Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Include Settable Properties in Default Constructor #445

Merged
merged 21 commits into from
Oct 1, 2024

Conversation

t-kalinowski
Copy link
Member

This patch implements the idea proposed in this comment and discussed in the last working group (WG) meeting: summarized here.

With this change, if a property has a setter function, the property will be included as an argument in the default class constructor, and the constructor will call the custom setter for that property.

@hadley
Copy link
Member

hadley commented Sep 19, 2024

Now that I see those failures, I'm pretty sure I tried doing this and that's exactly why I gave up 😆 Maybe we could somehow use the property default to determine if it gets turned into a constructor argument?

@t-kalinowski
Copy link
Member Author

Tests pass, but one of the examples in new_property() fails. Specifically, the example where we suggest using custom getter and setter functions to issue a warning that a property is deprecated. Now the deprecated property setter gets called on object construction.

# These can be useful if you want to deprecate a property
person <- new_class("person", properties = list(
  first_name = class_character,
  firstName = new_property(
     getter = function(self) {
       warning("@firstName is deprecated; please use @first_name instead", call. = FALSE)
       self@first_name
     },
     setter = function(self, value) {
       warning("@firstName is deprecated; please use @first_name instead", call. = FALSE)
       self@first_name <- value
       self
     }
   )
))
hadley <- person(first_name = "Hadley")
#> Error: <person>@first_name must be <character>, not <NULL>
#> In addition: Warning message:
#> @firstName is deprecated; please use @first_name instead 

Some alternatives for how to omit a property with a custom setter from the constructor:

  1. Require a custom constructor
  2. Use quote(expr=) as a sentinel that now means "don't include in constructor," instead of "include as required arg in constructor"
  3. Add an argument to new_property() like new_property(constructor_arg = TRUE | FALSE)

I think requiring option 1, a custom constructor here, is a reasonable solution.

@hadley
Copy link
Member

hadley commented Sep 19, 2024

The deprecated wrapper is such a nice idea and I'd really prefer not to have require a custom constructor for it. How do defaults work with settable properties currently?

@t-kalinowski
Copy link
Member Author

t-kalinowski commented Sep 19, 2024

We take whatever value is supplied to new_property(default = <value>) and use it as the default argument value when building the default constructor function. NULL is a special value that we may coerce to the correct type, but otherwise, we do no additional processing of the default value. This approach allows users to supply items like quoted language objects to create lazy or missing defaults.

@t-kalinowski
Copy link
Member Author

If we add a required() function, we might also consider adding an omit() function. For example:

Foo <- new_class("Foo", properties = list(
  x = new_property(default = required()),
  y = new_property(default = omit())
))

This would result in:

formals(Foo) == alist(x =)

@t-kalinowski
Copy link
Member Author

t-kalinowski commented Sep 20, 2024

Currently, the example doesn’t quite work. Adding a getter to deprecate a property also removes that property from the constructor, potentially breaking existing user code unless a custom constructor is provided.

Some other approaches:

1. Infer Initialization Context

We could modify the setter to "do nothing" when it receives a NULL value:

firstName = new_property(
  class = NULL | class_character, 
  setter = function(self, value) {
    if (is.null(value)) return()
    warning("@firstName is deprecated; please use @first_name instead")
    self@first_name <- value
    self
  }
)

This suggests a potential need for a general mechanism to detect when the object is being initialized:

setter = function(self, value) {
  if (initializing(self)) {
    # Handle property during initialization
  } else {
    # Handle property after initialization
  }
}

2. Ignore Missing Values in new_object()

Another approach is to make new_object() ignore missing values like quote(expr=). This would allow deprecation strategies such as:

Person <- new_class("Person", properties = list(
  first_name = class_character,
  firstName = new_property(
     class_character,
     getter = <unchanged>,
     setter = <unchanged>,
     default = quote(expr =)
   )
))

With this approach:

  • hadley <- Person(first_name = "Hadley") would work without error or warning.
  • hadley <- Person(firstName = "Hadley") would trigger the deprecation warning.

The constructor signature would then be:

args(Person) == function(first_name = character(), firstName) {}

Here, the meaning of a missing argument would change from "this property value must be supplied to the constructor" to "if omitted, this property value is not set." Enforcing that a property value is "required" would then be the job of the validator, which seems reasonable to me.

@hadley
Copy link
Member

hadley commented Sep 20, 2024

Thanks for exploring this! Your final idea sounds reasonable to me; I presume you'd start with a PR to make this the default for regular properties first?

@lawremi
Copy link
Collaborator

lawremi commented Sep 22, 2024

Thanks tackling this. I agree that the second approach is best. One minor thing about the example is that first_name should be added after firstName in case the user was relying on positional matching.

@t-kalinowski
Copy link
Member Author

How about this, starting from the current example in ?new_property:

Person <- new_class("Person", properties = list(
  <...unchanged...>
  default = quote(...)
))

Produces:

args(Person) == function(first_name = character(), ...) {}

The firstName$setter is not invoked by the constructor unless explicitly supplied as
a named argument to the constructor call.

R/constructor.R Outdated
@@ -63,6 +64,12 @@ constructor_args <- function(parent, properties = list()) {
function(name) prop_default(properties[[name]]))
)

is_dots <- vlapply(self_args, identical, quote(...))
if (any(is_dots)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe if ... is present it should always be the last argument? Or the first? Otherwise you end up with a mix of arguments that can/can't be supplied by position.

(I would also consider the idea that the first argument to every constructor should be ... just to force all arguments to be fully named)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the first argument to every constructor should be ...

I think this is going to be too annoying in practice. Many classes will only have a small handful of properties, and being unable to positionally match 1, 2, or 3 would be cumbersome.

Otherwise you end up with a mix of arguments that can/can't be supplied by position.

I think #446 might solve this by allowing the user to specify a property that is unset by default but still present as a named arg in the constructor formals that can match positionally.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case, I think you should make ... the last argument, if it's present.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the code so ... is added at the end of the constructor. Note, deprecating this way now will potentially change the position of later arguments E.g.,

# before deprecating: 
function(firstName, lastName, country)

# after deprecating `firstName` and `lastName`:
function(name, country, ...)
# whereas with the previous approach, this was possible:
function(name, ..., country)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should chat a bit more about this live, because I don't quite see the problem.

R/property.R Outdated Show resolved Hide resolved
Previously, the generated constructor had:
  new_object(, ... = ...)
It now has:
  new_object(, ...)
@t-kalinowski t-kalinowski marked this pull request as ready for review September 27, 2024 16:36
@t-kalinowski
Copy link
Member Author

t-kalinowski commented Sep 27, 2024

I've revised the PR to take a conservative approach, supporting the different use cases without introducing any new features that would warrant further discussion (e.g., the meaning of a missing or ... property default).

I updated the examples to show how to achieve all the different use cases in #449:

  • The "deprecated property" is implemented with a setter that silently accepts NULL.
  • The "required property" is implemented with a quoted error call.
  • The "read-only (frozen) property" is implemented with a custom setter.

In the future, we may add features that would make some of these examples more ergonomic, but I don't think we'd introduce any changes that would break them as they are currently written.

This PR is ready for another review and merge.

Copy link
Member

@hadley hadley left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great to me. Thanks for all your work on this!

R/class.R Outdated Show resolved Hide resolved
R/class.R Outdated Show resolved Hide resolved
R/class.R Show resolved Hide resolved
R/constructor.R Show resolved Hide resolved
R/class.R Outdated Show resolved Hide resolved
R/property.R Outdated Show resolved Hide resolved
R/property.R Outdated Show resolved Hide resolved
```{r}
Person <- new_class("Person", properties = list(
first_name = class_character,
firstName = new_property(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this firstName property be of class_character? Even though it is deprecated, we still want it to function properly. And I guess we would really want it to be a scalar. What happens if there is no reasonable default, as in this case? Could the default be made to be quote(first_name)? Then the setter avoids a warning if the value is the same as first_name.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I updated the example to show class = class_character, default = quote(first_name). The check in the setter is now identical(value, self@first_name).

Another approach would be to make the class NULL | character, and keep the simple is.null() check in the setter.

@lawremi
Copy link
Collaborator

lawremi commented Sep 28, 2024

I like the simplicity of this approach. Please just see the comment I made on the example.

@t-kalinowski t-kalinowski merged commit 21f82d7 into main Oct 1, 2024
11 checks passed
@t-kalinowski t-kalinowski deleted the include-dynamic-settable-props-in-constructor branch October 1, 2024 13:14
t-kalinowski added a commit that referenced this pull request Oct 1, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants