Skip to content

Commit d9e8faa

Browse files
authored
Consistent ... handling (#655)
* Allow `...` to be a single unnamed list in `new_object()`, `set_props()` and `convert()`. Fixes #497. * Use `_` prefix in `new_object()` and `set_props()` to avoid accidental matches to existing properties. Fixes #423.
1 parent eb1b1b0 commit d9e8faa

15 files changed

Lines changed: 243 additions & 42 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
name: property-dots
3+
description: >
4+
Guide for writing functions that accept property name-value pairs via
5+
`...`. Use when adding a function whose `...` collects property values.
6+
---
7+
8+
# Accept properties via `...`
9+
10+
Use this skill when writing a new function that collects property name-value pairs
11+
through `...`, e.g. ``new_object(`_parent`, ...)`` or ``set_props(`_object`, ...)``.
12+
13+
Two problems show up whenever `...` carries property values:
14+
15+
1. A fixed argument can shadow a property of the same name. If `set_props()`
16+
had a formal called `object`, then `set_props(x, object = 1)` would bind
17+
`object` to the formal instead of treating it as the property `object`.
18+
2. Callers sometimes want to supply property values programmatically, as a
19+
single list, rather than as individual `name = value` pairs.
20+
21+
`collect_dots()` and the underscore naming convention solve these.
22+
23+
For example, take (a simple version of) `update_props()`
24+
25+
```r
26+
update_props <- function(`_object`, ...) {
27+
props(`_object`) <- collect_dots(...)
28+
`_object`
29+
}
30+
31+
# Both work
32+
update_props(x, object = 1, width = 10)
33+
update_props(x, list(object = 1, width = 10))
34+
```
35+
36+
## The underscore convention
37+
38+
Property names starting with `_` are reserved for internal use. This lets you name a fixed argument with a leading `_` so it can never collide with a real property passed through `...`.
39+
40+
- ``new_object(`_parent`, ...)```_parent` is the parent object.
41+
- ``set_props(`_object`, ...)```_object` is the object to modify.
42+
43+
`_parent` and `_object` are not syntactically valid names, so they **must be
44+
backtick-quoted** everywhere they appear:
45+
46+
```r
47+
set_props <- function(`_object`, ..., .check = TRUE) {
48+
props(`_object`, check = .check) <- collect_dots(...)
49+
`_object`
50+
}
51+
```
52+
53+
Because these arguments are always passed positionally (and generated
54+
constructors pass the parent positionally), the unusual name never burdens
55+
callers — it only stops the name from competing with `...`.
56+
57+
Apply the convention when:
58+
59+
- The function takes properties via `...`, AND
60+
- It also needs a fixed argument that a user might plausibly use as a property
61+
name (`object`, `parent`, `value`, ...).
62+
63+
64+
## Collecting the dots with `collect_dots()`
65+
66+
`collect_dots(...)` returns a named list of the `...` values. As a convenience,
67+
if `...` is a single unnamed list, its elements are used instead, which makes
68+
it easy to build the values programmatically. It errors if any value is
69+
unnamed.
70+
71+
```r
72+
collect_dots(x = 1, y = 2) # list(x = 1, y = 2)
73+
collect_dots(list(x = 1, y = 2)) # list(x = 1, y = 2) -- spliced
74+
collect_dots(1) # error: All arguments to `...` must be named.
75+
collect_dots(list(1)) # error: All elements of `..1` must be named.
76+
```
77+
Use `collect_dots()` instead of `list(...)`.
78+
79+
## Documenting it
80+
81+
In the `@param` for `...`, mention the single-list shortcut:
82+
83+
```r
84+
#' @param ... Name-value pairs of properties to set. As a convenience, you can
85+
#' supply a single unnamed list instead of individual name-value pairs, which
86+
#' makes it easy to set properties programmatically.
87+
```
88+
89+
roxygen handles `@param _object` and a backtick-quoted formal fine.

NEWS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
* `convert()` now errors when upcasting to an abstract class, rather than producing an instance of that abstract class (#680, #686).
99
* `convert()` no longer automatically converts between sibling classes (classes that merely share a common ancestor); the default downcast now applies only when `to` is genuinely a descendant of `from`'s class (#509).
1010
* `convert()` now falls back to the corresponding `as.*()` function (e.g. `as.character()`) when converting to a base type like `class_character` and no method or inheritance-based default applies, so `convert(1, class_character)` works out of the box (#472).
11+
* `convert()` accepts a single unnamed list of property overrides when downcasting, as a shortcut for individual name-value pairs (#497).
1112
* `convert()` no longer errors when `from` is a base or S3 object and `to` is an S7 class that inherits from `from`'s class. The base/S3 value is now passed as `.data` to the `to` constructor (#537).
1213
* `method<-` now works for double-dispatch operators (e.g. `+`, `==`, `%*%`) with plain S3 or S4 classes, even when neither operand is an S7 object (#544).
1314
* `method<-` no longer embeds a copy of a generic owned by another package in your package namespace. Instead it returns a sentinel value that the new `S7_on_build()` removes from the namespace at build time; call `S7_on_build()` at the top level of `zzz.R` (see `vignette("packages")`) (#364).
1415
* `method<-` now accepts `NULL` to unregister an existing method, e.g. `method(foo, class_character) <- NULL` (#613).
1516
* `convert()` is now idempotent when `from` is already an instance of `to`, returning it unchanged. When `from` inherits from `to` but is more specific, dispatch is now restricted to classes more specific than `to`, so an inherited downcasting method can no longer be selected in place of an upcast (#429).
1617
* `method<-` now gives a clear error when assigning a primitive function (e.g. `log`) as a method (#608).
1718
* `method<-` and `method()` now accept a length-1 list as `signature` for single-dispatch generics, matching the list-of-classes form required for multi-dispatch (#555).
19+
* `new_object()` now names its first argument `_parent` to minimise the chance of a clash with a property (#423). It also accepts a single unnamed named list as a shortcut for splicing property values, making it easier to programmatically construct an object from a list of properties (#497).
1820
* `method<-` can now register methods on S3 and S4 generics with base types (e.g. `class_character`), S3 classes (`new_S3_class()`, `class_factor`, etc.), S7 unions (expanded to one registration per class), `class_any` (registered as the `default` method), and `NULL` (registered as the `NULL` method) (#455).
1921
* `new_class()` now allows properties named `names`, `dim`, `dimnames`, `class`, `comment`, `tsp`, and `row.names`. But property names beginning with `_` are now reserved for internal use (#579).
2022
* `new_class()` experimentally allows `class_environment` as a parent again, so you can build S7 objects that share R's reference semantics for environments. This support is provisional: because environments are mutated in place, some operations behave differently than for value-typed S7 objects, and the API may change. `S7_data()` and `S7_data<-()` error on environment-based objects, since they would otherwise destroy the object's S7 attributes in place (#590).
@@ -36,6 +38,7 @@
3638
* `S7_data<-()` now preserves attributes (like `names` or `dim`) from the replacement data instead of carrying over the originals, so resizing the underlying data works correctly (#478).
3739
* `S7_error_method_not_found` now has a correct class vector without a duplicate `"error"` entry (@jjjermiah, #604).
3840
* `S7_inherits()` and `check_is_S7()` now accept any class specification (S7 class, S7 union, S3 class, S4 class, or base type wrapper like `class_integer`), not just S7 classes (#556).
41+
* `set_props()` now names its first argument `_object` to minimise the chances of a clash with a property (#423). It also accepts a single unnamed named list as a shortcut for splicing property values, making it easier to set properties programmatically (#497).
3942
* `S7_on_load()` is the new name for `methods_register()`, giving it a nicer symmetry with `S7_on_build()`; `methods_register()` remains available for backward compatibility (#615).
4043
* `str()` on S7 objects that inherit from data.frame (or other S3 classes whose underlying data has a `dim` attribute incompatible with the bare base type) no longer errors (#494).
4144
* `super()` now works with S3 and S4 objects, not just S7 objects (#500).

R/class.R

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ check_parent <- function(parent, class, call = sys.call(-1L)) {
284284
parent_class <- class@parent
285285
if (is.null(parent_class)) {
286286
stop2(
287-
"`.parent` must not be supplied when class has no parent.",
287+
"`_parent` must not be supplied when class has no parent.",
288288
call = call
289289
)
290290
}
@@ -298,7 +298,7 @@ check_parent <- function(parent, class, call = sys.call(-1L)) {
298298
return()
299299
}
300300
msg <- sprintf(
301-
"`.parent` must be an instance of %s, not %s.",
301+
"`_parent` must be an instance of %s, not %s.",
302302
class_desc(parent_class),
303303
obj_desc(parent)
304304
)
@@ -307,11 +307,15 @@ check_parent <- function(parent, class, call = sys.call(-1L)) {
307307

308308
# Object ------------------------------------------------------------------
309309

310-
#' @param .parent,... Parent object and named properties used to construct the
310+
#' @param _parent,... Parent object and named properties used to construct the
311311
#' object.
312+
#'
313+
#' As a convenience, if `...` is a single unnamed list, then the elements of
314+
#' that list are used as the properties. This makes it easy to
315+
#' programmatically construct an object from a list of property values.
312316
#' @rdname new_class
313317
#' @export
314-
new_object <- function(.parent, ...) {
318+
new_object <- function(`_parent`, ...) {
315319
class <- sys.function(-1)
316320
if (!inherits(class, "S7_class")) {
317321
stop2("`new_object()` must be called from within a constructor.")
@@ -324,48 +328,45 @@ new_object <- function(.parent, ...) {
324328
stop2(msg)
325329
}
326330

327-
if (!missing(.parent)) {
328-
check_parent(.parent, class)
331+
if (!missing(`_parent`)) {
332+
check_parent(`_parent`, class)
329333
}
330334

331-
args <- list(...)
332-
if ("" %in% names2(args)) {
333-
stop2("All arguments to `...` must be named.")
334-
}
335+
args <- collect_dots(...)
335336

336337
has_setter <- vlapply(class@properties[names(args)], prop_has_setter)
337338
self_attrs <- args[!has_setter]
338339
names(self_attrs) <- prop_storage_rename(names(self_attrs))
339340

340-
# We must awkwardly operate on `.parent` rather than binding to a local
341+
# We must awkwardly operate on `_parent` rather than binding to a local
341342
# variable; since otherwise the extra binding causes ALTREP-wrapped values to
342343
# be materialised when byte-compiled (#607).
343344
attrs <- c(
344345
list(class = class_dispatch(class), S7_class = class),
345346
self_attrs,
346-
attributes(.parent)
347+
attributes(`_parent`)
347348
)
348349
attrs <- attrs[!duplicated(names(attrs))]
349-
attributes(.parent) <- attrs
350+
attributes(`_parent`) <- attrs
350351

351352
# invoke custom property setters
352353
prop_setter_vals <- args[has_setter]
353354
for (name in names(prop_setter_vals)) {
354-
prop(.parent, name, check = FALSE) <- prop_setter_vals[[name]]
355+
prop(`_parent`, name, check = FALSE) <- prop_setter_vals[[name]]
355356
}
356357

357358
# Don't need to validate if parent class already validated,
358359
# i.e. it's a non-abstract S7 class
359360
parent_validated <- inherits(class@parent, "S7_object") &&
360361
!class@parent@abstract
361362
validate_from(
362-
.parent,
363+
`_parent`,
363364
parent = if (parent_validated) class@parent,
364365
# Attribute validation failures to the constructor call, not new_object()
365366
call = sys.call(-1L)
366367
)
367368

368-
.parent
369+
`_parent`
369370
}
370371

371372
#' @export

R/convert.R

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@
4949
#' @param to An S7 class specification, passed to [as_class()].
5050
#' @param ... Other arguments passed to custom `convert()` methods. For
5151
#' downcasting, these can be used to override existing properties or set new
52-
#' ones.
52+
#' ones. As a convenience, you can supply a single unnamed list instead of
53+
#' individual name-value pairs, which makes it easy to override properties
54+
#' programmatically.
5355
#' @return Either `from` coerced to class `to`, or an error if the coercion
5456
#' is not possible.
5557
#' @export
@@ -122,7 +124,8 @@ convert <- function(from, to, ...) {
122124
} else if (class_inherits(from, to)) {
123125
convert_up(from, to)
124126
} else if (is_down_cast(from, to)) {
125-
convert_down(from, to, ...)
127+
dots <- collect_dots(...)
128+
convert_down(from, to, dots)
126129
} else if (is_base_class(to)) {
127130
base_coerce(from, to, ...)
128131
} else {
@@ -195,12 +198,13 @@ is_down_cast <- function(x, class) {
195198
class_dispatch_extends(obj_dispatch(x), class_dispatch(class))
196199
}
197200

198-
convert_down <- function(from, to, ...) {
201+
convert_down <- function(from, to, user_args = list()) {
199202
from_class <- S7_class(from)
200203

201204
if (!is_class(from_class)) {
202205
# `from` is a base or S3 object; pass it as `.data` to the constructor
203-
return(to(.data = from, ...))
206+
user_args$.data <- from
207+
return(do.call(to, user_args))
204208
}
205209

206210
# Use `from` as a prototype/seed when constructing `to`: copy over property
@@ -217,7 +221,6 @@ convert_down <- function(from, to, ...) {
217221
}
218222

219223
# Drop properties overridden by user-supplied arguments
220-
user_args <- list(...)
221224
from_prop_names <- setdiff(from_prop_names, names(user_args))
222225

223226
from_prop_values <- props(from, from_prop_names)

R/property.R

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -497,11 +497,14 @@ props <- function(object, names = prop_names(object)) {
497497
}
498498

499499
#' @export
500-
#' @param ... Name-value pairs given property to modify and new value.
500+
#' @param _object The object to modify.
501+
#' @param ... Name-value pairs given property to modify and new value. As a
502+
#' convenience, you can supply a single unnamed list instead of individual
503+
#' name-value pairs, which makes it easy to set properties programmatically.
501504
#' @rdname props
502-
set_props <- function(object, ..., .check = TRUE) {
503-
props(object, check = .check) <- list(...)
504-
object
505+
set_props <- function(`_object`, ..., .check = TRUE) {
506+
props(`_object`, check = .check) <- collect_dots(...)
507+
`_object`
505508
}
506509

507510
as_properties <- function(x, call = sys.call(-1L)) {

R/utils.R

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,27 @@ names2 <- function(x) {
5252
}
5353
}
5454

55+
# Collect `...` into a named list. As a convenience, a single unnamed list is
56+
# spliced in so its elements become the values, making it easy to supply
57+
# values programmatically. All values must be named.
58+
collect_dots <- function(..., call = sys.call(-1)) {
59+
args <- list(...)
60+
61+
is_single_list <- length(args) == 1L &&
62+
!nzchar(names2(args)) &&
63+
is.list(args[[1L]])
64+
65+
if (is_single_list) {
66+
args <- args[[1L]]
67+
if ("" %in% names2(args)) {
68+
stop2("All elements of `..1` must be named.", call = call)
69+
}
70+
} else if ("" %in% names2(args)) {
71+
stop2("All arguments to `...` must be named.", call = call)
72+
}
73+
args
74+
}
75+
5576
is_prefix <- function(x, y) {
5677
length(x) <= length(y) && identical(unclass(x), unclass(y)[seq_along(x)])
5778
}

man/convert.Rd

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/new_class.Rd

Lines changed: 7 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/props.Rd

Lines changed: 6 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/testthat/_snaps/class.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,26 +160,26 @@
160160
Error in `new_object()`:
161161
! `new_object()` must be called from within a constructor.
162162

163-
# new_object() / errors if `.parent` doesn't inherit from the parent class (#409)
163+
# new_object() / errors if `_parent` doesn't inherit from the parent class (#409)
164164

165165
Code
166166
Foo()
167167
Condition
168168
Error in `new_object()`:
169-
! `.parent` must be an instance of <Bar>, not S3<S7_base_class>.
169+
! `_parent` must be an instance of <Bar>, not S3<S7_base_class>.
170170
Code
171171
Baz()
172172
Condition
173173
Error in `new_object()`:
174-
! `.parent` must be an instance of <integer>, not <character>.
174+
! `_parent` must be an instance of <integer>, not <character>.
175175

176-
# new_object() / errors if `.parent` is supplied but class has no parent
176+
# new_object() / errors if `_parent` is supplied but class has no parent
177177

178178
Code
179179
NoParent()
180180
Condition
181181
Error in `new_object()`:
182-
! `.parent` must not be supplied when class has no parent.
182+
! `_parent` must not be supplied when class has no parent.
183183

184184
# new_object() / validates object
185185

0 commit comments

Comments
 (0)