Skip to content

Include settable dynamic properties in constructor #449

@t-kalinowski

Description

@t-kalinowski

If a property has a custom setter(), it should be possible to set the property by passing a value to the default constructor. A draft PR, #445, implements this.

However, before #445 can be merged, we need to resolve the question: Should the default constructor call the property setter? There are three possible answers: Always, Never, and Sometimes. Each answer is motivated by a compelling use case.

(Discussion on this question has been organic and spread across different threads, this is my attempt to collate and help us reach a decision)

Never call setters

A compelling use case here is a set-once, read-only thereafter property.

Person <- new_class("Person", properties = list(
  birth_name = new_property(
    class = class_character,
    setter = function(self, value) stop("Cannot set 'birth_name' again")
  )
))

person <- Person(birth_name = "John")

try(person@birth_name <- "Bob")  # Error: Cannot set 'birth_name' again

In this scenario, new_object() only sets the underlying property attributes using attributes<-, never the property setter. There would then need to be a mechanism for class authors to opt-in to running the setters. This could be via a new_class(initializer=) hook:

Person <- new_class("Person",
  initializer = function(self) {
    props(self) <- props(self) # opt-in to calling all prop `setter`s
    self
  })

Sometimes call setters

The compelling usage example here is of a deprecated property. If explicitly set by the user, we want the setter to run; otherwise, we don't.

Person <- new_class("Person", properties = list(
  first_name = new_property(class = class_character), 
  firstName = new_property(                          
    getter = function(self) {
      warning("@firstName is deprecated; use @first_name instead")
      self@first_name
    },
    setter = function(self, value) {
      warning("@firstName is deprecated; use @first_name instead")
      self@first_name <- value
      self
    }
  )
))

hadley <- Person(firstName = "Hadley")   # warning
hadley@firstName                         # warning
hadley@firstName <- "John"               # warning

hadley <- Person(first_name = "Hadley")  # no warning

With this path, new_object() would need to inspect the input value and only conditionally invoke the setter via @<-.

new_object <- function(...) {
  props <- list(...)
  for (name in names(props)) {
    val <- props[[name]]
    if (!is.null(val)) # or missing(), or ...
      prop(object, name) <- val
  }
  validate(object)
  object
}

If is.null() is too strong a check for invoking the setter, we could instead use missing() (and also, make the corresponding constructor formal value quote(expr=)).

For this "deprecated property" use case, we may also want to demote the property from being a named argument in the constructor and instead accept it in .... This would also implicitly make it "missing" if not provided, and never set.

Always call the setters

The compelling use case here is of a property setter that does some more involved initialization, argument coercion, etc.

Person <- new_class("Person", properties = list(
  birth_date = new_property(
    class = class_Date,
    setter = function(self, value) {
      self@birthdate <- as.Date(value) # coerce when setting
    }
  )
))

person <- Person(birthdate = "1999-01-01") # coerces character to Date

Property authors could "opt-out" of calling the setter by including the appropriate checks within setter.

For example, going back to the "set-once" example, the setter would now need to handle initialization too:

Person <- new_class("Person", properties = list(
  birth_name = new_property(
    class = class_character,
    setter = function(self, value) {
      if (is.null(self@birth_name)) { 
        # initializing
        self@birth_name <- value
        return(self)
      } 
      # not initializing 
      stop("Cannot set 'birth_name' again")
    }
  )
))

The same would apply to the "Deprecated Property" scenario, but with different constraints.
Care would need to be taken not to accidentally invoke the getter() from the setter(). This could be done either by checking with attr(self, "firstName", TRUE) instead of self@firstName, or by finding another approach, such as accepting a "don't warn" sentinel like NULL in the setter:

Person <- new_class("Person", properties = list(
  first_name = new_property(class = class_character), 
  firstName = new_property(                          
    getter = function(self) {
      warning("@firstName is deprecated; use @first_name instead")
      self@first_name
    },
    setter = function(self, value) {
      if(!is.null(value)
        warning("@firstName is deprecated; use @first_name instead")
      self@first_name <- value
      self
    }
  )
))

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions