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

External S7 classes #341

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open

External S7 classes #341

wants to merge 17 commits into from

Conversation

hadley
Copy link
Member

@hadley hadley commented Sep 9, 2023

Prior to this PR, if class A in package A extends class B in package B, class A will include a copy of the definition of class B at build-time. Methods are not also copied, so if class B is later updated and package B reinstalled, it's possible to end up with in an inconsistent state where you have the class definition from version 1 and the methods from version 2.

This PR adds a new "external class" object designed to ameliorate this problem by doing more work at run-time rather at build-time. For example, the constructor of class B will now have arguments ... and it will figure out which arguments go to which constructor at run-time (we prefer to do this at build time in order to generate a constructor with named arguments that can be auto-completed etc).

Fixes #317

@hadley

This comment was marked as outdated.

@hadley hadley changed the title First stab at a dynamic class First stab at an external class Sep 10, 2023
@hadley hadley changed the title First stab at an external class External S7 classes Sep 11, 2023
@hadley
Copy link
Member Author

hadley commented Sep 11, 2023

To do:

  • Figure out how to test this
  • Avoid inlining the parent properties at build-time, possibly by setting to some sentinel value that indicates they're dynamic and then replacing class@properties with a call to a helper.

@hadley hadley marked this pull request as ready for review September 14, 2023 13:24
@hadley hadley requested review from t-kalinowski and DavisVaughan and removed request for t-kalinowski September 14, 2023 13:25
@hadley
Copy link
Member Author

hadley commented Sep 14, 2023

@DavisVaughan @t-kalinowski this will need discussion with the full committee before we merge it, but I'd love to get your initial thoughts on both the overall idea and the implementation.

Comment on lines +118 to +119
if (!is.null(package) && is_class(parent)) {
if (!is.null(parent@package) && !identical(parent@package, package)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does it need to be a nested if?

@@ -128,30 +136,44 @@ new_class <- function(
}

# Combine properties from parent, overriding as needed
Copy link
Collaborator

Choose a reason for hiding this comment

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

This comment may be better placed inside inherit_properties() now


constructor <- constructor_fun()
if (!is_class(constructor)) {
stop("`constructor_fun()` must yield an S7 class")
Copy link
Collaborator

Choose a reason for hiding this comment

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

This might be a fairly esoteric error if you don't actually provide constructor_fun and instead provided package and name incorrectly


constructor <- constructor_fun()
if (!is_class(constructor)) {
stop("`constructor_fun()` must yield an S7 class")
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
stop("`constructor_fun()` must yield an S7 class")
stop("`constructor_fun()` must return an S7 class")

#' Classes from other packages
#'
#' @description
#' You need an explicit external class when you want extend a class defined in
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
#' You need an explicit external class when you want extend a class defined in
#' You need an explicit external class when you want to extend a class defined in

#' when the other package changes your package doesn't need to be rebuilt to
#' get those changes.
#'
#' [new_class()] will automatically convert an S7 class to an external class
Copy link
Collaborator

Choose a reason for hiding this comment

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

It automatically converts the parent right?

@@ -316,7 +317,7 @@ prop_names <- function(object) {
c("name", "parent", "package", "properties", "abstract", "constructor", "validator")
} else {
class <- S7_class(object)
props <- attr(class, "properties", exact = TRUE)
props <- attr(class, "properties", exact = TRUE)()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is it worth adding class_properties() or something similar that does this, and maybe adds a comment about the fact that properties are dynamic to support external classes? I think this is called in at least 3 places

@ properties :List of 2
.. $ x: <S7_property>
.. ..$ name : chr "x"
.. ..$ class : <S7_base_class>: <integer>
.. ..$ getter : NULL
.. ..$ setter : NULL
.. ..$ validator: NULL
.. ..$ default : NULL
.. $ y: <S7_property>
.. ..$ name : chr "y"
.. ..$ class : <S7_base_class>: <integer>
.. ..$ getter : NULL
.. ..$ setter : NULL
.. ..$ validator: NULL
.. ..$ default : NULL
@ properties : function ()
Copy link
Collaborator

Choose a reason for hiding this comment

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

I guess this reduction in info is expected because it is dynamic now?

}
}
)
expect_error(foo2(x = -1), "invalid")
Copy link
Collaborator

Choose a reason for hiding this comment

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

Worth a snapshot or no?

}
}
dynamic_args <- function(args, selected) {
missing <- setdiff(selected, names(args))
Copy link
Collaborator

Choose a reason for hiding this comment

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

It took me a few minutes to figure this out, maybe add a comment like

# Pad `args` with currently missing arguments

Copy link
Member

@t-kalinowski t-kalinowski left a comment

Choose a reason for hiding this comment

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

The motivation for this PR very clear - the current situation is problematic and can easily lead to problems as packages get updated in a user library. The proposed implementation comes w/ some tradeoffs, (most of which can be mitigated w/ more code). It's worth spelling some of these out:

  • Needing to call class@properties() as a function is inconsistent w/ how the other class@ attributes are accessed. A new user might even call it surprising, (and also, syntactically noisy). Because this is a user-facing interface, I think we should explore alternative implementations that still lets us inject dynamic properties as needed, w/o converting this property to a closure.

  • The loss of args names in constructor functions in favor of ... is, I think, a big loss. It means users lose interactive autocomplete suggestions (w/o substantial work to compensate in the IDE) E.g., i imagine across tidyverse/tidymodels, many constructors will live in external packages.

  • Looking at the example in new_external_class() raises a few questions (mostly related to print()):

> foo <- new_class("foo", properties = list(x = class_integer))
> foo_ex <- new_external_class("S7", "foo", function() foo)
> foo2 <- new_class("foo", parent = foo_ex)
> foo2
<foo> class
@ parent : <S7::foo>
@ constructor: function(...) {...}
@ validator : <NULL>
@ properties :
 $ x: <integer>
> foo2()
<foo>
 @ x: int(0)
  • Should a properties origin be presented here. When I'm working w/ a subclassed class, I typically want to quickly know what is "new" or "different" from the base class, and having that information presented by default print() would go a long way.
  • The ... signature of the constructor is unfortunate. Would it make sense to include at least the "new" constructor args in the signature? For example, if foo2 had a property y, defined as new_class("foo", properties(list(y = class_character)), parent = foo_ex), then perhaps the constructor signature could be (..., y), with ... being forwarded to the parent constructor.
  • (side questions, not directly related to this PR: why are we presenting an empty class validator property?)

Some stray thoughts:

  • Would it make sense to convert @properties to a locked and sealed environment, rather than a list? Then the environment could be populated once via a call in .onLoad(), similar to the way we recommend registering S7 methods. (also, opens the door for active bindings)
  • Could other work be done in .onLoad()? Perhaps, building the default constructor with the proper signature? Or catch and warn about edge cases when pkgA must be reinstalled due to an update in pkgB?

@lawremi
Copy link
Collaborator

lawremi commented Jul 24, 2024

An alternative approach would be to record in the namespace every parent class passed to new_class() that is from another package. At namespace load, we simply check whether all of the cached classes are the same (by whatever definition we want) as those currently defined in their original package. If the check fails, we ask the user to reinstall the package. It's the safest approach, because it might be that the package needs to be updated anyway.

This is similar to how some C programs check for ABI breakage by comparing struct sizes with those compiled into a library.

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.

Extending a class from another package
4 participants