Single dispatch
Method lookup
S3
Where precisely does UseMethod()
look for the methods?
As of R 4.0.0, it looks in the following three places:
The method table is a special environment
.__S3MethodsTable__.
found in the environment where the generic is defined.The chain of environments starting at the
parent.frame()
of the call to generic, ending at the global environment.The base environment (i..e. skips the search).
S7
S7 methods are defined using assignment:
Behind the scenes, this acts directly upon the method table, so method lookup for S7 generics never needs to look in the parent frame.
method<-
is likely to start as a shim around
.S3method()
but we may want to consider a separate
.__S7MethodsTable__.
. This could use a new data structure
that resolves generic/class ambiguity
(e.g. all.equal.data.frame()
). Methods for S7 classes
defined on an S3 generics would still use the S3 method table. Could
consider attaching the method table to the generic, instead of its
containing environment.
Method lookup would be cached for performance, so that it is only performed once per class. Cached methods would be marked with a special attribute so that they could be flushed whenever a new method for the generic is added.
Method call frame
S3
Once the method has been found, it must be called.
UseMethod()
does not work like a regular function call but
instead:
Changes to arguments are ignored.
Method can access objects created in generic. (Changed in R 4.4.0.)
The parent frame of the method call is the parent frame of the generic.
These properties are summarised in the following example:
foo <- function(x, y) {
y <- 2
z <- 2
UseMethod("foo")
}
foo.numeric <- function(x, y) {
print(parent.frame())
c(x = x, y = y, z = z)
}
# In R 4.3 and earlier
foo(1, 1)
#> x y z
#> 1 1 2
foo(1, 1)
#> <environment: R_GlobalEnv>
#> Error in foo.numeric(1, 1): object 'z' not found
S7
Can we eliminate the special behaviour and make it just like a regular function call? Presumably easier than changing dispatch rules because we’ll call a function other than
UseMethod()
.-
Need to make precise how arguments are passed to the method.
plot()
at least assumes that this works:foo <- function(x, y) { UseMethod("foo") } foo.numeric <- function(x, y) { deparse(substitute(x)) } x <- 10 foo(x) #> [1] "x"
How does that intersect with assignment within the generic?
Inheritance
S3
i.e. how does NextMethod()
work: currently most state
recorded in special variables like .Generic
, etc.
Can we avoid this confusion:
S4
Want to avoid this sort of code, where we rely on magic from
callGeneric()
to pass on values from current call.
method("mean", "foofy") <- function(x, ..., na.rm = TRUE) {
x <- x@values
callGeneric()
}
S7
Can we require generic
and object
arguments
to make code easier to reason about?
method("mean", "POSIXct") <- function(x) {
POSIXct(NextMethod(), tz = attr(x, "tz"))
}
# Explicit is nice:
method("mean", "POSIXct") <- function(x) {
POSIXct(NextMethod("mean", x), tz = attr(x, "tz"))
}
# But what does this do? Is this just an error?
method("mean", "POSIXct") <- function(x) {
POSIXct(NextMethod("sd", 10), tz = attr(x, "tz"))
}
Group generics
S3
Group generics (Math
, Ops
,
Summary
, Complex
): exist for some internal
generics. Looked for before final fallback.
sloop::s3_dispatch(sum(Sys.time()))
#> sum.POSIXct
#> sum.POSIXt
#> sum.default
#> => Summary.POSIXct
#> Summary.POSIXt
#> Summary.default
#> -> sum (internal)
Double dispatch
S3
Used by Ops group generic. Basic process is find method for first and second arguments. Then:
- If same, ok
- If one internal, use other
- Otherwise, warn and use internal
S7
Goal is to use iterated dispatch which implies asymmetry in dispatch
order. User responsible for ensuring that x + y
equivalent
to y + x
(types should almost always be the same, but
values are likely to be different).
double_dispatch <- function(x, y, generic = "+") {
grid <- rev(expand.grid(sloop::s3_class(y), sloop::s3_class(x)))
writeLines(paste0("* ", generic, ".", grid[[1]], ".", grid[[2]]))
}
ab <- structure(list(), class = c("a", "b"))
cd <- structure(list(), class = c("c", "d"))
double_dispatch(ab, cd)
#> * +.a.c
#> * +.a.d
#> * +.b.c
#> * +.b.d
double_dispatch(cd, ab)
#> * +.c.a
#> * +.c.b
#> * +.d.a
#> * +.d.b
double_dispatch(1, 1L)
#> * +.double.integer
#> * +.double.numeric
#> * +.numeric.integer
#> * +.numeric.numeric
In vctrs, some question if we will remove inheritance from all double
dispatch. We have already done so for vec_ptype2()
and
vec_cast()
because the coercion hierarchy often does not
match the class hierarchy. May also do for vec_arith()
.
Implicit class
S3
When UseMethod()
receives an object without a
class
attribute, it uses the implicit
class, as provided by .class2()
. This is made up of four
rough categories: dimension, type, language, numeric.
# dimension class
.class2(matrix("a"))
#> [1] "matrix" "array" "character"
.class2(array("a"))
#> [1] "array" "character"
# typeof(), with some renaming
.class2(sum)
#> [1] "function"
.class2(quote(x))
#> [1] "name"
# language class
.class2(quote({}))
#> [1] "{"
# similarly for if, while, for, =, <-, (
# numeric
.class2(1)
#> [1] "double" "numeric"
Note that internal generics behave differently, instead immediately falling back to the default default case.