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

Updates to default constructor #438

Merged
merged 30 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1d3a5f6
Update Roxygen version to 7.3.2; `pkgload:::use_compilation_db()`
t-kalinowski Sep 8, 2024
ab1baf7
use `class_missing` in `class_missing` example
t-kalinowski Sep 8, 2024
97be220
ignore dev artifacts
t-kalinowski Sep 8, 2024
20d740e
define `S7_generic()` and `S7_method()` in `.onLoad()`
t-kalinowski Sep 8, 2024
208fb4e
fix `prop` name collision
t-kalinowski Sep 8, 2024
434fa52
change constructor arg defaults
t-kalinowski Sep 8, 2024
b9cbad0
some effort to keep printing reasonably pretty
t-kalinowski Sep 8, 2024
7ee32dd
update tests
t-kalinowski Sep 8, 2024
d45f394
more test updates
t-kalinowski Sep 8, 2024
eac36e4
allow `new_property(default = <call|missing>)`
t-kalinowski Sep 9, 2024
ad3b0b2
test tweak
t-kalinowski Sep 9, 2024
159de6f
style changes and other tweaks
t-kalinowski Sep 9, 2024
c136f3d
more style changes; reorder fn defs
t-kalinowski Sep 9, 2024
1ea6ebe
simplify `modify_list()`
t-kalinowski Sep 9, 2024
257eeb9
return `pairlist()`s from `constructor_args()`
t-kalinowski Sep 9, 2024
4c0c6ed
delete `nlist` / `named_list()` helper
t-kalinowski Sep 9, 2024
ec1b022
remove accidental `.gitignore` entries
t-kalinowski Sep 9, 2024
d717400
accept `class_missing` as a prop.
t-kalinowski Sep 9, 2024
e7d792f
allow symbol arg defaults
t-kalinowski Sep 10, 2024
d069a8f
add tests
t-kalinowski Sep 10, 2024
562af65
update `new_property()` docs
t-kalinowski Sep 10, 2024
095e93a
add NEWS
t-kalinowski Sep 10, 2024
7b8982f
add more `new_property()` doc examples
t-kalinowski Sep 10, 2024
9fcf3d7
helper tweak
t-kalinowski Sep 10, 2024
83ab2cb
Update R/property.R
t-kalinowski Sep 10, 2024
f381fca
Update R/property.R
t-kalinowski Sep 10, 2024
f264c39
Add suggested doc changes.
t-kalinowski Sep 10, 2024
65c55d7
Merge branch 'main' into new-constructor-defaults
t-kalinowski Sep 10, 2024
299ae86
move `%||%` into `aaa.R`
t-kalinowski Sep 10, 2024
9cfbd6e
Can't use `\()` yet.
t-kalinowski Sep 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@
^pkgdown$
^cran-comments\.md$
^CRAN-SUBMISSION$
^compile_commands\.json$
^\.cache$
^\.vscode$
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ script.R
inst/doc
docs
.Rhistory
compile_commands.json
.cache
.vscode
3 changes: 2 additions & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,5 @@ Config/testthat/parallel: TRUE
Config/testthat/start-first: external-generic
Encoding: UTF-8
Roxygen: list(markdown = TRUE)
RoxygenNote: 7.3.1
RoxygenNote: 7.3.2
Config/build/compilation-database: true
6 changes: 6 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# S7 (development version)

* The default object constructor returned by `new_class()` has been updated.
It now accepts missing and lazy (promise) property defaults. Additionally,
all custom property setters are now consistently invoked by the default
constructor. If you're using S7 in an R package, you'll need to re-document
to ensure that your docs match the updated usage (#438).

* Fixed an issue where a custom property `getter()` would infinitely recurse
when accessing itself (reported in #403, fixed in #406).

Expand Down
9 changes: 9 additions & 0 deletions R/aaa.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@


`%||%` <- function(x, y) if (is.null(x)) y else x

new_function <- function(args = NULL,
body = NULL,
env = asNamespace("S7")) {
as.function.default(c(args, body) %||% list(NULL), env)
}
18 changes: 9 additions & 9 deletions R/base.R
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
new_base_class <- function(name, constructor_name = name) {
force(name)

constructor <- function(.data = class_missing) {
if (is_class_missing(.data)) {
.data <- base_default(name)
}
.data
}
constructor <- new_function(
args = list(.data = base_default(name)),
body = quote(.data),
env = baseenv()
)

validator <- function(object) {
if (base_class(object) != name) {
sprintf("Underlying data must be <%s> not <%s>", name, base_class(object))
sprintf("Underlying data must be <%s> not <%s>",
name, base_class(object))
}
}

Expand All @@ -35,8 +35,8 @@ base_default <- function(type) {
list = list(),
expression = expression(),
Copy link
Member

Choose a reason for hiding this comment

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

Should this now be quote(expression())? (Not sure, as I haven't yet read the code that evaluates quoted defaults).

Copy link
Member

Choose a reason for hiding this comment

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

Or maybe they never get evaluated, they just get inlined into the functions arguments? That is potentially confusing because R won't display a argument with default 1:10 and quote(1:10) differently. Or if it's a more complex object (like a data.frame), deparsing might be confusing? But maybe that just suggests that quoting the defaults should be the rule, not the exception?

Copy link
Collaborator

Choose a reason for hiding this comment

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

.... because R won't display a argument with default 1:10 and quote(1:10) differently.

Huh? not really .. (I assume you meant something else...) :

> ff <- function(x = 1:10, y = quote(1:10)) { x - eval(y) }
> ff()
 [1] 0 0 0 0 0 0 0 0 0 0
> ff
function(x = 1:10, y = quote(1:10)) { x - eval(y) }
> args(ff)
function (x = 1:10, y = quote(1:10)) 
NULL
> 

Copy link
Member

@hadley hadley Sep 9, 2024

Choose a reason for hiding this comment

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

I meant this problem:

f <- function() {}
formals(f) <- list(x = 1:10, y = quote(1:10))
str(f)
#> function (x = 1:10, y = 1:10)

Created on 2024-09-09 with reprex v2.1.0

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 think this might be what @hadley is describing (please confirm)

# literal constants get inlined
f <- function(x = 1) x
typeof(formals(f)$x)
#> [1] "double"

# expressions stay expressions in 'normally' defined functions
f <- function(x = 1:10) x
typeof(formals(f)$x)
#> [1] "language"
f
#> function(x = 1:10) x

# but, generated functions with arguments that cannot be generated 
# as a literal by the parser present the same as "normally" defined functions
formals(f)$x <- 1:10
typeof(formals(f)$x)
#> [1] "integer"
f # prints the same as before, even though x is no longer type 'language'
#> function (x = 1:10) 
#> x

Created on 2024-09-09 with reprex v2.1.1

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think you don't use formals<- properly -- you must use alist() :

> a1 <- alist(a=1:10); a2 <- alist(a = quote(1:10)); identical(a1, a2)
[1] FALSE
> f1 <- f2 <- function(){}; formals(f1) <- a1; formals(f2) <- a2; identical(f1,f2)
[1] FALSE
> str(f1)
function (a = 1:10)  
> str(f2)
function (a = quote(1:10))  
> 

Copy link
Member

Choose a reason for hiding this comment

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

@mmaechler this is an issue with creating functions in non-standard ways, so we definitely want to use list() here.

@t-kalinowski while inlining objects into the constructor args directly will generally be safe, I think it's going to potentially cause a lot of confusion.

Copy link
Member Author

@t-kalinowski t-kalinowski Sep 9, 2024

Choose a reason for hiding this comment

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

On my first pass at this (unpushed), I did have all branches in base_default() wrapped in quote(...). I reverted it once I realized it was unnecessary.

Also, it's about 3x faster to call a constructor with an inlined value (I'm not sure how much this matters here; in absolute numbers, these are both very fast).

f1 <- f2 <- function(x = integer()) x
formals(f2)$x <- integer()

bench::mark(f1(), f2()) |> print() |> plot() |> print()
#> # A tibble: 2 × 13
#>   expression      min median `itr/sec` mem_alloc `gc/sec` n_itr  n_gc total_time
#>   <bch:expr> <bch:tm> <bch:>     <dbl> <bch:byt>    <dbl> <int> <dbl>   <bch:tm>
#> 1 f1()          246ns  328ns  1937884.        0B        0 10000     0     5.16ms
#> 2 f2()           41ns  123ns  5988817.        0B        0 10000     0     1.67ms
#> # ℹ 4 more variables: result <list>, memory <list>, time <list>, gc <list>
#> Loading required namespace: tidyr

Copy link
Member

Choose a reason for hiding this comment

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

I do think that this is more of a user facing documentation issue — i.e. I think we should generally recommend that the user wrap their default values in quote, unless they really know what they're doing.

Copy link
Member Author

Choose a reason for hiding this comment

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

We can use NSE for new_property(default) and automatically quote default. We could then allow using I() as an NSE escape hatch.

That said, as I consider it, I'm coming to the conclusion that using NSE here isn't the best approach. The laziness of a default value should be explicit, clearly communicated to readers, and opt-in for authors.

I agree we'll want to update docs to discuss this and encourage usage of quote().


`function` = function() {},
environment = new.env(parent = emptyenv())
`function` = quote(function() {}),
environment = quote(new.env(parent = emptyenv()))
)}


Expand Down
78 changes: 66 additions & 12 deletions R/class-spec.R
Original file line number Diff line number Diff line change
Expand Up @@ -80,21 +80,75 @@ class_friendly <- function(x) {
)
}

class_constructor <- function(.x, ...) {
class_construct <- function(.x, ...) {
eval(class_construct_expr(.x, ...))
}


class_construct_expr <- function(.x, ...) {
t-kalinowski marked this conversation as resolved.
Show resolved Hide resolved
f <- class_constructor(.x)
# If the constructor is a closure wrapping a simple expression, try
# to extract the expression
# (mostly for nicer printing and introspection.)

## early return if not safe to unwrap
# can't unwrap if we're passing on ...
if(...length()) {
return(as.call(list(f, ...)))
}

# can't unwrap if the closure is potentially important
# (this can probably be relaxed to allow additional environments)
fe <- environment(f)
if(!identical(fe, baseenv())) {
return(as.call(list(f, ...)))
}

# special case for `class_missing`
if (identical(body(f) -> fb, quote(expr =))) {
return(quote(expr =))
}

# `new_object()` must be called from the class constructor, can't
# be safely unwrapped
if("new_object" %in% all.names(fb)) {
return(as.call(list(f, ...)))
}

# maybe unwrap body if it is a single expression wrapped in `{`
if (length(fb) == 2L && identical(fb[[1L]], quote(`{`)))
fb <- fb[[2L]]

# If all the all the work happens in the promise to the `.data` arg,
# return the `.data` expression.
ff <- formals(f)
if ((identical(fb, quote(.data))) &&
identical(names(ff), ".data")) {
return(ff$.data)
}

# if all the work happens in the function body, return the body.
if (is.null(ff)) {
return(fb)
}

#else, return a call to the constructor
as.call(list(f, ...))
}

class_constructor <- function(.x) {
switch(class_type(.x),
NULL = function() NULL,
any = function() NULL,
S4 = function(...) methods::new(.x, ...),
S7 = .x,
S7_base = .x$constructor,
S7_union = class_constructor(.x$classes[[1]]),
S7_S3 = .x$constructor,
stop(sprintf("Can't construct %s", class_friendly(.x)), call. = FALSE)
NULL = function() NULL,
missing = new_function(, quote(expr =), baseenv()),
any = function() NULL,
S4 = function(...) methods::new(.x, ...),
S7 = .x,
S7_base = .x$constructor,
S7_union = class_constructor(.x$classes[[1]]),
S7_S3 = .x$constructor,
stop(sprintf("Can't construct %s", class_friendly(.x)), call. = FALSE)
)
}
class_construct <- function(.x, ...) {
class_constructor(.x)(...)
}

class_validate <- function(class, object) {
validator <- switch(class_type(class),
Expand Down
14 changes: 3 additions & 11 deletions R/class.R
Original file line number Diff line number Diff line change
Expand Up @@ -260,17 +260,9 @@ new_object <- function(.parent, ...) {
attr(object, "S7_class") <- class
class(object) <- class_dispatch(class)

supplied_props <- nms[!vlapply(args, is_class_missing)]
for (prop in supplied_props) {
prop(object, prop, check = FALSE) <- args[[prop]]
}

# We have to fill in missing values after setting the initial properties,
# because custom setters might set property values
missing_props <- setdiff(nms, union(supplied_props, names(attributes(object))))
for (prop in missing_props) {
prop(object, prop, check = FALSE) <- prop_default(class@properties[[prop]])
}
# Set properties. This will potentially invoke custom property setters
Copy link
Member

Choose a reason for hiding this comment

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

This is a lot simpler 😄

for (name in names(args))
prop(object, name, check = FALSE) <- args[[name]]

# Don't need to validate if parent class already validated,
# i.e. it's a non-abstract S7 class
Expand Down
42 changes: 21 additions & 21 deletions R/constructor.R
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
new_constructor <- function(parent, properties) {
properties <- as_properties(properties)
arg_info <- constructor_args(parent, properties)
self_args <- as_names(arg_info$self, named = TRUE)
self_args <- as_names(names(arg_info$self), named = TRUE)

if (identical(parent, S7_object) || (is_class(parent) && parent@abstract)) {
return(new_function(
args = missing_args(arg_info$self),
args = arg_info$self,
body = new_call("new_object", c(list(quote(S7_object())), self_args)),
env = asNamespace("S7")
))
Expand All @@ -13,16 +14,16 @@ new_constructor <- function(parent, properties) {
if (is_class(parent)) {
parent_name <- parent@name
parent_fun <- parent
args <- missing_args(union(arg_info$parent, arg_info$self))
args <- modify_list(arg_info$parent, arg_info$self)
} else if (is_base_class(parent)) {
parent_name <- parent$constructor_name
parent_fun <- parent$constructor
args <- missing_args(union(arg_info$parent, arg_info$self))
args <- modify_list(arg_info$parent, arg_info$self)
} else if (is_S3_class(parent)) {
parent_name <- paste0("new_", parent$class[[1]])
parent_fun <- parent$constructor
args <- formals(parent$constructor)
args[arg_info$self] <- missing_args(arg_info$self)
args[names(arg_info$self)] <- arg_info$self
} else {
# user facing error in S7_class()
stop("Unsupported `parent` type", call. = FALSE)
Expand All @@ -33,7 +34,8 @@ new_constructor <- function(parent, properties) {
args[names(args) == "..."] <- list(quote(expr = ))
}

parent_args <- as_names(arg_info$parent, named = TRUE)
parent_args <- as_names(names(arg_info$parent), named = TRUE)
names(parent_args)[names(parent_args) == "..."] <- ""
parent_call <- new_call(parent_name, parent_args)
body <- new_call("new_object", c(parent_call, self_args))

Expand All @@ -44,34 +46,32 @@ new_constructor <- function(parent, properties) {
}

constructor_args <- function(parent, properties = list()) {
parent_args <- names2(formals(class_constructor(parent)))
parent_args <- formals(class_constructor(parent))

self_args <- names2(properties)
self_arg_nms <- names2(properties)
# Remove dynamic arguments
self_args <- self_args[vlapply(properties, function(x) is.null(x$getter))]
self_arg_nms <- self_arg_nms[vlapply(properties, function(x) is.null(x$getter))]

if (is_class(parent) && !parent@abstract) {
# Remove any parent properties; can't use parent_args() since the constructor
# might automatically set some properties.
self_args <- setdiff(self_args, names2(parent@properties))
self_arg_nms <- setdiff(self_arg_nms, names2(parent@properties))
}

list(
parent = parent_args,
self = self_args
self_args <- as.pairlist(lapply(
setNames(, self_arg_nms),
function(name) prop_default(properties[[name]]))
)

list(parent = parent_args,
self = self_args)
}


# helpers -----------------------------------------------------------------

new_function <- function(args, body, env) {
f <- function() {}
formals(f) <- args
body(f) <- body
environment(f) <- env
attr(f, "srcref") <- NULL
is_property_dynamic <- function(x) is.function(x$getter)

f
}
missing_args <- function(names) {
lapply(setNames(, names), function(i) quote(class_missing))
}
Expand Down
43 changes: 37 additions & 6 deletions R/property.R
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
#'
#' By specifying a `getter` and/or `setter`, you can make the property
#' "dynamic" so that it's computed when accessed or has some non-standard
#' behaviour when modified.
#' behaviour when modified. Dynamic properties are not included as an argument
#' to the default class constructor.
#'
#' @param class Class that the property must be an instance of.
#' See [as_class()] for details.
Expand All @@ -31,8 +32,10 @@
#' The validator will be called after the `class` has been verified, so
#' your code can assume that `value` has known type.
#' @param default When an object is created and the property is not supplied,
#' what should it default to? If `NULL`, defaults to the "empty" instance
#' of `class`.
#' what should it default to? If `NULL`, it defaults to the "empty" instance
#' of `class`. This can also be a quoted call, which then becomes a standard
#' function promise in the default constructor, evaluated at the time the
#' object is constructed.
#' @param name Property name, primarily used for error messages. Generally
#' don't need to set this here, as it's more convenient to supply as a
#' the element name when defining a list of properties. If both `name`
Expand All @@ -59,9 +62,14 @@
#' my_clock <- clock()
#' my_clock@now; Sys.sleep(1)
#' my_clock@now
#' # This property is read only
#' # This property is read only, because there is a 'getter' but not a 'setter'
#' try(my_clock@now <- 10)
#'
#' # Because the property is dynamic, it is not included as an
#' # argument to the default constructor
#' try(clock(now = 10))
#' args(clock)
#'
#' # These can be useful if you want to deprecate a property
#' person <- new_class("person", properties = list(
#' first_name = class_character,
Expand All @@ -81,14 +89,37 @@
#' hadley@firstName
#' hadley@firstName <- "John"
#' hadley@first_name
#'
#' # Properties can have default values that are quoted calls.
#' # These become standard function promises in the default constructor,
#' # evaluated at the time the object is constructed.
t-kalinowski marked this conversation as resolved.
Show resolved Hide resolved
#' stopwatch <- new_class("stopwatch", properties = list(
#' starttime = new_property(class = class_POSIXct, default = quote(Sys.time())),
#' totaltime = new_property(getter = function(self)
#' difftime(Sys.time(), self@starttime, units = "secs"))
#' ))
#' args(stopwatch)
#' round(stopwatch()@totaltime)
#' round(stopwatch(Sys.time() - 1)@totaltime)
#'
#' # Properties can also have a 'missing' default value, making them
#' # required arguments to the default constructor.
#' # You can generate a missing arg with `quote(expr =)` or `rlang::missing_arg()`
Copy link
Member

Choose a reason for hiding this comment

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

Should S7 provide missing_arg() too? Could call it required()?

Copy link
Member Author

Choose a reason for hiding this comment

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

required() would be a nice companion to nullable(), proposed in #433.

#' Person <- new_class("Person", properties = list(
#' name = new_property(class_character, default = quote(expr = ))
#' ))
#' try(Person())
#' Person("Alice")
new_property <- function(class = class_any,
getter = NULL,
setter = NULL,
validator = NULL,
default = NULL,
name = NULL) {
class <- as_class(class)
if (!is.null(default) && !class_inherits(default, class)) {
if (!is.null(default) &&
!(is.call(default) || is.symbol(default)) && # allow promises
!class_inherits(default, class)) {
msg <- sprintf("`default` must be an instance of %s, not a %s", class_desc(class), obj_desc(default))
stop(msg)
}
Expand Down Expand Up @@ -131,7 +162,7 @@ str.S7_property <- function(object, ..., nest.lev = 0) {
}

prop_default <- function(prop) {
prop$default %||% class_construct(prop$class)
prop$default %||% class_construct_expr(prop$class)
}

#' Get/set a property
Expand Down
2 changes: 2 additions & 0 deletions R/special.R
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
#' @examples
#' foo <- new_generic("foo", "x")
#' method(foo, class_numeric) <- function(x) "number"
#' method(foo, class_missing) <- function(x) "missing"
#' method(foo, class_any) <- function(x) "fallback"
#'
#' foo(1)
#' foo()
#' foo("")
class_missing <- structure(list(), class = "S7_missing")

is_class_missing <- function(x) inherits(x, "S7_missing")
Expand Down
Loading
Loading