S3 Group Generics and Operator Overloading
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. Everything on this page is base R (the base, utils, stats and methods packages that ship with R), so no add-on package version applies to the scripts; the lintr 3.4.0 default linters were used only to check them, and one sentence in the section on chooseOpsMethod() mentions the S7 package, version 0.2.2, which no script loads. Almost everything in R that looks like syntax, such as a + b, a == b or round(a), is a function call, and most of those functions are internal generics: they look at the class attribute of their arguments and run an S3 method if one exists. R does not make you write one method per function. It collects about sixty-five of them into five groups (Math, Ops, Summary, Complex and matrixOps) so that a single function such as Ops.money can give a class all of its arithmetic and comparison operators at once. This page explains how those group methods are found, what the special variable .Generic holds, how a method for one member beats a method for the group, what R does when the two operands of an operator belong to different classes (the Incompatible methods warning and the chooseOpsMethod() generic added in R 4.3.0), and then builds a complete money class on doubles with a currency attribute.
Every example is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines that start with #> are what it printed. The scripts also pass the default lintr linters; a # nolint: comment marks a line where the idiom on show is what a default linter dislikes (the variables .Generic, .Method and .Group do not exist outside a method, so object_usage_linter cannot see them, and a formal argument called na.rm or use.names is not snake case), so the linter does not hide the lesson. The names books, loans, shelf_today and loan_days come from the running library-shelf scenario described on the section index: a script that needs them starts with source("scenario.R"). Where a message contains quotation marks, they are the typographic quotes R prints in a UTF-8 locale. The page assumes you know how a class is defined and how UseMethod() and NextMethod() find methods; those are the subject of S3 Classes and Generics, and the attributes that a class such as money carries are explained in Attributes and Names.
Why group generics exist
S3 dispatch normally starts in a generic written in R: print <- function(x, ...) UseMethod("print") looks at the class of x and calls print.book or print.default. Arithmetic and comparison do not work like that. +, ==, abs(), sum() and their relatives are primitive or internal functions written in C. They are still generic, because the C code checks whether an argument has a class attribute and, if it does, looks for an R method first. If you had to define the method for each of them separately, a numeric-like class would need a dozen methods before x + y, x * y, x < y, -x and round(x) all behaved. The Language Definition describes the answer in its section on group methods: the functions and operators are grouped, a method can be written for a group, and it is also possible to write a method specific to one member of a group. It adds that there is no mechanism to define additional groups, so the set is fixed.
A method for a group is named Group.class: Ops.money, Math.money, Summary.money, Complex.money and matrixOps.money. A method for one member is named like any other S3 method, generic.class: +.money, round.money, sum.money. The two kinds can coexist, and the specific one wins (the dispatch rules are in the section Dispatch order).
The five groups
The help page for the groups lists the members and says which argument is used for dispatch. The table below is taken from it and checked against the behaviour of R 4.6.1 in this page’s scripts.
| Group | Members | Dispatches on | Method prototype |
|---|---|---|---|
|
|
the first argument, |
|
|
|
both arguments |
|
|
|
both arguments |
|
|
|
the first argument supplied |
|
|
|
the argument |
|
Source: R documentation: groupGeneric
Three details in that table deserve a sentence each. The member list of matrixOps on the groupGeneric help page still says that the group contains the matrix multiplication operator only and that crossprod() and tcrossprod() are meant to follow; the help page of crossprod() and the R 4.4.0 NEWS say that they are generic since R 4.4.0 and can be handled through matrixOps, and the script in the section matrixOps and Complex confirms that on R 4.6.1. Second, the Language Definition (section 5.6) lists three groups, Math, Summary and Ops, and does not mention Complex or matrixOps; the help page is the more complete reference. Third, the group generics are not ordinary lazy functions: the help page states that there is no lazy evaluation of arguments for group-generic functions, and that if the operators are called as functions, argument names are removed so that matching is always positional.
A method is used for a group or one of its members only if the object has a class attribute, because the internal code dispatches on oldClass() and not on the implicit class returned by class(). A method named Ops.integer is therefore never called for 1L + 1L; the script in Dispatch order shows it.
Group methods that ship with R
Base R defines group methods for factor, data.frame and difftime in the Math, Ops and Summary groups, an ordered method for Ops, Date and POSIXt methods for Math and Ops, Summary methods for the date-times, and package_version methods for Ops and Summary; the stats package adds Ops.ts. The easiest way to see what the installed R has is methods() with the group name. It also lists the S4 methods that the methods package registers for the same generics, which the script filters out.
source("scenario.R")
# Which S3 group methods exist? Keep the S3 ones; the S4 entries come from
# the methods package.
group_methods <- function(group) {
info <- attr(methods(group), "info")
rownames(info)[!info$isS4]
}
group_methods("Math")
#> [1] "Math.data.frame" "Math.Date" "Math.difftime" "Math.factor"
#> [5] "Math.POSIXt"
group_methods("Summary")
#> [1] "Summary.data.frame" "Summary.Date"
#> [3] "Summary.difftime" "Summary.factor"
#> [5] "Summary.numeric_version" "Summary.ordered"
#> [7] "Summary.POSIXct" "Summary.POSIXlt"
#> [9] "Summary.roman"
group_methods("Ops")
#> [1] "Ops.data.frame" "Ops.Date"
#> [3] "Ops.difftime" "Ops.factor"
#> [5] "Ops.numeric_version" "Ops.ordered"
#> [7] "Ops.POSIXt" "Ops.R_version_string_with_specials"
#> [9] "Ops.raster" "Ops.roman"
#> [11] "Ops.ts"
group_methods("Complex")
#> character(0)
Source: R documentation: methods
None of the base classes has a Complex method, and matrixOps has none either. The next part of the same script shows what the existing methods do with operations that make no sense for their class: they stop with a message or, in the case of factors, warn and return NA. These messages are the ones your own methods will be compared with.
# Dates: Ops.Date and Math.Date refuse what is not meaningful.
shelf_today + 21
#> [1] "2026-07-21"
shelf_today - as.Date("2026-06-01")
#> Time difference of 29 days
tryCatch(shelf_today + shelf_today, error = \(e) conditionMessage(e))
#> [1] "binary + is not defined for \"Date\" objects"
tryCatch(shelf_today * 2, error = \(e) conditionMessage(e))
#> [1] "* not defined for \"Date\" objects"
tryCatch(sqrt(shelf_today), error = \(e) conditionMessage(e))
#> [1] "sqrt not defined for \"Date\" objects"
tryCatch(sum(shelf_today), error = \(e) conditionMessage(e))
#> [1] "sum not defined for \"Date\" objects"
range(shelf_today + c(0, 5, 2))
#> [1] "2026-06-30" "2026-07-05"
Source: R documentation: Ops.Date
Ops.Date implements the meaningful operations (a date plus a number of days, the difference of two dates, comparisons) and refuses the rest, and Math.Date and Summary.Date refuse what has no meaning for dates: range() works, sum() and sqrt() do not.
# Factors: Ops.factor keeps == and != and warns about the rest.
genre <- factor(c("Fiction", "Mystery", "Fiction"))
genre == "Fiction"
#> [1] TRUE FALSE TRUE
tryCatch(genre < "Mystery", warning = \(w) conditionMessage(w))
#> [1] "‘<’ not meaningful for factors"
tryCatch(genre + 1, warning = \(w) conditionMessage(w))
#> [1] "‘+’ not meaningful for factors"
factor(c("low", "high"), levels = c("low", "high"), ordered = TRUE) < "high"
#> [1] TRUE FALSE
Source: R documentation: factor
Ops.factor accepts == and !=, which compare labels, and for the ordered subclass the comparisons too, and answers every other operator with a warning and a vector of NA. The class(x) of a factor is "factor", and the ordered one is c("ordered", "factor"), so Ops.ordered is found first and Ops.factor is the fallback.
# Time differences: Ops.difftime, with extra methods for * and /.
late <- shelf_today - as.Date("2026-06-01")
late
#> Time difference of 29 days
late * 2
#> Time difference of 58 days
sum(late, late)
#> Time difference of 58 days
tryCatch(late / late, error = \(e) conditionMessage(e))
#> [1] "second argument of / cannot be a \"difftime\" object"
tryCatch(late * late, error = \(e) conditionMessage(e))
#> [1] "both arguments of * cannot be \"difftime\" objects"
tryCatch(sqrt(late), error = \(e) conditionMessage(e))
#> [1] "'sqrt' not defined for \"difftime\" objects"
shelf_today + late
#> [1] "2026-07-29"
# Any other non-numeric operand reaches the internal arithmetic.
tryCatch(1 + "a", error = \(e) conditionMessage(e))
#> [1] "non-numeric argument to binary operator"
Source: R documentation: difftime
The difftime lines show the specific-beats-group rule inside base R itself: besides Ops.difftime, the class has its own methods for * and / (they appear in methods(class = "difftime")), and they are the ones that produced the error messages about a second argument of / and two arguments of *. Also note that shelf_today + late adds a Date and a difftime, two classes with different methods, and yet gives no warning: R handles that particular pairing itself, which leads to the next topic.
Writing a group method
A group method is an ordinary function with the prototype of its group and the name Group.class. Inside it, R has defined a handful of variables in the method’s environment, and the most important is .Generic, the name of the member that was actually called. That is what lets one function stand in for dozens.
The special variables
The help page of UseMethod() lists the variables that the dispatch mechanism creates: .Generic is a length-one character vector with the name of the generic; .Method is the character vector naming the method (normally of length one, and of length two for the Ops group, with an empty string for an operand that did not select the method); .Class is the remaining class vector that NextMethod() uses. The group help page adds .Group, the name of the group used for dispatch, which is the empty string when a method specific to one member was chosen.
# A toy class: a count of items.
count <- function(n) structure(as.integer(n), class = "count")
Ops.count <- function(e1, e2) {
generic <- .Generic # nolint: object_usage_linter.
method <- .Method # nolint: object_usage_linter.
group <- .Group # nolint: object_usage_linter.
cat("group method for", generic, "\n")
cat(".Method:", paste0("\"", method, "\""), "\n")
cat(".Group: ", group, "\n")
cat("nargs: ", nargs(), "\n")
invisible(NULL)
}
x <- count(1:3)
invisible(x + 1L)
#> group method for +
#> .Method: "Ops.count" ""
#> .Group: Ops
#> nargs: 2
invisible(2L * x)
#> group method for *
#> .Method: "" "Ops.count"
#> .Group: Ops
#> nargs: 2
invisible(x == x)
#> group method for ==
#> .Method: "Ops.count" "Ops.count"
#> .Group: Ops
#> nargs: 2
invisible(-x)
#> group method for -
#> .Method: "Ops.count"
#> .Group: Ops
#> nargs: 1
Source: R documentation: UseMethod
Four things in that output are worth reading carefully. .Method has two elements for a binary operator and records which operand supplied the method: "Ops.count" "" for x + 1L and "" "Ops.count" for 2L * x. A unary operator calls the method with one argument, and nargs() (or missing(e2)) is how the method tells; the .Method of a unary call has a single element. The group is Ops in every case. And the same method served +, *, == and - without being told apart by anything but .Generic.
The same variables exist in Math and Summary methods. Both methods below call NextMethod(), which runs the internal default with the original arguments, so the result is the one the unclassed vector would have produced.
# Math and Summary methods see .Generic too.
Math.count <- function(x, ...) {
generic <- .Generic # nolint: object_usage_linter.
cat("Math method for", generic, "with", ...length(), "extra argument(s)\n")
NextMethod()
}
Summary.count <- function(..., na.rm = FALSE) { # nolint: object_name_linter.
generic <- .Generic # nolint: object_usage_linter.
cat("Summary method for", generic, "- na.rm is", na.rm, "\n")
NextMethod()
}
unclass(round(x, 1))
#> Math method for round with 1 extra argument(s)
#> [1] 1 2 3
unclass(cumsum(x))
#> Math method for cumsum with 0 extra argument(s)
#> [1] 1 3 6
max(x, na.rm = TRUE)
#> Summary method for max - na.rm is TRUE
#> [1] 3
range(x)
#> Summary method for range - na.rm is FALSE
#> [1] 1 3
# Repeating na.rm is an error (R 4.4.0 and later).
tryCatch(
sum(1:3, na.rm = TRUE, na.rm = FALSE),
error = \(e) conditionMessage(e)
)
#> [1] "formal argument \"na.rm\" matched by multiple actual arguments"
Two details are visible here. The digits of round(x, 1) arrives in the ... of a Math method, which is why the prototype is Math(x, ...) and the method has no formal argument called digits. And na.rm is a formal argument of a Summary method, so a method can read it, but every other argument, including the data, arrives in ...; Summary.count is called with the data in ..., not in a named parameter. Since R 4.4.0 a Summary call that repeats na.rm is an error rather than a silent choice of one of the two values; the message in R 4.6.1 comes from ordinary argument matching and is shown at the end of the block above.
A switch(.Generic, ...) is the usual way to write a group method, and it has one trap that is easy to hit when writing a Math method. An arm with no value, such as abs = ,, falls through to the next arm that has one; if the arm after the last fall-through has no name, switch() treats it as the default, and a second unnamed arm after that is a second default. The result is an error rather than a wrong answer.
# A switch() arm without a value falls through to the next one; a trailing
# comma before the unnamed default makes that default ambiguous.
tryCatch(switch("a", a = , 1, 2), error = \(e) conditionMessage(e))
#> [1] "duplicate 'switch' defaults: '1' and '2'"
Source: R documentation: switch
The lintr default object_usage_linter reports .Generic, .Method and .Group as "no visible binding for global variable", because it analyses the function without the dispatch environment. The pattern used throughout this page avoids repeating a nolint comment on every use: copy the variable once at the top of the method (generic <- .Generic # nolint: object_usage_linter.) and use generic after that, which also reads better in a switch().
Reaching the default behaviour
Most group methods end up wanting the work the internal code would have done: the actual arithmetic. There are three ways to get it, and one trap. The trap is to call the operator on the objects you received. get(.Generic)(e1, e2) with the original classed e1 and e2 is a call to the generic again, which dispatches to the same method, which calls the generic, until R gives up. The first block shows the error that ends it (the script lowers options(expressions = 200) so that the failure is quick and its message is deterministic; on a default setting R usually fails with the same error, or with a message about the C stack, after a few thousand levels).
# Calling the operator on the original objects dispatches to the method again.
Ops.loop <- function(e1, e2) {
generic <- .Generic # nolint: object_usage_linter.
get(generic)(e1, e2)
}
op <- options(expressions = 200)
tryCatch(structure(1, class = "loop") + 1, error = \(e) conditionMessage(e))
#> [1] "evaluation nested too deeply: infinite recursion / options(expressions=)?"
options(op)
Source: R documentation: options
The three ways to avoid the recursion are in the second block of the script.
# The three ways of reaching the default behaviour from inside a method.
Ops.celsius <- function(e1, e2) {
generic <- .Generic # nolint: object_usage_linter.
by_get <- get(generic)(unclass(e1), unclass(e2))
by_docall <- do.call(generic, list(unclass(e1), unclass(e2)))
by_next <- unclass(NextMethod())
list(by_get = by_get, by_docall = by_docall, by_next = by_next)
}
str(structure(c(10, 20), class = "celsius") + 1)
#> List of 3
#> $ by_get : num [1:2] 11 21
#> $ by_docall: num [1:2] 11 21
#> $ by_next : num [1:2] 11 21
Source: R documentation: UseMethod
All three return the same numbers. get(.Generic) and do.call(.Generic, ...) both call the operator by name and are safe only after unclass() has removed the class attribute, which is the reason older tutorials put unclass() into every method. NextMethod() is the more direct route: it calls the internal default with the original arguments, with dispatch switched off, and it keeps the attributes that the internal arithmetic keeps (for the arithmetic operators, the attributes of both operands, so the class survives). For the money class below, the methods use unclass() through a small helper because they also validate the operands and build the result themselves; the Summary method uses NextMethod() and wraps the result.
Dispatch order: specific, group, internal
When R evaluates a + b and a has a class, it walks the class vector of the operand from left to right. For each class it looks for a specific method +.class first and for the group method Ops.class second; the first function it finds is the method. If no class in the vector has either, the internal code runs. The help page puts the consequence in one sentence: a method defined for an individual member of the group takes precedence over a method defined for the group as a whole. The Language Definition says the same in other words: a class method dominates a group method. The class order matters more than the kind of method, which the second part of the script shows: a group method for the first class beats a specific method for the second.
# One group method, one specific method.
temp <- function(x) structure(as.double(x), class = "temp")
Ops.temp <- function(e1, e2) {
generic <- .Generic # nolint: object_usage_linter.
cat(" Ops.temp handles", generic, "\n")
get(generic)(unclass(e1), unclass(e2))
}
`*.temp` <- function(e1, e2) {
cat(" *.temp handles *\n")
temp(unclass(e1) * unclass(e2))
}
a <- temp(20)
b <- temp(2.5)
cat("a * b\n")
#> a * b
unclass(a * b)
#> *.temp handles *
#> [1] 50
cat("a - b\n")
#> a - b
unclass(a - b)
#> Ops.temp handles -
#> [1] 17.5
cat("a > b\n")
#> a > b
a > b
#> Ops.temp handles >
#> [1] TRUE
# The class vector is searched from left to right; within one class the
# specific method comes before the group method.
x <- structure(1, class = c("first", "second"))
`*.second` <- function(e1, e2) "the specific method of second"
Ops.first <- function(e1, e2) "the group method of first"
x * 2
#> [1] "the group method of first"
`*.first` <- function(e1, e2) "the specific method of first"
x * 2
#> [1] "the specific method of first"
Source: R documentation: groupGeneric
* found *.temp and never looked at Ops.temp; - and > have no specific method and went to the group method. In the class-vector example x * 2 used Ops.first although a specific *.second existed, and used *.first as soon as that was defined. Three more consequences are visible in the rest of the script:
# NextMethod() from a specific method goes straight to the internal code.
`-.temp` <- function(e1, e2) {
cat(" -.temp calls NextMethod()\n")
NextMethod()
}
r <- a - b
#> -.temp calls NextMethod()
cat("class of the result:", class(r), "\n")
#> class of the result: temp
unclass(r)
#> [1] 17.5
# A method for one member does not create a method for the group.
`*.money_like` <- function(e1, e2) "multiplied"
m <- structure(2, class = "money_like")
m * 3
#> [1] "multiplied"
unclass(m + 3)
#> [1] 5
# Group methods dispatch on the class attribute only, not on the implicit class.
Ops.integer <- function(e1, e2) "a method for integer"
1L + 1L
#> [1] 2
-
A
NextMethod()call inside-.tempwent directly to the internal subtraction, which kept the classtemp; it did not go through the group methodOps.tempthat exists for the class. If a specific method needs the logic of the group method, move that logic into a helper function that both call. -
A method for one member (
*.money_like) does not create a method for the group:m + 3ran the internal addition, and kept the class attribute ofm. -
Ops.integerwas never called for1L + 1L: group dispatch looks at the class attribute only, not at the implicit class thatclass(1L)reports. The same is true of every internal generic, which is why you can writeprint.numericorOps.numericbut cannot change what1 + 1does.
Double dispatch for Ops
UseMethod() looks at one argument. The Ops group looks at two, which is what makes Date + difftime or money * 2 and 2 * money work with a single method. The help page describes the algorithm: for each operand the class vector is examined to find a specific (preferred) or Ops method. If a method is found for just one operand, or the same method is found for both, it is used. If different methods are found, R calls the generic chooseOpsMethod() to pick one, and if that does not resolve the conflict there is a warning about incompatible methods, and in that case, or when no method is found for either operand, the internal method is used. The Summary group dispatches on the first argument supplied only, and Math and Complex on their single argument, so the conflict can only arise for Ops and matrixOps.
The script below makes two classes with different methods for the same operator, foo with a specific method for *, *.foo, and bar with the group method Ops.bar, and a small helper that runs an expression, mutes the warnings and returns both the value and the warning messages.
# Two classes, each with its own method for "*".
foo <- function() structure(1, class = "foo")
bar <- function() structure(2, class = "bar")
`*.foo` <- function(e1, e2) "the method of foo"
Ops.bar <- function(e1, e2) "the group method of bar"
# Run an expression and report the value and any warnings it signalled.
capture <- function(expr) {
warnings <- character()
value <- withCallingHandlers(
expr,
warning = \(w) {
warnings <<- c(warnings, conditionMessage(w))
invokeRestart("muffleWarning")
}
)
list(value = unclass(value), warnings = warnings)
}
str(capture(foo() * foo()))
#> List of 2
#> $ value : chr "the method of foo"
#> $ warnings: chr(0)
str(capture(foo() * 2))
#> List of 2
#> $ value : chr "the method of foo"
#> $ warnings: chr(0)
str(capture(2 * foo()))
#> List of 2
#> $ value : chr "the method of foo"
#> $ warnings: chr(0)
str(capture(bar() * bar()))
#> List of 2
#> $ value : chr "the group method of bar"
#> $ warnings: chr(0)
str(capture(foo() * bar()))
#> List of 2
#> $ value : num 2
#> $ warnings: chr "Incompatible methods (\"*.foo\", \"Ops.bar\") for \"*\""
str(capture(bar() * foo()))
#> List of 2
#> $ value : num 2
#> $ warnings: chr "Incompatible methods (\"Ops.bar\", \"*.foo\") for \"*\""
Source: R documentation: groupGeneric
Read the output from the top. foo() * foo(), foo() * 2 and 2 * foo() use the method of foo: one method found, or the same method for both operands. bar() * bar() uses the group method of bar. In foo() * bar() each operand selects a different method, so R emits Incompatible methods ("*.foo", "Ops.bar") for "*", ignores both methods and multiplies the two numbers with the internal code: the value is the number 2, not the string returned by either method. The warning names the two methods in the order of the operands, so bar() * foo() reports them the other way round. The warning is easy to miss in a script, and the result is a plain number that has silently lost the meaning of both classes; that is the failure to design against.
# Operators that foo has no specific method for: only bar offers one.
str(capture(foo() + bar()))
#> List of 2
#> $ value : chr "the group method of bar"
#> $ warnings: chr(0)
Source: R documentation: groupGeneric
The last line is the other side of the same rule: for + only bar offers a method (its group method), so foo() + bar() uses it without a warning. Inheritance behaves the same way and produces a common surprise.
# Inheritance: both operands find the same method through the class vector.
parent <- structure(1, class = "parent")
child <- structure(2, class = c("child", "parent"))
Ops.parent <- function(e1, e2) "the method of parent"
str(capture(child + parent))
#> List of 2
#> $ value : chr "the method of parent"
#> $ warnings: chr(0)
str(capture(parent + child))
#> List of 2
#> $ value : chr "the method of parent"
#> $ warnings: chr(0)
# A subclass that adds its own group method creates a conflict with parent.
Ops.child <- function(e1, e2) "the method of child"
str(capture(child + child))
#> List of 2
#> $ value : chr "the method of child"
#> $ warnings: chr(0)
str(capture(child + parent))
#> List of 2
#> $ value : num 3
#> $ warnings: chr "Incompatible methods (\"Ops.child\", \"Ops.parent\") for \"+\""
child inherits from parent, so both operands resolve to Ops.parent and the method is used. As soon as the subclass defines its own Ops.child, child + child is fine but child + parent finds Ops.child for the left operand and Ops.parent for the right one: different methods, so the warning appears. A subclass that overrides a group method should keep the parent’s method working for mixed operands, or define chooseOpsMethod() (next section) to say which of the two wins.
chooseOpsMethod(): letting a class win
R 4.3.0 added the generic chooseOpsMethod(x, y, mx, my, cl, reverse) for exactly this conflict. The NEWS entry describes it as a mechanism for objects to resolve the cases in which two suitable methods are found for an Ops call, and says that it "supports experimenting with alternative object systems"; the help page describes it as the function the Ops group generic calls when two suitable methods are found. It is an S3 generic that dispatches on x, with these arguments:
-
xandy: the objects being dispatched on (the first call hasxas the first operand); -
mxandmy: the methods that were found forxand fory; -
cl: the call to the group generic; -
reverse:FALSEin the first call;TRUEin the second call, in whichxandy(andmxandmy) are swapped.
It must return TRUE or FALSE, and TRUE means that the method mx should be used. R first calls it with the operands in the order they were written. If the answer is FALSE it calls it again with the two operands swapped and reverse = TRUE, so a class only has to define chooseOpsMethod.myclass and say TRUE to have its own method win regardless of the side it stands on. If neither call answers TRUE, the warning from the previous section is signalled. The default method in base R returns FALSE.
foo <- function() structure(1, class = "foo")
bar <- function() structure(2, class = "bar")
`*.foo` <- function(e1, e2) "the method of foo"
Ops.bar <- function(e1, e2) "the group method of bar"
# The default says "no preference" for every pair.
names(formals(chooseOpsMethod))
#> [1] "x" "y" "mx" "my" "cl" "reverse"
body(getS3method("chooseOpsMethod", "default"))
#> [1] FALSE
# bar declares that its method wins against anything.
chooseOpsMethod.bar <- function(x, y, mx, my, cl, reverse) TRUE
foo() * bar()
#> [1] "the group method of bar"
bar() * foo()
#> [1] "the group method of bar"
Source: R documentation: chooseOpsMethod
chooseOpsMethod.bar returning TRUE makes bar win in both orders: for foo() * bar() R first asks foo (no method, so the default answers FALSE) and then asks bar with the arguments swapped. The second part of the script is a more careful policy that only claims the operation when the other operand is a foo, and records every call; the log shows that only bar was ever consulted, once with reverse = TRUE for foo() * bar() and once with reverse = FALSE for bar() * foo().
# A more careful policy that also records how it was called.
seen <- new.env()
seen$calls <- character()
chooseOpsMethod.bar <- function(x, y, mx, my, cl, reverse) {
seen$calls <- c(
seen$calls,
sprintf("x is %s, reverse = %s", class(x), reverse)
)
inherits(y, "foo")
}
foo() * bar()
#> [1] "the group method of bar"
bar() * foo()
#> [1] "the group method of bar"
seen$calls
#> [1] "x is bar, reverse = TRUE" "x is bar, reverse = FALSE"
Source: R documentation: chooseOpsMethod
Because chooseOpsMethod() is an ordinary S3 generic, a package registers its methods with S3method(chooseOpsMethod, myclass). It is not a replacement for good design: the winning method still has to understand the other operand, or refuse it clearly, which is what Ops.money does below for dates. It matters most when two independently written packages define classes that must interoperate, and it is the hook that packages implementing alternative object systems use: the S7 package (CRAN version 0.2.2 when this page was checked) registers chooseOpsMethod.S7_object, which methods("chooseOpsMethod") lists after library(S7). That remark was checked separately and is not part of any script on this page.
R’s own S4 system has a separate and richer mechanism for the same job: the S4 group generics Arith, Compare and Logic (members of Ops), Math, Math2 (which contains round and signif), Summary and Complex select their methods from the signature of both arguments; they are documented in ?S4groupGeneric and S4 Classes and Methods.
A money class built on doubles
The rest of the page builds one class in steps: money, a double vector with a currency attribute, used for the late fees of the library shelf. The design is deliberately small, and the rules are the point:
-
a
moneyvector has one currency, stored as an attribute, and every operation keeps it; -
money can be added to and subtracted from money of the same currency, scaled by a plain number and divided by a plain number; the ratio of two amounts is a plain number;
-
it can be compared with money of the same currency;
-
it can be rounded, summed, ranged and averaged; it cannot be raised to a power, square-rooted, or multiplied by itself;
-
anything else is an error with a message that names the problem, never a silent
NAor a silent loss of the class.
Doubles are used because the aim is to show the S3 machinery. Real accounting code stores whole minor units (cents) in integers, or uses a decimal type, because binary floating point cannot represent most decimal fractions exactly; the last script of this page shows 0.1 + 0.2 == 0.3 failing for the same reason. The whole class lives in one script, which is shown below in consecutive pieces. Each block continues the previous one, the output after each expression is what the script printed, and the blocks together are the script from top to bottom.
Constructor, formatting and printing
The pattern from S3 Classes and Generics applies: a low-level new_money() that only builds the object, a friendly money() that validates, and accessors. amount() strips the attributes so that arithmetic on the numbers can never dispatch back to the class. format.money() formats with formatC(..., format = "f", digits = 2, big.mark = ",") and appends the currency; its digits argument defaults to NULL because print.data.frame() passes digits = NULL down to format(), and a method that did not tolerate it would print four decimals in a data frame. The %||% operator used for the default is in base R since 4.4.0. print.money() prints the formatted strings without quotes, right-aligned, and returns its argument invisibly, as every print method should.
source("scenario.R")
# Part 1: constructor, helper, accessors, format and print.
new_money <- function(amount = double(), currency = "EUR") {
stopifnot(is.double(amount), is.character(currency))
structure(amount, currency = currency, class = "money")
}
money <- function(amount = double(), currency = "EUR") {
if (!is.character(currency) || length(currency) != 1L ||
!grepl("^[A-Z]{3}$", currency)) {
stop("currency must be a three-letter code such as \"EUR\"",
call. = FALSE)
}
new_money(as.double(amount), currency)
}
currency <- function(x) attr(x, "currency", exact = TRUE)
amount <- function(x) as.vector(unclass(x))
format.money <- function(x, digits = NULL, ...) {
digits <- digits %||% 2L
out <- paste(
formatC(amount(x), format = "f", digits = digits, big.mark = ","),
currency(x)
)
out[is.na(amount(x))] <- NA_character_
names(out) <- names(x)
out
}
print.money <- function(x, ...) {
print(format(x), quote = FALSE, right = TRUE, ...)
invisible(x)
}
fees <- money(c(0.25, 1.5, 12, 1234.5))
fees
#> [1] 0.25 EUR 1.50 EUR 12.00 EUR 1,234.50 EUR
class(fees)
#> [1] "money"
unclass(fees)
#> [1] 0.25 1.50 12.00 1234.50
#> attr(,"currency")
#> [1] "EUR"
tryCatch(money(5, "euros"), error = \(e) conditionMessage(e))
#> [1] "currency must be a three-letter code such as \"EUR\""
Source: R documentation: formatC
fees prints as money, unclass(fees) shows what is underneath (a double vector with a currency attribute and no class), and a bad currency is stopped by the helper.
Keeping the class through subsetting, c(), rep() and unique()
The default [ keeps names, dimensions and dimnames and drops every other attribute, including the class. A vector class must therefore define [ (and usually [[ and [<-) or every subset turns back into a bare number. The standard shape is to call NextMethod() and re-wrap the result with the attribute taken from the original. The same applies to c(), rep() and unique(): each of them builds a new vector and does not keep attributes. rev() and sort() need no method of their own: the body of rev.default is x[length(x):1L] and sort.default indexes with x[order(...)] for any object, so both use [.money automatically. length() needs nothing either, because the length of the underlying vector is the length of the money vector; it would need a method for a class built on a list.
Two details of c.money() come from how c() dispatches. It dispatches on its first argument only, so the method receives every argument, including the named recursive and use.names, in its formals; leaving them out makes c(..., recursive = TRUE), which range.default calls internally, put TRUE into the list of parts. Since R 4.1.0 c() also removes NULL arguments before dispatching to methods (but keeps a NULL first argument for backward compatibility), which simplifies such methods. The assignment method [<-.money refuses to store a plain number or another currency, because the assignment would silently put numbers of the wrong kind into the vector.
# Part 2: keep the attribute when subsetting and combining.
`[.money` <- function(x, ...) new_money(NextMethod(), currency(x))
`[[.money` <- function(x, ...) new_money(NextMethod(), currency(x))
`[<-.money` <- function(x, ..., value) {
if (!inherits(value, "money") || !identical(currency(value), currency(x))) {
stop("can only store ", currency(x), " amounts in a ", currency(x),
" vector", call. = FALSE)
}
new_money(NextMethod(), currency(x))
}
c.money <- function(..., recursive = FALSE,
use.names = TRUE) { # nolint: object_name_linter.
parts <- list(...)
cur <- unique(vapply(parts, currency, character(1)))
if (length(cur) != 1L) {
stop("cannot combine ", paste(cur, collapse = " and "), call. = FALSE)
}
new_money(unlist(lapply(parts, amount)), cur)
}
rep.money <- function(x, ...) new_money(NextMethod(), currency(x))
unique.money <- function(x, ...) new_money(NextMethod(), currency(x))
fees[2:3]
#> [1] 1.50 EUR 12.00 EUR
fees[[4]]
#> [1] 1,234.50 EUR
rev(fees)
#> [1] 1,234.50 EUR 12.00 EUR 1.50 EUR 0.25 EUR
fees[-1]
#> [1] 1.50 EUR 12.00 EUR 1,234.50 EUR
x <- fees
x[2] <- money(2)
x
#> [1] 0.25 EUR 2.00 EUR 12.00 EUR 1,234.50 EUR
tryCatch({
x[1] <- money(1, "USD")
}, error = \(e) conditionMessage(e))
#> [1] "can only store EUR amounts in a EUR vector"
tryCatch({
x[1] <- 1
}, error = \(e) conditionMessage(e))
#> [1] "can only store EUR amounts in a EUR vector"
c(fees, money(0.75))
#> [1] 0.25 EUR 1.50 EUR 12.00 EUR 1,234.50 EUR 0.75 EUR
tryCatch(c(fees, money(1, "USD")), error = \(e) conditionMessage(e))
#> [1] "cannot combine EUR and USD"
rep(fees[1:2], times = 2)
#> [1] 0.25 EUR 1.50 EUR 0.25 EUR 1.50 EUR
unique(c(fees, fees))
#> [1] 0.25 EUR 1.50 EUR 12.00 EUR 1,234.50 EUR
length(fees)
#> [1] 4
sort(fees, decreasing = TRUE)
#> [1] 1,234.50 EUR 12.00 EUR 1.50 EUR 0.25 EUR
order(fees)
#> [1] 1 2 3 4
Source: R documentation: Extract
sort(fees, decreasing = TRUE) and order(fees) worked without an xtfrm method, which is explained in the section Sorting and xtfrm().
The Ops group
Ops.money carries the rules from the list above in one switch(generic, ...). A helper same_currency() raises the error for mixed currencies. Notice the structure: the unary case is handled first, using nargs(); then the binary operators are grouped by the rule they obey, with the fall-through arms ("+" = , "-" = { ... }) used for the operators that share a rule; and the final unnamed arm is the default, which turns every other member of the group (^, %%, %/%, &, |) into an error.
# Part 3: the Ops group.
same_currency <- function(e1, e2, verb) {
if (!identical(currency(e1), currency(e2))) {
stop("cannot ", verb, " ", currency(e1), " and ", currency(e2),
call. = FALSE)
}
}
Ops.money <- function(e1, e2) {
generic <- .Generic # nolint: object_usage_linter.
if (nargs() == 1L) {
if (generic %in% c("+", "-")) {
return(new_money(get(generic)(amount(e1)), currency(e1)))
}
stop("unary ", generic, " is not meaningful for money", call. = FALSE)
}
both <- inherits(e1, "money") && inherits(e2, "money")
switch(generic,
"+" = , "-" = {
if (!both) stop("both operands must be money", call. = FALSE)
same_currency(e1, e2, c("+" = "add", "-" = "subtract")[[generic]])
new_money(get(generic)(amount(e1), amount(e2)), currency(e1))
},
"*" = {
if (both) stop("cannot multiply money by money", call. = FALSE)
cur <- currency(if (inherits(e1, "money")) e1 else e2)
new_money(amount(e1) * amount(e2), cur)
},
"/" = {
if (both) {
same_currency(e1, e2, "divide")
amount(e1) / amount(e2)
} else if (inherits(e1, "money")) {
new_money(amount(e1) / amount(e2), currency(e1))
} else {
stop("cannot divide a number by money", call. = FALSE)
}
},
"==" = , "!=" = , "<" = , ">" = , "<=" = , ">=" = {
if (!both) stop("can only compare money with money", call. = FALSE)
same_currency(e1, e2, "compare")
get(generic)(amount(e1), amount(e2))
},
stop(sQuote(generic), " is not meaningful for money", call. = FALSE)
)
}
Source: R documentation: groupGeneric
The examples show the rules at work. The comparison operators return plain logical vectors, the ratio of two amounts is a plain number, and multiplying money by a number works from either side. Every refusal is a stop with a message of its own: mixed currencies, a bare number where money was expected, money times money, a character operand (here the error comes from the internal arithmetic, since amount(e1) * amount(e2) reaches it), a logical operator, and the unary !.
fees + fees
#> [1] 0.50 EUR 3.00 EUR 24.00 EUR 2,469.00 EUR
fees - money(0.25)
#> [1] 0.00 EUR 1.25 EUR 11.75 EUR 1,234.25 EUR
fees * 2
#> [1] 0.50 EUR 3.00 EUR 24.00 EUR 2,469.00 EUR
3 * fees
#> [1] 0.75 EUR 4.50 EUR 36.00 EUR 3,703.50 EUR
fees / 4
#> [1] 0.06 EUR 0.38 EUR 3.00 EUR 308.62 EUR
fees[4] / fees[1]
#> [1] 4938
-fees[1:2]
#> [1] -0.25 EUR -1.50 EUR
fees > money(1)
#> [1] FALSE TRUE TRUE TRUE
fees == money(1.5)
#> [1] FALSE TRUE FALSE FALSE
tryCatch(fees + money(1, "USD"), error = \(e) conditionMessage(e))
#> [1] "cannot add EUR and USD"
tryCatch(fees + 1, error = \(e) conditionMessage(e))
#> [1] "both operands must be money"
tryCatch(fees * fees, error = \(e) conditionMessage(e))
#> [1] "cannot multiply money by money"
tryCatch(fees * "a", error = \(e) conditionMessage(e))
#> [1] "non-numeric argument to binary operator"
tryCatch(fees < money(1, "USD"), error = \(e) conditionMessage(e))
#> [1] "cannot compare EUR and USD"
tryCatch(fees & fees, error = \(e) conditionMessage(e))
#> [1] "‘&’ is not meaningful for money"
tryCatch(!fees, error = \(e) conditionMessage(e))
#> [1] "unary ! is not meaningful for money"
Source: R documentation: Arithmetic
Printing rounds to two digits, which is why fees / 4 shows 0.06 EUR and 0.38 EUR for the stored values 0.0625 and 0.375. The stored doubles are not rounded; only format() is.
The Math group
Math.money accepts the members that keep the unit (abs, floor, ceiling, trunc, round, signif, cumsum), returns a plain number for sign, and refuses everything else, among them sqrt and exp, whose results would not be an amount of currency. The digits of round() and signif() travels in the .... Because the method calls get(generic)(amount(x), ...), the extra arguments reach the real function.
# Part 4: the Math group.
Math.money <- function(x, ...) {
generic <- .Generic # nolint: object_usage_linter.
switch(generic,
abs = , floor = , ceiling = , trunc = , round = , signif = , cumsum =
new_money(get(generic)(amount(x), ...), currency(x)),
sign = sign(amount(x)),
stop(generic, "() is not meaningful for money", call. = FALSE)
)
}
third <- money(10) / 3
third
#> [1] 3.33 EUR
round(third, 2)
#> [1] 3.33 EUR
round(third, 2) * 3
#> [1] 9.99 EUR
floor(fees)
#> [1] 0.00 EUR 1.00 EUR 12.00 EUR 1,234.00 EUR
abs(money(-2.5))
#> [1] 2.50 EUR
cumsum(fees)
#> [1] 0.25 EUR 1.75 EUR 13.75 EUR 1,248.25 EUR
sign(money(c(-2, 0, 3)))
#> [1] -1 0 1
tryCatch(sqrt(fees), error = \(e) conditionMessage(e))
#> [1] "sqrt() is not meaningful for money"
tryCatch(exp(fees), error = \(e) conditionMessage(e))
#> [1] "exp() is not meaningful for money"
Source: R documentation: Round
round(third, 2) * 3 is 9.99, not 10: rounding an amount is a choice about when the cents are lost, and the class does not make that choice for you.
The Summary group
Summary.money dispatches on the first argument and receives all of them in ..., so the method checks every one of them: it demands that all are money and all have the same currency. It then calls NextMethod(), which runs the internal sum, min, max or range on the original arguments, and wraps the plain number it returns in new_money(); the internal code drops the attributes, so the re-wrapping is not optional. na.rm is a formal argument of the method, and NextMethod() passes it on. prod, all and any are members of the group and are refused.
# Part 5: the Summary group.
Summary.money <- function(..., na.rm = FALSE) { # nolint: object_name_linter.
generic <- .Generic # nolint: object_usage_linter.
if (!generic %in% c("sum", "min", "max", "range")) {
stop(generic, "() is not meaningful for money", call. = FALSE)
}
parts <- list(...)
if (!all(vapply(parts, inherits, logical(1), what = "money"))) {
stop("all arguments must be money", call. = FALSE)
}
cur <- unique(vapply(parts, currency, character(1)))
if (length(cur) != 1L) {
stop("cannot combine ", paste(cur, collapse = " and "), call. = FALSE)
}
new_money(NextMethod(), cur)
}
sum(fees)
#> [1] 1,248.25 EUR
min(fees)
#> [1] 0.25 EUR
max(fees)
#> [1] 1,234.50 EUR
range(fees)
#> [1] 0.25 EUR 1,234.50 EUR
sum(fees, money(100))
#> [1] 1,348.25 EUR
sum(money(c(1, NA, 3)))
#> [1] <NA>
sum(money(c(1, NA, 3)), na.rm = TRUE)
#> [1] 4.00 EUR
tryCatch(prod(fees), error = \(e) conditionMessage(e))
#> [1] "prod() is not meaningful for money"
tryCatch(any(fees), error = \(e) conditionMessage(e))
#> [1] "any() is not meaningful for money"
tryCatch(sum(fees, 1), error = \(e) conditionMessage(e))
#> [1] "all arguments must be money"
tryCatch(sum(fees, money(1, "USD")), error = \(e) conditionMessage(e))
#> [1] "cannot combine EUR and USD"
Source: R documentation: range
range() is a good test of the whole class: range.default calls c(), min() and max() on its arguments, so it only works when c.money, Summary.money and new_money() all behave. sum(fees, money(100)) shows the dots collecting two vectors, and the four error lines show the checks at work.
Generics that are not in a group
A frequent mistake, in tutorials and in practice, is to expect a group method to cover functions that look like members. It does not. mean, median, rev, sort, unique, rep, c, seq, diff, format, print, as.character and as.double are not members of any group; some are internal generics, some are ordinary S3 generics written in R, and each dispatches on its own. The following script uses a class with a currency attribute and no methods at all, and records, for each call, what class came back and whether the attribute survived. The group column says which group the function belongs to, if any.
# A vector class with a "currency" attribute and NO methods at all.
bare <- structure(c(0.25, 1.5, 1.5, 12), currency = "EUR", class = "bare")
# What happens to the class and to the attribute in each call?
results <- list(
"x[1:2]" = bare[1:2],
"x[[1]]" = bare[[1]],
"rev(x)" = rev(bare),
"rep(x, 2)" = rep(bare, 2),
"c(x, x)" = c(bare, bare),
"unique(x)" = unique(bare),
"sort(x)" = sort(bare),
"mean(x)" = mean(bare),
"median(x)" = median(bare),
"diff(x)" = diff(bare),
"seq(x[1], x[4], length.out = 3)" = seq(bare[1], bare[4], length.out = 3),
"as.character(x)" = as.character(bare),
"as.double(x)" = as.double(bare),
"x + 1" = bare + 1,
"x * 2" = bare * 2,
"x > 1" = bare > 1,
"round(x)" = round(bare),
"sqrt(x)" = sqrt(bare),
"cumsum(x)" = cumsum(bare),
"sum(x)" = sum(bare),
"max(x)" = max(bare),
"range(x)" = range(bare)
)
group <- c(
"x + 1" = "Ops", "x * 2" = "Ops", "x > 1" = "Ops",
"round(x)" = "Math", "sqrt(x)" = "Math", "cumsum(x)" = "Math",
"sum(x)" = "Summary", "max(x)" = "Summary", "range(x)" = "Summary"
)
survey <- data.frame(
call = names(results),
group = ifelse(is.na(group[names(results)]), "-", group[names(results)]),
class = vapply(results, \(r) class(r)[[1L]], character(1)),
currency = vapply(
results, \(r) if (is.null(attr(r, "currency"))) "lost" else "kept",
character(1)
),
row.names = NULL
)
print(survey, right = FALSE)
#> call group class currency
#> 1 x[1:2] - numeric lost
#> 2 x[[1]] - numeric lost
#> 3 rev(x) - numeric lost
#> 4 rep(x, 2) - numeric lost
#> 5 c(x, x) - numeric lost
#> 6 unique(x) - numeric lost
#> 7 sort(x) - numeric lost
#> 8 mean(x) - numeric lost
#> 9 median(x) - numeric lost
#> 10 diff(x) - bare lost
#> 11 seq(x[1], x[4], length.out = 3) - numeric lost
#> 12 as.character(x) - character lost
#> 13 as.double(x) - numeric lost
#> 14 x + 1 Ops bare kept
#> 15 x * 2 Ops bare kept
#> 16 x > 1 Ops logical lost
#> 17 round(x) Math bare kept
#> 18 sqrt(x) Math bare kept
#> 19 cumsum(x) Math numeric lost
#> 20 sum(x) Summary numeric lost
#> 21 max(x) Summary numeric lost
#> 22 range(x) Summary numeric lost
Source: R documentation: groupGeneric
Read the table by column. The two arithmetic Ops rows, round and sqrt keep the class and the currency, because the internal code copies the attributes of the operands. The comparison x > 1 answers a logical vector. cumsum and the three Summary rows return plain numbers. Every function outside a group (x[1:2], rev, rep, c, unique, sort, mean, median, seq, as.double) returns a plain numeric and loses the attribute; diff is the odd one, because diff.default restores the class but not the attribute, so it returns an object of the right class with a missing currency, which is worse than losing the class. as.character produces text.
The consequence is a checklist for any vector class built on a basic type: define [, [[ and [<-; define c (and rep, unique) if the class is meant to be concatenated; and decide for each statistic (mean, median, range, quantile) whether it makes sense. For money the remaining generics are short:
# Part 6: generics that are not in a group need methods of their own.
tryCatch(fees$currency, error = \(e) conditionMessage(e))
#> [1] "$ operator is invalid for atomic vectors"
mean.money <- function(x, ...) new_money(mean(amount(x), ...), currency(x))
median.money <- function(x, na.rm = FALSE, ...) { # nolint: object_name_linter.
new_money(median(amount(x), na.rm = na.rm), currency(x))
}
seq.money <- function(from, to, by, ...) {
new_money(seq(amount(from), amount(to), by = amount(by), ...), currency(from))
}
as.character.money <- function(x, ...) format(x, ...)
as.double.money <- function(x, ...) amount(x)
`$.money` <- function(x, name) {
switch(name,
currency = currency(x),
amount = amount(x),
stop("money has no field ", sQuote(name), call. = FALSE)
)
}
mean(fees)
#> [1] 312.06 EUR
median(fees)
#> [1] 6.75 EUR
seq(money(0), money(1), by = money(0.25))
#> [1] 0.00 EUR 0.25 EUR 0.50 EUR 0.75 EUR 1.00 EUR
as.character(fees[1:2])
#> [1] "0.25 EUR" "1.50 EUR"
paste("fee:", fees[1:2])
#> [1] "fee: 0.25 EUR" "fee: 1.50 EUR"
as.double(fees)
#> [1] 0.25 1.50 12.00 1234.50
fees$currency
#> [1] "EUR"
fees$amount
#> [1] 0.25 1.50 12.00 1234.50
tryCatch(fees$cost, error = \(e) conditionMessage(e))
#> [1] "money has no field ‘cost’"
Source: R documentation: median
mean.money and median.money unwrap, compute and re-wrap; median.money has to name na.rm as a formal because median() has it as a formal argument. seq.money supports from, to and by, and seq() dispatches on its first argument. as.character.money makes paste() use the formatted text (the paste result is fee: 0.25 EUR, not the bare number), and as.double.money returns the number; as.numeric is the same function, so methods are written for as.double.
The first line of the block is the reason for $.money: the default $ stops on atomic vectors. A $ method is easy to write but needs care on a class built on an atomic vector. It receives the name as a string already, x$name never evaluates the name, and any code that calls $ on a money vector will meet your method. Define it only when the class has real named fields to expose, and prefer [[ with a name (where names have meaning) or accessor functions such as currency() for everything else. A list-based class such as the book class of the scenario should not define $ at all, because the default does exactly what is needed. On the other hand, $<- and [[<- are separate generics and need separate methods if the object should be modifiable by name.
Sorting and xtfrm()
sort() and order() rank objects with the generic xtfrm(), which returns a numeric vector that sorts in the same order as the objects. For a numeric class the default method returns unclass(x), so money sorted correctly above with no xtfrm.money. The method matters when the class stores something that does not sort the way its numbers or text do. A copy of a library book has a condition, stored as text but ranked by quality; alphabetical order puts fair before good before new before poor.
# The condition of a library copy, stored as text but ordered by quality.
condition_levels <- c("poor", "fair", "good", "new")
copy_condition <- function(x = character()) {
stopifnot(all(x %in% condition_levels))
structure(x, class = "copy_condition")
}
`[.copy_condition` <- function(x, ...) copy_condition(NextMethod())
format.copy_condition <- function(x, ...) format(unclass(x), ...)
print.copy_condition <- function(x, ...) {
print(format(x), quote = FALSE)
invisible(x)
}
shelf <- copy_condition(c("good", "new", "poor", "fair", "good"))
# Without xtfrm() the default sorts the text alphabetically.
sort(shelf)
#> [1] fair good good new poor
order(shelf)
#> [1] 4 1 5 2 3
# xtfrm() returns the sort keys; sort() and order() use it for any object.
xtfrm.copy_condition <- function(x) match(unclass(x), condition_levels)
xtfrm(shelf)
#> [1] 3 4 1 2 3
sort(shelf)
#> [1] poor fair good good new
sort(shelf, decreasing = TRUE)
#> [1] new good good fair poor
order(shelf)
#> [1] 3 4 1 5 2
# A numeric class needs no method: the default returns the numbers.
getS3method("xtfrm", "default")(structure(c(3, 1, 2), class = "anything"))
#> [1] 3 1 2
Source: R documentation: xtfrm
Without xtfrm.copy_condition the default ranks the text alphabetically; with it, sort() and order() use the quality order, and sort(shelf, decreasing = TRUE) reverses it. sort() also needs the [ method here, since sort.default builds its answer with x[order(x)] for objects. The NEWS for R 4.0.0 records that sort.list() was extended to work for non-atomic objects through xtfrm() methods.
Late fees on the shelf
The class is now enough for the shelf scenario. A loan is late for each day beyond loan_days (21) counted to the return date or, for a loan still out, to shelf_today; the fee is 0.25 EUR per late day. loans$fee <- late_fee keeps the class inside the data frame, format.money() is what makes it print as 1.25 EUR, and the row selection loans[which(late_fee > money(10)), ...] uses the comparison method. Because late_fee has NA for the loan with the impossible date, na.rm = TRUE is passed through Summary.money and mean.money.
# Part 7: late fees on the shelf, 0.25 EUR per day after the loan period.
end_date <- loans$return_date
end_date[is.na(end_date)] <- shelf_today
days_out <- as.integer(end_date - loans$loan_date)
days_late <- pmax(0L, days_out - loan_days)
late_fee <- money(0.25 * days_late)
loans$fee <- late_fee
head(loans[, c("loan_id", "loan_date", "fee")], 4)
#> loan_id loan_date fee
#> 1 L001 2025-01-14 0.00 EUR
#> 2 L002 2025-01-16 0.00 EUR
#> 3 L003 2025-01-21 0.00 EUR
#> 4 L004 2025-01-26 1.25 EUR
fee_total <- sum(late_fee, na.rm = TRUE)
fee_total
#> [1] 111.75 EUR
max(late_fee, na.rm = TRUE)
#> [1] 15.75 EUR
mean(late_fee, na.rm = TRUE)
#> [1] 1.89 EUR
round(late_fee[1:6])
#> [1] 0.00 EUR 0.00 EUR 0.00 EUR 1.00 EUR 1.00 EUR 0.00 EUR
long_overdue <- loans[which(late_fee > money(10)), c("loan_id", "fee")]
head(long_overdue[order(long_overdue$fee, decreasing = TRUE), ], 4)
#> loan_id fee
#> 54 L054 15.75 EUR
#> 55 L055 15.00 EUR
#> 56 L056 12.50 EUR
#> 57 L057 11.00 EUR
member <- tolower(trimws(loans$member))
member[!nzchar(member)] <- NA
per_member <- vapply(
split(late_fee, member),
\(f) amount(sum(f, na.rm = TRUE)),
numeric(1)
)
head(sort(new_money(per_member), decreasing = TRUE), 3)
#> dev patel ana ruiz chloe martin
#> 25.25 EUR 24.75 EUR 24.50 EUR
Source: R documentation: split
The per-member total uses split(), which indexes the money vector with [ for each group (that is why [.money matters even when you never subset by hand), and then rebuilds a named money vector with new_money() so that it prints with currency and sorts with the default xtfrm().
Money meets a date
The last part of the script crosses the class with one that has its own method. late_fee[1:3] + shelf_today adds money to a Date; the methods are Ops.money and +.Date, they differ, and chooseOpsMethod() is not defined yet, so R warns and uses the internal addition. suppressWarnings() shows what comes out: the number of days since 1970 added to the fee and presented as 20,634 EUR, which is a wrong amount that looks plausible. Defining chooseOpsMethod.money() that returns TRUE makes the money method win in both orders, and Ops.money then refuses the date with its own message; the money-to-money addition at the end still works.
# Part 8: mixing money with a class that has its own method.
tryCatch(late_fee[1:3] + shelf_today, warning = \(w) conditionMessage(w))
#> [1] "Incompatible methods (\"Ops.money\", \"+.Date\") for \"+\""
suppressWarnings(late_fee[1:3] + shelf_today)
#> [1] 20,634.00 EUR 20,634.00 EUR 20,634.00 EUR
chooseOpsMethod.money <- function(x, y, mx, my, cl, reverse) TRUE
tryCatch(late_fee[1:3] + shelf_today, error = \(e) conditionMessage(e))
#> [1] "both operands must be money"
tryCatch(shelf_today + late_fee[1:3], error = \(e) conditionMessage(e))
#> [1] "both operands must be money"
tryCatch(late_fee[1:3] == shelf_today, error = \(e) conditionMessage(e))
#> [1] "can only compare money with money"
late_fee[1:3] + money(1)
#> [1] 1.00 EUR 1.00 EUR 1.00 EUR
Source: R documentation: chooseOpsMethod
That is the practical use of chooseOpsMethod(): it does not decide what the combination means, it decides whose method gets to decide. A class that wants to interoperate with another one would handle that operand inside Ops.money instead of refusing it.
matrixOps and Complex
The two smallest groups rarely need a method of their own, but they follow the same rules. matrixOps was added in R 4.3.0 with %*%, which became an S3 generic with it; crossprod() and tcrossprod() became generic in R 4.4.0 and joined the group. Its members dispatch on both arguments exactly like Ops, and .Generic tells the method which one was called. Because crossprod(x) is called with one argument, the method has to handle a missing y. The Complex group dispatches on z.
# matrixOps: %*% dispatches on both arguments, like Ops (R 4.3.0 and later);
# crossprod() and tcrossprod() joined the group in R 4.4.0.
counts <- function(m) structure(m, class = "counts")
matrixOps.counts <- function(x, y) {
generic <- .Generic # nolint: object_usage_linter.
group <- .Group # nolint: object_usage_linter.
cat("matrixOps method:", generic, "in group", group, "\n")
if (missing(y)) {
y <- x
}
counts(get(generic)(unclass(x), unclass(y)))
}
m <- counts(matrix(1:4, nrow = 2))
unclass(m %*% m)
#> matrixOps method: %*% in group matrixOps
#> [,1] [,2]
#> [1,] 7 15
#> [2,] 10 22
unclass(m %*% matrix(1, nrow = 2, ncol = 2))
#> matrixOps method: %*% in group matrixOps
#> [,1] [,2]
#> [1,] 4 4
#> [2,] 6 6
unclass(matrix(1, nrow = 2, ncol = 2) %*% m)
#> matrixOps method: %*% in group matrixOps
#> [,1] [,2]
#> [1,] 3 7
#> [2,] 3 7
unclass(crossprod(m))
#> matrixOps method: crossprod in group matrixOps
#> [,1] [,2]
#> [1,] 5 11
#> [2,] 11 25
class(tcrossprod(m))
#> matrixOps method: tcrossprod in group matrixOps
#> [1] "counts"
# Complex: Arg, Conj, Im, Mod and Re dispatch on z.
Complex.tagged <- function(z) {
generic <- .Generic # nolint: object_usage_linter.
cat("Complex method:", generic, "\n")
NextMethod()
}
z <- structure(complex(real = 3, imaginary = 4), class = "tagged")
unclass(Mod(z))
#> Complex method: Mod
#> [1] 5
unclass(Re(z))
#> Complex method: Re
#> [1] 3
unclass(Conj(z))
#> Complex method: Conj
#> [1] 3-4i
Source: R documentation: crossprod
Every call was routed through the same method with .Group equal to matrixOps, including crossprod() and tcrossprod(), and a %*% with a plain matrix on either side. The Complex methods show the NextMethod() pattern again; Mod(z) and Re(z) keep the class attribute because the internal code copies attributes, which is why the script unclasses the printed values.
Group methods in a package
At top level of a script, or in the global environment of a session, Ops.money is found because it is visible from the call. Inside a package the methods live in the namespace, and S3 dispatch finds them there only through the registration database (a function that is merely defined in the namespace is not visible from the caller), so they have to be registered. The NAMESPACE file of a package that contains the money class registers each method with an S3method() directive; the names of operators and other non-standard names must be quoted, and group methods use the group name as the generic.
S3method("[", money)
S3method("[[", money)
S3method("[<-", money)
S3method("$", money)
S3method(Math, money)
S3method(Ops, money)
S3method(Summary, money)
S3method(c, money)
S3method(chooseOpsMethod, money)
S3method(format, money)
S3method(mean, money)
S3method(print, money)
With roxygen2 these lines are generated from @export or @exportS3Method tags on the method definitions; see Documentation with Rd, roxygen2 and Vignettes and Packages, Libraries and Namespaces. A method defined for a generic from another package (such as median, which is in stats) needs that generic to be imported as well. Writing R Extensions also notes that usage sections of Rd files for S3 methods, for members of the Ops group and for user-defined infix operators follow the same naming rules.
Common messages and what they mean
| Message | Kind | What it means and what to do |
|---|---|---|
|
warning |
The two operands selected different methods and |
|
error |
A group method called the operator on the classed objects again. Use |
|
error |
A |
|
warning |
|
|
error |
|
|
error |
The specific |
|
error |
The internal arithmetic received a character or other non-numeric operand; a group method that reaches the internal code passes it on. |
|
error |
A |
|
error |
|
|
error |
The messages of this page’s own |
Source: R documentation: groupGeneric
Version notes
| Version | Change |
|---|---|
R 4.0.0 |
|
R 4.1.0 |
|
R 4.3.0 |
New generic |
R 4.4.0 |
|
R 4.6.1 |
The version of this page. The |
Where older tutorials differ
Many S3 tutorials were written before chooseOpsMethod() and before the classed-atomic improvements of as.data.frame(). The table below lists approaches and simplifications that are common in older material and what R 4.6.1 does instead.
| Older approach or simplification | Current behaviour | Why |
|---|---|---|
S3 dispatches on the first argument only. |
|
The group help page says that the classes of both arguments are considered. |
Resolve incompatible methods by giving both classes the same method. |
Define |
It is the supported way to say which method wins; before 4.3.0 there was only the warning and the internal fallback. |
Define |
Define |
One group method covers the 16 members of |
|
Identical to the backtick form. |
The assignment creates the same binding; the string form only matters for style (and |
Write |
Not needed in R 4.6.1; the default method handles classed atomic vectors since R 4.3.0. |
The NEWS entry of R 4.3.0 names |
Using |
Needed when you call the operator by name; |
Calling the operator on classed objects recurses until R stops it. |
|
Only |
The group has seven members: |
|
Both also compare the attributes and the class. |
See the script below: |
The remaining items of the table are shown by a script: strings as method names, one method per operator with an unclass() in each, a class that forgets [.price, comparisons with all.equal(), and the data frame column that needs no as.data.frame method any more.
price <- function(x) structure(as.double(x), class = "price")
# 1. Method names written as strings: the same binding as the backtick form.
"+.price" <- function(e1, e2) price(unclass(e1) + unclass(e2)) # nolint
identical(
get("+.price"),
getS3method("+", "price")
)
#> [1] TRUE
# 2. One method per operator with unclass() in each, instead of Ops.price;
# the operators without a method fall back to the internal code.
"-.price" <- function(e1, e2) price(unclass(e1) - unclass(e2)) # nolint
unclass(price(5) + price(2.5) - price(1))
#> [1] 6.5
class(price(5) * price(2))
#> [1] "price"
class(price(5) > price(2))
#> [1] "logical"
# 3. Forgetting [.price: the class (and everything else) is gone after [ .
p <- price(c(1.5, 2, 8))
class(p[1:2])
#> [1] "numeric"
class(rev(p))
#> [1] "numeric"
# 4. all.equal() on classed vectors compares the attributes too.
all.equal(p, c(1.5, 2, 8))
#> [1] "Attributes: < Modes: list, NULL >"
#> [2] "Attributes: < Lengths: 1, 0 >"
#> [3] "Attributes: < names for target but not for current >"
#> [4] "Attributes: < current is not list-like >"
#> [5] "target is price, current is numeric"
isTRUE(all.equal(p, c(1.5, 2, 8)))
#> [1] FALSE
isTRUE(all.equal(unclass(p), c(1.5, 2, 8)))
#> [1] TRUE
all.equal(price(0.1 + 0.2), price(0.3))
#> [1] TRUE
price(0.1 + 0.2) == price(0.3)
#> [1] FALSE
identical(price(0.1 + 0.2), price(0.3))
#> [1] FALSE
# 5. No as.data.frame.price <- as.data.frame.vector is needed any more.
d <- data.frame(id = 1:3, fee = p)
class(d$fee)
#> [1] "price"
nrow(d[d$fee > 1.75, ])
#> [1] 2
Source: R documentation: groupGeneric
A class with a method for + and - only still adds, multiplies and compares: the operators without a method run the internal code, which keeps the class for arithmetic and answers a plain logical for a comparison. The consequence is that an incomplete class does not fail, it silently does the wrong thing, and the cure is the group method. Subsetting a class without [.price returns a bare number for p[1:2] and for rev(p). all.equal() on a classed vector and a plain one reports the difference in attributes and class (five lines), and with isTRUE() it is FALSE; 0.1 + 0.2 == 0.3 is FALSE for classed doubles, as for any doubles, while all.equal() with its tolerance answers TRUE. Finally data.frame(id = 1:3, fee = p) keeps the class price without an as.data.frame.price method, and the comparison d$fee > 1.75 selects rows.
Where to go next
-
S3 Classes and Generics for
UseMethod(),NextMethod(), constructors and validators, and how methods are found. -
S4 Classes and Methods for the S4 group generics
Arith,Compare,Logic,Math,Math2,SummaryandComplex, which dispatch on declared signatures. -
The S7 Object System for the newer object system, which registers a
chooseOpsMethod()method so that its operators cooperate with S3 ones. -
OOP Overview and Base Types for how the five object systems relate.
-
Operators and Precedence for the operators themselves and for user-defined
%op%operators. -
Subsetting and Indexing for
[,[[and$and their replacement forms. -
Factors and Dates and Times for the classes whose group methods were shown above.
-
Numbers and Arithmetic for the floating-point behaviour that makes money on doubles a teaching device.
-
Conditions and Error Handling for
tryCatch()andwithCallingHandlers(), used throughout this page to capture warnings and errors. -
Packages, Libraries and Namespaces for
S3method()registration in a package.
References
-
The R Language Definition: Group methods (5.6); Method dispatching (5.3), NextMethod (5.5)
-
Writing R Extensions: Registering S3 methods
-
R NEWS: Changes in R 4.0.0, 4.1.0, 4.3.0 and 4.4.0