-
Notifications
You must be signed in to change notification settings - Fork 47
Consistent ... handling
#655
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
base: main
Are you sure you want to change the base?
Changes from all commits
dd5e389
cbe095c
2d4e0b1
8c1f9f4
194691e
9a8614a
c6aba95
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| --- | ||
| name: property-dots | ||
| description: Guide for writing S7 functions that accept property name-value pairs via `...`. Use when adding or editing a function whose `...` collects property values (like `new_object()`, `set_props()`, or `convert()`), so that the fixed arguments don't clash with property names. | ||
| --- | ||
|
|
||
| # Accept properties via `...` | ||
|
|
||
| Use this skill when writing a function that collects property name-value pairs | ||
| through `...`, e.g. ``new_object(`_parent`, ...)``, ``set_props(`_object`, ...)``, | ||
| or a downcasting `convert()` method. | ||
|
|
||
| Two problems show up whenever `...` carries property values: | ||
|
|
||
| 1. A fixed argument can shadow a property of the same name. If `set_props()` | ||
| had a formal called `object`, then `set_props(x, object = 1)` would bind | ||
| `object` to the formal instead of treating it as the property `object`. | ||
| 2. Callers sometimes want to supply property values programmatically, as a | ||
| single list, rather than as individual `name = value` pairs. | ||
|
|
||
| `splice_dots()` and the underscore naming convention solve these. | ||
|
|
||
| ## The underscore convention | ||
|
|
||
| Property names starting with `_` are reserved for internal use (documented in | ||
| `new_property()`). This lets you name a fixed argument with a leading `_` so it | ||
| can never collide with a real property passed through `...`. | ||
|
|
||
| - ``new_object(`_parent`, ...)`` — `_parent` is the parent object. | ||
| - ``set_props(`_object`, ...)`` — `_object` is the object to modify. | ||
|
|
||
| `_parent` and `_object` are not syntactically valid names, so they **must be | ||
| backtick-quoted** everywhere they appear: | ||
|
|
||
| ```r | ||
| set_props <- function(`_object`, ..., .check = TRUE) { | ||
| props(`_object`, check = .check) <- splice_dots(...) | ||
| `_object` | ||
| } | ||
| ``` | ||
|
|
||
| Because these arguments are always passed positionally (and generated | ||
| constructors pass the parent positionally), the unusual name never burdens | ||
| callers — it only stops the name from competing with `...`. | ||
|
|
||
| Apply the convention when: | ||
|
|
||
| - The function takes properties via `...`, AND | ||
| - It also needs a fixed argument that a user might plausibly use as a property | ||
| name (`object`, `parent`, `value`, ...). | ||
|
|
||
| Do *not* rename existing public arguments where it would be a breaking change | ||
| without good reason. For example `convert(from, to, ...)` keeps `from`/`to` as | ||
| is; only the dots-handling was updated. | ||
|
|
||
| ## Collecting the dots with `splice_dots()` | ||
|
|
||
| `splice_dots(...)` returns a named list of the `...` values. As a convenience, | ||
| if `...` is a single unnamed list, its elements are used instead, which makes | ||
| it easy to build the values programmatically. It errors if any value is | ||
| unnamed. | ||
|
|
||
| ```r | ||
| splice_dots(x = 1, y = 2) # list(x = 1, y = 2) | ||
| splice_dots(list(x = 1, y = 2)) # list(x = 1, y = 2) -- spliced | ||
| splice_dots(1) # error: All arguments to `...` must be named. | ||
| splice_dots(list(1)) # error: All elements of `..1` must be named. | ||
| ``` | ||
|
|
||
| Replace any `args <- list(...)` that collects properties with | ||
| `args <- splice_dots(...)`. Don't hand-roll the single-list check — that logic | ||
| lives in `splice_dots()` (and `is_single_list()`) so all callers behave | ||
| identically. | ||
|
|
||
| ## Getting the error call right | ||
|
|
||
| `splice_dots()` reports its error against its caller via | ||
| `error_call = sys.call(-1)`. This is correct for ordinary functions: | ||
|
|
||
| ```r | ||
| new_object <- function(`_parent`, ...) { | ||
| ... | ||
| args <- splice_dots(...) # errors blame `new_object()` | ||
| ... | ||
| } | ||
| ``` | ||
|
|
||
| It is **not** reliable inside an S7 generic, because dispatch rewrites the call | ||
| stack (you'll see a bogus call like `.set_ops_need_as_vector()`). For a | ||
| generic, pass the call explicitly: | ||
|
|
||
| ```r | ||
| convert <- function(from, to, ...) { | ||
| ... | ||
| } else if (is_down_cast(from, to)) { | ||
| convert_down(from, to, splice_dots(..., error_call = quote(convert()))) | ||
| } | ||
| ... | ||
| } | ||
| ``` | ||
|
|
||
| ## Documenting it | ||
|
|
||
| In the `@param` for the underscore argument, explain the unusual name and point | ||
| to `new_property()`: | ||
|
|
||
| ```r | ||
| #' @param _object The object to modify. It has an unusual name to avoid clashing | ||
| #' with property names supplied in `...`; see [new_property()] for details. | ||
| ``` | ||
|
|
||
| In the `@param` for `...`, mention the single-list shortcut: | ||
|
|
||
| ```r | ||
| #' @param ... Name-value pairs of properties to set. As a convenience, you can | ||
| #' supply a single unnamed list instead of individual name-value pairs, which | ||
| #' makes it easy to set properties programmatically. | ||
| ``` | ||
|
|
||
| roxygen handles `@param _object` and a backtick-quoted formal fine; the usage | ||
| line renders as ``set_props(`_object`, ...)``. | ||
|
|
||
| ## Worked example | ||
|
|
||
| A function that returns a modified copy of an object, setting properties from | ||
| `...`: | ||
|
|
||
| ```r | ||
| #' @param _object The object to modify. It has an unusual name to avoid clashing | ||
| #' with property names supplied in `...`; see [new_property()] for details. | ||
| #' @param ... Name-value pairs of properties to set. As a convenience, you can | ||
| #' supply a single unnamed list instead of individual name-value pairs. | ||
| update_props <- function(`_object`, ...) { | ||
| props(`_object`) <- splice_dots(...) | ||
| `_object` | ||
| } | ||
| ``` | ||
|
|
||
| Now both of these work, even for a property named `object`: | ||
|
|
||
| ```r | ||
| update_props(x, object = 1, width = 10) | ||
| update_props(x, list(object = 1, width = 10)) | ||
| ``` | ||
|
|
||
| ## Checklist | ||
|
|
||
| - [ ] Name any clash-prone fixed argument with a leading `_`, backtick-quoted | ||
| everywhere it appears. | ||
| - [ ] Collect property values with `splice_dots(...)`, not `list(...)`. | ||
| - [ ] For an S7 generic, pass `error_call = quote(generic())` to `splice_dots()`. | ||
| - [ ] Document the `_` argument (cross-reference `new_property()`) and the | ||
| single-list shortcut on `...`. | ||
| - [ ] Add a test that a property whose name matches the old fixed argument can | ||
| now be set, and a test for the single-list shortcut. | ||
| - [ ] Run `air format .` and re-document. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,7 +37,9 @@ | |
| #' @param to An S7 class specification, passed to [as_class()]. | ||
| #' @param ... Other arguments passed to custom `convert()` methods. For | ||
| #' downcasting, these can be used to override existing properties or set new | ||
| #' ones. | ||
| #' ones. As a convenience, you can supply a single unnamed list instead of | ||
| #' individual name-value pairs, which makes it easy to override properties | ||
| #' programmatically. | ||
| #' @return Either `from` coerced to class `to`, or an error if the coercion | ||
| #' is not possible. | ||
| #' @export | ||
|
|
@@ -86,7 +88,7 @@ convert <- function(from, to, ...) { | |
| } else if (class_inherits(from, to)) { | ||
| convert_up(from, to) | ||
| } else if (is_down_cast(from, to)) { | ||
| convert_down(from, to, ...) | ||
| convert_down(from, to, splice_dots(..., error_call = quote(convert()))) | ||
| } else { | ||
| msg <- paste_c( | ||
| "Can't find method with dispatch classes:\n", | ||
|
|
@@ -127,12 +129,13 @@ is_down_cast <- function(x, class) { | |
| inherits(x, setdiff(class_dispatch(class), "S7_object")) | ||
| } | ||
|
|
||
| convert_down <- function(from, to, ...) { | ||
| convert_down <- function(from, to, user_args = list()) { | ||
| from_class <- S7_class(from) | ||
|
|
||
| if (!is_class(from_class)) { | ||
| # `from` is a base or S3 object; pass it as `.data` to the constructor | ||
| return(to(.data = from, ...)) | ||
| user_args$.data <- from | ||
| return(do.call(to, user_args)) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This approach now forces
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's true, but it's not a change: check out the old line 152 below. (I mean now we do force it for this very specific case, but that's only available in the dev version, so it's not a breaking change). |
||
| } | ||
|
|
||
| # Use `from` as a prototype/seed when constructing `to`: copy over property | ||
|
|
@@ -149,7 +152,6 @@ convert_down <- function(from, to, ...) { | |
| } | ||
|
|
||
| # Drop properties overridden by user-supplied arguments | ||
| user_args <- list(...) | ||
| from_prop_names <- setdiff(from_prop_names, names(user_args)) | ||
|
|
||
| from_prop_values <- props(from, from_prop_names) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,6 +44,28 @@ names2 <- function(x) { | |
| } | ||
| } | ||
|
|
||
| # Is `...` a single unnamed list argument? Used by functions that accept | ||
| # either named arguments via `...` or a single list spliced in. | ||
| is_single_list <- function(args) { | ||
| length(args) == 1L && !nzchar(names2(args)) && is.list(args[[1L]]) | ||
| } | ||
|
|
||
| # Collect `...` into a named list. As a convenience, a single unnamed list is | ||
| # spliced in so its elements become the values, making it easy to supply | ||
| # values programmatically. All values must be named. | ||
| splice_dots <- function(..., error_call = sys.call(-1)) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this needs a better name. Maybe |
||
| args <- list(...) | ||
| if (is_single_list(args)) { | ||
| args <- args[[1L]] | ||
| if ("" %in% names2(args)) { | ||
| stop(simpleError("All elements of `..1` must be named.", error_call)) | ||
| } | ||
| } else if ("" %in% names2(args)) { | ||
| stop(simpleError("All arguments to `...` must be named.", error_call)) | ||
| } | ||
| args | ||
| } | ||
|
|
||
| is_prefix <- function(x, y) { | ||
| length(x) <= length(y) && identical(unclass(x), unclass(y)[seq_along(x)]) | ||
| } | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reading through this skill, I can't help but wonder, should
set_props()simply take two arguments and we can avoid all this pain by simply removing the potential for name clashes?