Skip to content

Commit 06a0b01

Browse files
authored
Register Ops bridges for S3/S4 classes (#679)
Fixes #544
1 parent 6545fa8 commit 06a0b01

6 files changed

Lines changed: 92 additions & 2 deletions

File tree

NEWS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* `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).
99
* `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).
1010
* `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).
11+
* `method<-` now works for double-dispatch operators (e.g. `+`, `==`, `%*%`) with plain S3 or S4 classes, even when neither operand is an S7 object (#544).
1112
* `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).
1213
* `method<-` now accepts `NULL` to unregister an existing method, e.g. `method(foo, class_character) <- NULL` (#613).
1314
* `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).

R/generic-spec.R

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,14 @@ group_generics <- function() {
139139
}
140140
out
141141
}
142+
143+
ops_group <- function(generic) {
144+
group <- group_generics()
145+
if (generic %in% group$Ops) {
146+
"Ops"
147+
} else if (generic %in% group$matrixOps) {
148+
"matrixOps"
149+
} else {
150+
NULL
151+
}
152+
}

R/method-ops.R

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,40 @@ chooseOpsMethod.S7_super <- chooseOpsMethod.S7_object
4848

4949
#' @rawNamespace if (getRversion() >= "4.3.0") S3method(matrixOps, S7_super)
5050
matrixOps.S7_super <- matrixOps.S7_object
51+
52+
# Bridge base operators to S7 operators for S3/S4 registration
53+
register_ops_bridge <- function(generic, signatures, env) {
54+
group <- ops_group(generic@name)
55+
if (is.null(group)) {
56+
return(invisible())
57+
}
58+
59+
classes <- unique(unlist(lapply(signatures, function(sig) {
60+
lapply(sig, ops_bridge_class)
61+
})))
62+
63+
for (class in classes) {
64+
# Don't clobber an existing group methods
65+
if (has_s3_method(group, class, env)) {
66+
next
67+
}
68+
69+
# Re-use `Ops.S7_object`/`matrixOps.S7_object` to avoid conflicting methods
70+
generic <- if (group == "Ops") Ops.S7_object else matrixOps.S7_object
71+
registerS3method(group, class, generic, envir = env)
72+
}
73+
invisible()
74+
}
75+
76+
has_s3_method <- function(generic, class, env) {
77+
!is.null(utils::getS3method(generic, class, envir = env, optional = TRUE))
78+
}
79+
80+
ops_bridge_class <- function(x) {
81+
switch(
82+
class_type(x),
83+
S7_S3 = x$class[[1]],
84+
S4 = x@className,
85+
NULL
86+
)
87+
}

R/method-register.R

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ register_method <- function(
9191
for (sig in signatures) {
9292
register_S7_method(generic, sig, method, call = call)
9393
}
94+
register_ops_bridge(generic, signatures, env)
9495
} else if (is_S3_generic(generic)) {
9596
for (sig in signatures) {
9697
register_S3_method(generic, sig, method, env, call = call)

tests/testthat/helper.R

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@ local_s3_generic <- function(name, frame = parent.frame()) {
106106
invisible()
107107
}
108108

109+
# Define an S3 method in globalenv() and remove it again on exit.
110+
local_s3_method <- function(name, fun, frame = parent.frame()) {
111+
assign(name, fun, envir = globalenv())
112+
defer(rm(list = name, envir = globalenv()), frame = frame)
113+
invisible()
114+
}
115+
109116
unregister_s3_methods <- function(envir, generic) {
110117
tbl <- envir[[".__S3MethodsTable__."]]
111118
if (!is.null(tbl)) {

tests/testthat/test-method-ops.R

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ test_that("Ops generics dispatch to S7 methods for S7 classes", {
2222
test_that("Ops generics dispatch to S3 methods", {
2323
skip_if(getRversion() < "4.3")
2424
local_methods(base_ops[["+"]])
25+
defer(unregister_s3_methods(baseenv(), "Ops"))
2526

2627
foo <- new_class("foo")
2728
method(`+`, list(class_factor, foo)) <- function(e1, e2) "factor-foo"
@@ -32,8 +33,7 @@ test_that("Ops generics dispatch to S3 methods", {
3233

3334
# Even if custom method exists
3435
foo_S3 <- structure(list(), class = "foo_S3")
35-
assign("+.foo_S3", function(e1, e2) stop("Failure!"), envir = globalenv())
36-
defer(rm("+.foo_S3", envir = globalenv()))
36+
local_s3_method("+.foo_S3", function(e1, e2) stop("Failure!"))
3737

3838
method(`+`, list(new_S3_class("foo_S3"), foo)) <- function(e1, e2) "S3-S7"
3939
method(`+`, list(foo, new_S3_class("foo_S3"))) <- function(e1, e2) "S7-S3"
@@ -42,6 +42,39 @@ test_that("Ops generics dispatch to S3 methods", {
4242
expect_equal(foo_S3 + foo(), "S3-S7")
4343
})
4444

45+
test_that("operator methods on S3/S4 classes work when neither operand is S7", {
46+
local_methods(base_ops[["+"]])
47+
48+
class_foo <- new_S3_class("foo")
49+
foo <- structure(list(), class = "foo")
50+
method(`+`, list(class_foo, class_any)) <- function(e1, e2) "foo+any"
51+
expect_equal(foo + 10, "foo+any")
52+
53+
fooS4 <- local_S4_class("fooS4")
54+
method(`+`, list(fooS4, class_any)) <- function(e1, e2) "fooS4+any"
55+
expect_equal(fooS4("x") + 10, "fooS4+any")
56+
57+
# An unregistered operator still falls back to the base behaviour
58+
expect_error(foo * 10, regexp = "non-numeric argument")
59+
})
60+
61+
test_that("operator bridge does not clobber an existing group method", {
62+
skip_if(getRversion() < "4.3")
63+
local_methods(base_ops[["+"]])
64+
defer(unregister_s3_methods(baseenv(), "Ops"))
65+
66+
local_s3_method("Ops.myS3", function(e1, e2) "myS3-ops")
67+
method(`+`, list(new_S3_class("myS3"), class_any)) <- function(e1, e2) "!"
68+
69+
# + method used for S7 classes
70+
x <- structure(list(), class = "myS3")
71+
foo <- new_class("foo")
72+
expect_equal(x + foo(), "!")
73+
74+
# but `Ops.myS3` used for base/S3 classes
75+
expect_equal(x + 10, "myS3-ops")
76+
})
77+
4578
test_that("Ops generics dispatch to S7 methods for S4 classes", {
4679
local_methods(base_ops[["+"]])
4780
fooS4 <- local_S4_class("foo", contains = "character")

0 commit comments

Comments
 (0)