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 9 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
2 changes: 2 additions & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@
^pkgdown$
^cran-comments\.md$
^CRAN-SUBMISSION$
^compile_commands\.json$
^\.cache$
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ script.R
inst/doc
docs
.Rhistory
compile_commands.json
.cache
fetch_github_issue.py
.Rbuildignore
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
12 changes: 4 additions & 8 deletions R/base.R
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
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 <- as.function.default(list(.data = base_default(name), quote(.data)),
t-kalinowski marked this conversation as resolved.
Show resolved Hide resolved
baseenv())

validator <- function(object) {
if (base_class(object) != name) {
Expand Down Expand Up @@ -35,8 +31,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
41 changes: 39 additions & 2 deletions R/class-spec.R
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ class_friendly <- function(x) {
)
}

class_constructor <- function(.x, ...) {
class_constructor <- function(.x) {
switch(class_type(.x),
NULL = function() NULL,
any = function() NULL,
Expand All @@ -92,8 +92,45 @@ class_constructor <- function(.x, ...) {
stop(sprintf("Can't construct %s", class_friendly(.x)), call. = FALSE)
)
}

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.)
fb <- body(f)
ff <- formals(f)
fe <- environment(f)

# early return if not safe to unwrap
if(length(list(...)))
return(as.call(list(f, ...)))
t-kalinowski marked this conversation as resolved.
Show resolved Hide resolved

if(!identical(fe, baseenv()))
return(as.call(list(f, ...)))

if("new_object" %in% all.names(fb))
t-kalinowski marked this conversation as resolved.
Show resolved Hide resolved
return(as.call(list(f, ...)))

# maybe unwrap if body 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.
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_construct <- function(.x, ...) {
class_constructor(.x)(...)
eval(class_construct_expr(.x, ...))
}

class_validate <- function(class, object) {
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
38 changes: 20 additions & 18 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,7 +46,8 @@ new_constructor <- function(parent, properties) {
}

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

self_args <- names2(properties)
# Remove dynamic arguments
Expand All @@ -55,22 +58,21 @@ constructor_args <- function(parent, properties = list()) {
self_args <- setdiff(self_args, names2(parent@properties))
}

list(
parent = parent_args,
self = self_args
)
self_args <- lapply(setNames(nm = self_args),
function(name) prop_default(properties[[name]]))
parent_args <- parent_constructor_formals %||%
structure(list(), names = character())

list(parent = as.list(parent_args),
self = as.list(self_args))
}

is_property_dynamic <- function(prop) is.function(x$getter)

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

new_function <- function(args, body, env) {
f <- function() {}
formals(f) <- args
body(f) <- body
environment(f) <- env
attr(f, "srcref") <- NULL

f
as.function.default(c(args, body), env)
hadley marked this conversation as resolved.
Show resolved Hide resolved
}
missing_args <- function(names) {
lapply(setNames(, names), function(i) quote(class_missing))
Expand Down
2 changes: 1 addition & 1 deletion R/property.R
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,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
20 changes: 13 additions & 7 deletions R/utils.R
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,9 @@ show_function <- function(x, constructor = FALSE) {
args <- formals(x)

if (constructor) {
args <- lapply(args, function(x) {
if (identical(x, quote(class_missing))) {
quote(expr = )
} else {
x
}
})
# don't show the defaults arg values in the constructor, keep it compact
# TODO: do show the default values next to properties in class printouts.
t-kalinowski marked this conversation as resolved.
Show resolved Hide resolved
args <- lapply(args, function(q) quote(expr =))
}

show_args(args, suffix = " {...}")
Expand All @@ -136,6 +132,16 @@ show_args <- function(x, name = "function", suffix = "") {
paste0(name, "(", args, ")", suffix)
}

modify_list <- function (x, new_vals) {
stopifnot(is.list(x))

for (name in names(new_vals))
x[name] <- new_vals[name]

x
}


# For older versions of R ----------------------------------------------------
deparse1 <- function(expr, collapse = " ", width.cutoff = 500L, ...) {
paste(deparse(expr, width.cutoff, ...), collapse = collapse)
Expand Down
8 changes: 4 additions & 4 deletions R/valid.R
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,14 @@ validate <- function(object, recursive = TRUE, properties = TRUE) {
validate_properties <- function(object, class) {
errors <- character()

for (prop in class@properties) {
for (prop_obj in class@properties) {
Copy link
Member

Choose a reason for hiding this comment

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

I like how this name change clarifies what's going on here.

# Don't validate dynamic properties
if (!is.null(prop$getter)) {
if (!is.null(prop_obj$getter)) {
next
}

value <- prop(object, prop$name)
errors <- c(errors, prop_validate(prop, value))
value <- prop(object, prop_obj$name)
errors <- c(errors, prop_validate(prop_obj, value))
}

errors
Expand Down
43 changes: 28 additions & 15 deletions R/zzz.R
Original file line number Diff line number Diff line change
Expand Up @@ -88,25 +88,36 @@ check_subsettable <- function(x, allow_env = FALSE) {
invisible(TRUE)
}

S7_generic <- new_class(
name = "S7_generic",
properties = list(
name = class_character,
methods = class_environment,
dispatch_args = class_character
),
parent = class_function
)
S7_generic <- NULL

on_load_define_S7_generic <- function() {
# we do this in .onLoad() because dynlib `prop_` symbol
# is not available at pkg build time, and new_class()
# errors if `@` is not usable.
S7_generic <<- new_class(
name = "S7_generic",
properties = list(
name = class_character,
methods = class_environment,
dispatch_args = class_character
),
parent = class_function
)
}

methods::setOldClass(c("S7_generic", "function", "S7_object"))
is_S7_generic <- function(x) inherits(x, "S7_generic")

S7_method <- new_class("S7_method",
parent = class_function,
properties = list(
generic = S7_generic,
signature = class_list

S7_method <- NULL

on_load_define_S7_method <- function() {
S7_method <<- new_class(
"S7_method",
parent = class_function,
properties = list(generic = S7_generic, signature = class_list)
)
)
}
methods::setOldClass(c("S7_method", "function", "S7_object"))

# hooks -------------------------------------------------------------------
Expand All @@ -121,6 +132,8 @@ methods::setOldClass(c("S7_method", "function", "S7_object"))
.onLoad <- function(...) {
activate_backward_compatiblility()

on_load_define_S7_generic()
on_load_define_S7_method()
on_load_make_convert_generic()
on_load_define_ops()
on_load_define_or_methods()
Expand Down
2 changes: 2 additions & 0 deletions man/class_missing.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tests/testthat/_snaps/class.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
.. ..$ validator: NULL
.. ..$ default : NULL
@ abstract : logi FALSE
@ constructor: function (x = class_missing, y = class_missing)
@ constructor: function (x = integer(0), y = integer(0))
@ validator : NULL
Code
str(list(foo2))
Expand Down
Loading
Loading