S3 Classes and Generics

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), with one exception: the example that shows what a NAMESPACE file looks like runs the real roxygen2 8.1.1 on a throwaway package. The lintr 3.4.0 default linters were used only to check the scripts. S3 is the object system that most of R itself is written in: print(), summary(), format(), plot(), mean(), [ and hundreds of other functions are generics, and the model objects, data frames, factors and dates you use every day are S3 classes. It is also the simplest object system, a naming convention plus one attribute, and that simplicity is what this page is about: what an S3 class is, how a call to a generic finds its method, how methods hand work to each other with NextMethod(), how to write a small class properly (the running book of the shelf scenario, and a shelf collection of books), how to look at and register methods, and the traps that catch people.

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 (a dispatch variable such as .Class that the linter cannot see, or a name that came from source()), so the linter does not hide the lesson. The names books, loans, shelf_today, loan_days and the book() constructor 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 other object systems have their own pages: OOP Overview and Base Types compares them, S3 Group Generics and Operator Overloading continues this page with Ops, Math and Summary, and S4 Classes and Methods, Reference Classes and R6 and The S7 Object System model the same book with formal classes.

What S3 is, and what it is not

The R Language Definition is plain about the design: rather than a full-fledged object-oriented system, R has a class system and a mechanism for dispatching on the class of an object. The class system rests on the class attribute, a character vector of class names, and the dispatch mechanism is a pair of functions, UseMethod() and NextMethod() (R-lang: Definition). A generic function is an ordinary function that calls UseMethod(); a method is an ordinary function whose name is the generic’s name, a dot and a class name. There is no class definition, no declared list of fields, no registry of classes, no checks: anything can be given a class, and any function named generic.class is a method for it. The manual calls out the consequence directly: users can read and change the attribute without restriction, and nothing checks that an object actually contains the components its class methods expect (R-lang: Classes).

That is the strength and the weakness of S3. It costs nothing to start, which is why R packages have always used it, and a method can be added for a class by anybody, in any package, without touching the code of the class or of the generic. The price is that all the discipline has to come from convention: you write the constructor that makes a valid object, the validator that checks it, and the methods that agree with each other. The help page for UseMethod even recommends that for new projects the more flexible and robust S4 scheme is recommended; in practice S3 remains the default for lightweight classes, and it is the system you must understand to read any existing R code. The five systems and how to choose among them are compared in OOP Overview and Base Types.

Two points of terminology help throughout. First, the generic.class naming rule means that a dot in a function name is never just punctuation: print.book is the print method for class book, while read.csv is an ordinary function that merely contains a dot. An Introduction to R warns about exactly this: a function named gen.cl is invoked by the generic gen for class cl, so such names should be reserved for methods (R-intro: Classes, generic functions and object orientation). Second, dispatch looks only at the class of one argument, normally the first; that is why S3 is called single dispatch, and why a function such as + that depends on both operands needs the special rules of group generics.

The class attribute

Reading and setting the class

A class is stored in the attribute named class. class(x) reads it (and, for an object without the attribute, computes an implicit class, see below); oldClass(x) reads only the attribute and returns NULL when there is none; class(x) <- value and oldClass(x) <- value set it; unclass(x) returns a copy without it; inherits(x, what) tells whether any of the class names in what is in the class vector; is.object(x) is TRUE exactly when the object has a class attribute. structure(.Data, ...) builds an object and sets attributes in one expression, which is the usual way to write a constructor, and attr(x, "class") is the long way to read the same attribute. The help page is ?class (R documentation: class); the manual section on attributes explains that an attribute is a named piece of metadata attached to an object and that class is one of the special ones (Attributes and Names covers attributes in general).

# A class is just an attribute: a character vector of class names.
x <- c(title = 1L)
attributes(x)
#> $names
#> [1] "title"
#>
class(x)
#> [1] "integer"
oldClass(x)
#> NULL
is.object(x)
#> [1] FALSE

class(x) <- "tag"
attributes(x)
#> $names
#> [1] "title"
#>
#> $class
#> [1] "tag"
#>
class(x)
#> [1] "tag"
oldClass(x)
#> [1] "tag"
is.object(x)
#> [1] TRUE
inherits(x, "tag")
#> [1] TRUE

# structure() sets attributes at creation; attr() reads and writes one.
y <- structure(list(title = "Dune"), class = c("sf", "novel"))
attr(y, "class")
#> [1] "sf"    "novel"
class(y)
#> [1] "sf"    "novel"
inherits(y, "novel")
#> [1] TRUE
inherits(y, c("poem", "novel"), which = TRUE)
#> [1] 0 2

# unclass() returns a copy without the class attribute.
unclass(y)
#> $title
#> [1] "Dune"
#>
class(unclass(y))
#> [1] "list"

# Assigning NULL removes the class again.
class(y) <- NULL
class(y)
#> [1] "list"
is.object(y)
#> [1] FALSE

# NULL cannot carry attributes, so it cannot get a class.
tryCatch(
  {
    z <- NULL
    class(z) <- "tag"
  },
  error = \(e) conditionMessage(e)
)
#> [1] "attempt to set an attribute on NULL"

A few details of the output are worth a look. Giving the integer vector a class changed nothing in its data, only added a second attribute, and oldClass(x) went from NULL to "tag". inherits(y, c("poem", "novel"), which = TRUE) returns the position of each name in the class vector, with 0 for a name that is not there; the plain form returns a single TRUE or FALSE and is the form to use in conditions. unclass() returns a copy: the original y still has its class afterwards, as every R function works on copies (see Memory and Copy Semantics). And NULL cannot carry attributes at all, so class(z) <- "tag" on NULL fails with attempt to set an attribute on NULL: an S3 class cannot be put on NULL, which matters for classes meant to represent "nothing".

The class vector may have several elements, and the order is meaningful: it runs from the most specific class to the most general, and it is the order in which methods are looked up. c("audiobook", "book") means "an audiobook, which is a kind of book", and an audiobook object can be used wherever a book is expected because print.book will be found when there is no print.audiobook. There is no declaration of this relationship anywhere: it exists only because the class vector of each object says so (the section on inheritance below builds one).

Implicit classes

An object without a class attribute still has a class for dispatch purposes, the implicit class. class() reports a short form of it and .class2() (new in R 4.0.0) reports the full vector that UseMethod() really searches (R NEWS: Changes in R 4.0.0). For a matrix that vector is c("matrix", "array", "integer", "numeric") or the equivalent for the underlying type: the dimensions come first, then the type, then the broader category. A double vector is c("double", "numeric"), an integer vector c("integer", "numeric"), a function is "function", and a symbol, a call or NULL have the class of their type. The help page for ?class states it in these words: if the object does not have a class attribute, it has an implicit class, notably "matrix", "array", "function" or "numeric", or the result of typeof(x).

# Without a class attribute an object still has an implicit class.
describe_class <- function(x) {
  c(
    typeof = typeof(x),
    class = paste(class(x), collapse = " "),
    dispatch = paste(.class2(x), collapse = " ")
  )
}

objects <- list(
  double = 1.5,
  integer = 1L,
  character = "a",
  matrix = matrix(1:6, nrow = 2),
  array = array(1:24, dim = c(2, 3, 4)),
  list = list(1),
  builtin = sum,
  closure = \(x) x,
  name = quote(x),
  call = quote(f(x)),
  null = NULL
)
as.data.frame(t(vapply(objects, describe_class, character(3L))))
#>              typeof        class                     dispatch
#> double       double      numeric               double numeric
#> integer     integer      integer              integer numeric
#> character character    character                    character
#> matrix      integer matrix array matrix array integer numeric
#> array       integer        array        array integer numeric
#> list           list         list                         list
#> builtin     builtin     function                     function
#> closure     closure     function                     function
#> name         symbol         name                         name
#> call       language         call                         call
#> null           NULL         NULL                         NULL

# None of them has a class attribute, so oldClass() is NULL for all of them.
vapply(objects, \(x) is.null(oldClass(x)), logical(1L))
#>    double   integer character    matrix     array      list   builtin   closure
#>      TRUE      TRUE      TRUE      TRUE      TRUE      TRUE      TRUE      TRUE
#>      name      call      null
#>      TRUE      TRUE      TRUE

# Since R 4.0.0 a matrix also inherits from "array".
inherits(matrix(1:4, 2), "array")
#> [1] TRUE
class(matrix(1:4, 2))
#> [1] "matrix" "array"
length(class(matrix(1:4, 2)))
#> [1] 2

Since R 4.0.0 a matrix has class c("matrix", "array"), where it used to have the single class "matrix", and S3 methods for class "array" are dispatched for matrices (the 4.0.0 NEWS points out that this breaks code that wrongly assumes the class of a matrix has length one). The consequence for if (class(x) == "matrix") is covered in the section on testing classes below. .class2() is meant for teaching and debugging, as the help page points out: to find out what a call will dispatch on, ask .class2(); do not build program logic on it.

The class attribute is only a convention

Nothing about the attribute checks the object it is attached to. structure(list(), class = "book") is a perfectly valid R object that claims to be a book and has none of a book’s fields; every method written for book objects is then free to fail in its own way. The manual’s advice is the origin of the pattern in the next section: when they are available, specific creation and coercion functions should be preferred to assigning a class by hand. The rest of this page uses three small functions per class: a low-level constructor that builds the object and checks types, a validator that checks values, and a user-facing helper that coerces and calls both. That vocabulary (constructor, validator, helper) is community practice, popularised by Hadley Wickham’s Advanced R and the R Packages book rather than defined by any R manual, but it matches how base R’s own classes work: factor() is a helper over structure() with levels and class checks.

Constructor, validator and helper: the book class

The running scenario’s scenario.R defines a book class this way. The first function is the low-level constructor, new_book(). It is cheap and strict about types, it does not try to be helpful, and it builds the object with structure():

new_book <- function(title = character(), author = character(),
                     isbn = character(), year = integer(),
                     genre = character(), pages = integer()) {
  stopifnot(
    is.character(title), is.character(author), is.character(isbn),
    is.integer(year), is.character(genre), is.integer(pages)
  )
  structure(
    list(
      title = title, author = author, isbn = isbn,
      year = year, genre = genre, pages = pages
    ),
    class = "book"
  )
}

Three details deserve explanation. The defaults (character(), integer(), …​) make new_book() with no arguments produce a well-typed, zero-length record (every field has length zero, so it does not pass the validator below), which is handy for tests and for the empty-shelf case below, where it gives an empty data frame with the right column types. stopifnot() checks the types: a wrong type stops with R’s own message, is.integer(year) is not TRUE, which is informative enough for a function that only code (not users) calls. And structure() is simply a convenient wrapper for setting attributes: structure(list(...), class = "book") is the same as creating the list and then assigning class(x) <- "book", but it reads as one expression and cannot be forgotten halfway. The object is a plain list of length-one fields, so every list tool ($, [[, names(), str(), unclass()) works on it, which is both convenient and the source of some of the traps later on.

The second function is the validator. It checks the values, not the types, and it stops with messages written for the user. It returns its argument, so it can be chained as validate_book(new_book(...)):

validate_book <- function(x) {
  fields <- unclass(x)
  if (any(lengths(fields) != 1L)) {
    stop("every field of a book must have length 1", call. = FALSE)
  }
  if (is.na(fields$title) || !nzchar(fields$title)) {
    stop("a book needs a title", call. = FALSE)
  }
  if (!is.na(fields$isbn) && !is_valid_isbn13(fields$isbn)) {
    stop("invalid ISBN-13: ", fields$isbn, call. = FALSE)
  }
  if (!is.na(fields$year) && fields$year > 2100L) {
    stop("implausible year: ", fields$year, call. = FALSE)
  }
  x
}

The validator uses unclass(x) to read the fields with the list functions, lengths() to check that every field has exactly one element, and stop(..., call. = FALSE) so that the message does not carry the confusing name of an internal function. The ISBN rule calls is_valid_isbn13(), defined at the top of scenario.R (it removes everything except digits and X, then checks the ISBN-13 check digit with weights 1 and 3), and the year rule rejects anything after 2100. The third function is the helper that users call, book(). It trims text, normalises the ISBN, coerces numbers to integer, and runs the validator:

book <- function(title, author = NA_character_, isbn = NA_character_,
                 year = NA_integer_, genre = NA_character_,
                 pages = NA_integer_) {
  validate_book(new_book(
    title = trimws(title),
    author = trimws(author),
    isbn = if (is.na(isbn)) isbn else normalise_isbn(isbn),
    year = as.integer(year),
    genre = trimws(genre),
    pages = as.integer(pages)
  ))
}

The pattern separates three responsibilities that are often mixed together. The constructor answers "what is the internal structure?", so other code (a subclass, a deserialiser, a test) can build an object cheaply; the validator answers "is this a legal object?", and can be called again whenever an object may have been tampered with; the helper answers "how do users want to create it?", with friendly defaults and coercions. Because all three are plain functions, a subclass can reuse them, as the audiobook below does. Now the class in use:

source("scenario.R")

# The user-facing helper trims the text and normalises the ISBN.
b <- book(
  title = "  the quiet streets ",
  author = "Iris  Fenwick",
  isbn = "978-0-306-40615-7",
  year = 2020,
  genre = "Fiction ",
  pages = 320
)
b
#> <book> the quiet streets (Iris  Fenwick, 2020)
class(b)
#> [1] "book"
names(b)
#> [1] "title"  "author" "isbn"   "year"   "genre"  "pages"
b$isbn
#> [1] "9780306406157"
b[["year"]]
#> [1] 2020

# format(), print() and summary() are methods defined in scenario.R.
format(b)
#> [1] "the quiet streets (Iris  Fenwick, 2020)"
print(b)
#> <book> the quiet streets (Iris  Fenwick, 2020)
x <- print(b)
#> <book> the quiet streets (Iris  Fenwick, 2020)
identical(x, b)
#> [1] TRUE
summary(b)
#> Book: the quiet streets
#> ISBN known: TRUE
#> Pages: 320
s <- summary(b)
class(s)
#> [1] "summary.book"
unclass(s)
#> $title
#> [1] "the quiet streets"
#>
#> $has_isbn
#> [1] TRUE
#>
#> $pages
#> [1] 320
#>

# unclass() prints the raw list; str() shows the class attribute.
unclass(b)
#> $title
#> [1] "the quiet streets"
#>
#> $author
#> [1] "Iris  Fenwick"
#>
#> $isbn
#> [1] "9780306406157"
#>
#> $year
#> [1] 2020
#>
#> $genre
#> [1] "Fiction"
#>
#> $pages
#> [1] 320
#>
str(b)
#> List of 6
#>  $ title : chr "the quiet streets"
#>  $ author: chr "Iris  Fenwick"
#>  $ isbn  : chr "9780306406157"
#>  $ year  : int 2020
#>  $ genre : chr "Fiction"
#>  $ pages : int 320
#>  - attr(*, "class")= chr "book"

class(b) is "book", names(b) is the six field names, and b$isbn is the normalised ISBN without hyphens. Printing b called print.book(), a method defined in scenario.R (the code is listed in the section on methods below); format(b) returned the one-line description that print.book() uses; and summary(b) returned an object of class summary.book that was printed by its own method. unclass(b) shows the raw list without calling any method, and str(b) shows the structure together with the class attribute, which is the quickest way to see what an object really is. Note that the double space in the author "Iris Fenwick" survives: book() trims the ends of the text, it does not normalise inner whitespace.

The errors seen by the user come from two places, and the script below captures them with tryCatch():

source("scenario.R")

msg <- function(expr) {
  tryCatch(expr, error = \(e) conditionMessage(e))
}

# The low-level constructor checks types only, with stopifnot().
msg(new_book("A", "B", "9780306406157", 2020, "Fiction", 320))
#> [1] "is.integer(year) is not TRUE"
msg(new_book(1, "B", "9780306406157", 2020L, "Fiction", 320L))
#> [1] "is.character(title) is not TRUE"

# The validator checks values and stops with its own messages.
msg(book(""))
#> [1] "a book needs a title"
msg(book("A", isbn = "123"))
#> [1] "invalid ISBN-13: 123"
msg(book("A", year = 2200))
#> [1] "implausible year: 2200"
msg(book(c("A", "B")))
#> [1] "every field of a book must have length 1"

# The constructor is cheap and trusts its caller: it does not validate.
odd <- new_book("A", "B", "123", 2020L, "Fiction", 320L)
class(odd)
#> [1] "book"
msg(validate_book(odd))
#> [1] "invalid ISBN-13: 123"

new_book() fails with is.integer(year) is not TRUE when a double is passed for year (the helper book() would have coerced it with as.integer()), and with is.character(title) is not TRUE when the title is a number. The validator’s own messages are the ones written in validate_book(): a book needs a title, invalid ISBN-13: 123, implausible year: 2200, and every field of a book must have length 1. The last two lines of the script show why the split matters: new_book() happily built an object with an invalid ISBN because it checks types only, and the validator caught it afterwards. The constructor trusts its caller; the helper does not. For argument checks beyond stopifnot(), see Defensive Programming and Input Validation; for the error classes behind stop() see Conditions and Error Handling.

Generics and UseMethod dispatch

What a generic function is

A generic function is an ordinary function whose job is to pick a method. The Language Definition says that a generic should consist of a single statement, usually of the form foo <- function(x, ...) UseMethod("foo", x) (R-lang: Method dispatching). The generics you already use are exactly that: the bodies of print, summary, format and mean are each one call to UseMethod(). When the generic is called, UseMethod() looks at the class of the first argument, finds the best method, and calls it with the same arguments, in the same order, as the call to the generic, as if the call had been made directly to the method. Control never returns to the generic: whatever the method returns is the value of the call.

source("scenario.R")

# The generics that ship with R are one-line functions around UseMethod().
body(print)
#> UseMethod("print")
body(summary)
#> UseMethod("summary")
body(format)
#> UseMethod("format")
body(mean)
#> UseMethod("mean")
formals(print) |> names()
#> [1] "x"   "..."

# isS3stdGeneric() recognises a function whose body is a plain UseMethod() call.
utils::isS3stdGeneric(print)
#> print
#>  TRUE
utils::isS3stdGeneric(sum)
#> [1] FALSE

# A generic of our own: a single call to UseMethod().
describe <- function(x, ...) {
  UseMethod("describe")
}

# Methods are ordinary functions called <generic>.<class>.
describe.book <- function(x, ...) {
  sprintf("book: %s", x$title)
}
describe.default <- function(x, ...) {
  sprintf("an object of class %s", class(x)[[1L]])
}

b <- book("Dune", "Frank Herbert", year = 2000)
describe(b)
#> [1] "book: Dune"
describe(1:3)
#> [1] "an object of class integer"
describe(NULL)
#> [1] "an object of class NULL"

# Only the first argument takes part in the dispatch.
describe(1, b)
#> [1] "an object of class numeric"
describe(b, 1)
#> [1] "book: Dune"

# A method is a function like any other and can be called directly.
describe.book(b)
#> [1] "book: Dune"
exists("describe.book")
#> [1] TRUE

describe() is the whole recipe: a generic is a function that calls UseMethod("describe"); its methods are the functions describe.book and describe.default; the call describe(b) found describe.book because class(b) is "book", and the other calls fell to the default method. Three more details are visible in the output. Only the first argument takes part in the dispatch (describe(1, b) went to the default method even though b is a book), and describe(b, 1) found the book method again. A method is an ordinary function that may also be called directly, as describe.book(b) does, though that bypasses dispatch and is therefore only for testing or for calling a specific method from another method. And utils::isS3stdGeneric() recognises a standard generic, a function whose body is just a UseMethod() call: it said TRUE for print and FALSE for the primitive sum.

The .default method is the last resort, and the manual advises always writing one, because without it a call for an unexpected class is an error. The next script shows the wording of that error for a generic with only a book method:

source("scenario.R")

msg <- function(expr) {
  tryCatch(expr, error = \(e) conditionMessage(e))
}

# A generic without a default method fails for classes it does not know.
summarise <- function(x, ...) {
  UseMethod("summarise")
}
summarise.book <- function(x, ...) {
  sprintf("%s has %s pages", x$title, x$pages)
}

b <- book("Dune", pages = 412)
summarise(b)
#> [1] "Dune has 412 pages"
msg(summarise(1.5))
#> [1] "no applicable method for 'summarise' applied to an object of class \"c('double', 'numeric')\""
msg(summarise(1L))
#> [1] "no applicable method for 'summarise' applied to an object of class \"c('integer', 'numeric')\""
msg(summarise("a"))
#> [1] "no applicable method for 'summarise' applied to an object of class \"character\""
msg(summarise(NULL))
#> [1] "no applicable method for 'summarise' applied to an object of class \"NULL\""
msg(summarise(sum))
#> [1] "no applicable method for 'summarise' applied to an object of class \"function\""
msg(summarise(matrix(1:4, 2)))
#> [1] "no applicable method for 'summarise' applied to an object of class \"c('matrix', 'array', 'integer', 'numeric')\""
msg(summarise(list(1)))
#> [1] "no applicable method for 'summarise' applied to an object of class \"list\""
msg(summarise(data.frame(a = 1)))
#> [1] "no applicable method for 'summarise' applied to an object of class \"data.frame\""
msg(summarise(factor("a")))
#> [1] "no applicable method for 'summarise' applied to an object of class \"factor\""
msg(summarise(Sys.Date()))
#> [1] "no applicable method for 'summarise' applied to an object of class \"Date\""

The message is no applicable method for 'summarise' applied to an object of class "book" with the generic’s name and the class of the object. The class that is reported is the implicit class when there is no class attribute, and it shows the full vector that was searched: c('double', 'numeric') for a double, c('integer', 'numeric') for an integer vector, c('matrix', 'array', 'integer', 'numeric') for an integer matrix. This is the same vector that .class2() returns. If you see a message like this about an object of class NULL or function, the usual cause is not a missing method but a wrong object, for example a NULL returned by a lookup that failed.

The order in which methods are looked up

When the generic gen is called with an object whose class vector is c("first", "second"), R searches for gen.first, then gen.second, and finally gen.default; the first function found is called. The Language Definition describes it this way: the name of the generic is combined with each element of the class attribute in turn, and if none gives a function, generic.default is used (R-lang: Method dispatching). If the object has no class attribute, the implicit class (.class2(x)) is used in the same way, and then the default. The help page ?UseMethod adds the precise places where a function called gen.first is looked for:

  • first in the environment in which the generic function is called (the calling environment, and its enclosing environments up to the top level environment, see topenv());

  • then in the registration data base of the environment in which the generic is defined, normally a namespace. Packages add methods to this table with S3method() directives in the NAMESPACE file (see below) or with registerS3method();

  • and, after that, in the parents of the top level environment of the caller (for a script, the attached packages on the search path).

The relative order of the second and third place matters little in practice: the registration table is consulted after the top level environment of the caller but before its parents. That ordering was introduced in R 3.5.0 (R NEWS: Changes in R 3.5.0 announces that S3 method lookup searches the namespace registry after the top level environment of the calling environment). The consequence is the rule used throughout this page: methods for a generic need to be available in the environment of the call to the generic, or they must be registered. It does not matter whether they are visible in the environment where the generic is defined.

flowchart TD call["print(x)"] --> um["UseMethod('print')"] um --> has{"does x have a class attribute?"} has -- "yes" --> explicit["class vector = class(x), for example 'audiobook', 'book'"] has -- "no" --> implicit["class vector = implicit class, .class2(x), for example 'matrix', 'array', 'integer', 'numeric'"] explicit --> pick["take the next class name from the vector"] implicit --> pick pick --> left{"any class names left?"} left -- "yes" --> look{"is there a function print.CLASS in the calling environment or in the S3 registration table?"} look -- "no, try the next class" --> pick look -- "yes" --> run["call print.CLASS(x, ...) with the arguments of the call to the generic"] run --> nm{"does the method call NextMethod()?"} nm -- "yes, with .Class shifted along" --> pick nm -- "no" --> done["the value of the method is the value of print(x)"] left -- "no" --> def{"does print.default exist?"} def -- "yes" --> rundef["call print.default(x, ...)"] rundef --> done def -- "no" --> err["error: no applicable method for 'print' applied to an object of class ..."]

The diagram follows one call to the generic print(). The first decision is where the class vector comes from: the class attribute if the object has one, the implicit class otherwise. Each name in the vector in turn is combined with the generic’s name and looked up as a function. The first hit is called with the arguments of the call; if that method calls NextMethod(), the search resumes from the next class in the vector. When the vector is used up, the default method is the last resort, and if there is none, the error is signalled. Two details are not in the picture: the dispatch object is the first argument as it was passed (see the rules below), and the internal generics (length, [, c, as.character and others) first check that the object has a class attribute at all, because for efficiency they dispatch only on objects.

Dispatch on implicit classes

UseMethod() dispatches on the implicit class even for objects that have no class attribute, and that can surprise you in both directions. The script below defines a generic kind() with methods for the implicit classes integer, numeric, matrix, array and function:

# UseMethod() dispatches on the implicit class when there is no class attribute.
kind <- function(x) {
  UseMethod("kind")
}
kind.integer <- function(x) "integer method"
kind.numeric <- function(x) "numeric method"
kind.matrix <- function(x) "matrix method"
kind.array <- function(x) "array method"
kind.function <- function(x) "function method"
kind.default <- function(x) "default method"

kind(1L)
#> [1] "integer method"
kind(1.5)
#> [1] "numeric method"
kind(matrix(1.5, 2, 2))
#> [1] "matrix method"
kind(array(1.5, c(1, 1, 1)))
#> [1] "array method"
kind(sum)
#> [1] "function method"
kind(\(x) x)
#> [1] "function method"
kind("a")
#> [1] "default method"

# The method search follows .class2(), not class().
.class2(matrix(1L, 2, 2))
#> [1] "matrix"  "array"   "integer" "numeric"
rm(kind.matrix, kind.array)
kind(matrix(1L, 2, 2))
#> [1] "integer method"
rm(kind.integer)
kind(matrix(1L, 2, 2))
#> [1] "numeric method"

# This is why a numeric method can capture an integer vector.
rev.numeric <- function(x) "rev.numeric was called"
rev(1:3)
#> [1] "rev.numeric was called"
rev(c(1.5, 2.5))
#> [1] "rev.numeric was called"
rm(rev.numeric)
rev(1:3)
#> [1] 3 2 1

Each call dispatched on the class vector that .class2() reports. An integer is handled by kind.integer because "integer" comes before "numeric"; a double has no integer class and falls to kind.numeric; a matrix of doubles uses kind.matrix (the first element of its vector), then if that method is removed kind.array, then the type (kind.integer for an integer matrix), and then kind.numeric. This is the mechanism that lets print.default and format.default deal with every atomic type, and it is also the reason why a stray function that happens to be named like a method gets called: the last part of the script defines a function called rev.numeric and rev(1:3), an integer vector, runs it, because "numeric" is part of the implicit class of an integer vector. The help page for UseMethod states the inconsistency: UseMethod dispatches on the implicit class even for arguments that are not objects, but the other means of dispatch do not.

The other means of dispatch are the internal generics: primitives and internal functions such as length, [, [[, $, c, dim, names, as.character, as.numeric, rep, is.na and unlist (the complete list is on the help page ?InternalMethods, R documentation: InternalMethods), and the group generics Ops, Math, Summary and Complex (R documentation: groupGeneric). They are implemented in C, they dispatch on the first argument (the group Ops on both), and they do so only when is.object(x) is TRUE, that is, when the argument has a class attribute.

# Internal generics dispatch only on objects: is.object() must be TRUE.
length.numeric <- function(x) 99L
length(1:3)
#> [1] 3
length(c(1.5, 2.5))
#> [1] 2
is.object(1:3)
#> [1] FALSE

# The same method is found once the object has a class attribute.
length.tag <- function(x) 99L
tagged <- structure(1:3, class = "tag")
is.object(tagged)
#> [1] TRUE
length(tagged)
#> [1] 99
unclass(tagged)
#> [1] 1 2 3

# Compare: a closure-based generic such as rev() looks at the implicit class.
as.character.numeric <- function(x, ...) "never used"
as.character(1.5)
#> [1] "1.5"
rev.numeric <- function(x) "rev.numeric was called"
rev(1.5)
#> [1] "rev.numeric was called"

length.numeric has no effect on length(1:3) or on length(c(1.5, 2.5)), because neither vector is an object, whereas length.tag is called as soon as the vector has the class tag. The same applies to as.character.numeric, which as.character(1.5) ignores, while the closure-based rev() finds rev.numeric. The practical rule is that you can add methods for your own classes to any of the internal generics (that is how the [.shelf and c.shelf methods below work), but you cannot change how R handles a plain numeric vector by defining a method for an implicit class. Group generics, and the double dispatch of Ops, are the subject of S3 Group Generics and Operator Overloading.

What UseMethod does

UseMethod(generic, object) is a special function. Its first argument is the name of the generic (the string; if it is omitted, the name of the enclosing function is assumed), and its second is the object to dispatch on, which defaults to the first argument of the enclosing function. The manual lists the consequences (R-lang: UseMethod), and each one can be checked in a few lines.

# 1. UseMethod() never returns: statements after it do not run.
after <- function(x) {
  UseMethod("after")
  cat("never printed\n")
}
after.default <- function(x) "from the method"
after(1)
#> [1] "from the method"

# 2. The dispatch object is the first argument as it was passed, not the
#    current value of the variable: changing x before UseMethod() is ignored.
changed <- function(x) {
  x <- "now a character"
  UseMethod("changed")
}
changed.numeric <- function(x) paste("numeric method received", x)
changed.character <- function(x) paste("character method received", x)
changed(42)
#> [1] "numeric method received 42"

# 3. Since R 4.4.0 local variables of the generic are not forwarded.
local_var <- function(x) {
  helper <- "set in the generic" # nolint: object_usage_linter.
  UseMethod("local_var")
}
local_var.default <- function(x) exists("helper", inherits = FALSE)
local_var(1)
#> [1] FALSE

# 4. Only the first argument is evaluated; the others stay promises.
lazy <- function(x, y) {
  UseMethod("lazy")
}
lazy.default <- function(x, y) {
  cat("method started\n")
  tryCatch(y, error = \(e) conditionMessage(e))
}
lazy(1, stop("y was evaluated"))
#> method started
#> [1] "y was evaluated"

# 5. UseMethod(generic, object) dispatches on another object.
second <- function(x, y) {
  UseMethod("second", y)
}
second.character <- function(x, y) "dispatched on y, a character"
second.numeric <- function(x, y) "dispatched on y, a number"
second(1, "a")
#> [1] "dispatched on y, a character"
second("a", 1)
#> [1] "dispatched on y, a number"

# 6. The method is called with the arguments of the call to the generic.
show_call <- function(x, ...) {
  UseMethod("show_call")
}
show_call.default <- function(x, ...) {
  print(sys.call())
  print(match.call())
  nargs()
}
show_call(10, extra = TRUE)
#> show_call.default(10, extra = TRUE)
#> show_call.default(x = 10, extra = TRUE)
#> [1] 2
  1. UseMethod() does not return. Statements after it in the generic are never executed (after() never printed its message), and the method’s value is the value of the generic call. It can only be called from the body of a function.

  2. The dispatch object is the argument as it was passed. The help page says that, unlike S, the class used is that of the first actual argument passed, not the current value of the variable of that name. changed() assigned a character to x before calling UseMethod(), but the call was changed(42), so the numeric method was selected and it received the original 42. The arguments of the method are those of the call to the generic, matched to the method’s own formals by the usual rules.

  3. Local variables of the generic are not forwarded to the method. Before R 4.4.0 any local assignments in the frame of the generic were carried forward into the call to the method; the 4.4.0 NEWS (R NEWS: Changes in R 4.4.0) announces that UseMethod() no longer does this, so that method calls behave more like standard function calls and method code is easier to analyze. On R 4.6.1 the method of local_var() does not see helper. Old code that sets a variable in a generic and reads it in a method breaks; pass it as an argument instead.

  4. Only the dispatch object is evaluated. The other arguments stay promises (see Lazy Evaluation and Promises): in lazy() the method started before y was needed, and the error in y appeared only when the method evaluated it. The Language Definition adds that any arguments that were evaluated prior to the call to UseMethod() remain evaluated.

  5. UseMethod(generic, object) can dispatch on another object. second() dispatched on y rather than on x. In this case the first argument of the generic is not evaluated for the purpose of dispatch. There is no way to change the other arguments in the call to the method; they remain as they were in the call to the generic, in contrast with NextMethod(), where the arguments can be altered.

  6. The call seen by the method is the call to the method. sys.call() in the method shows show_call.default(10, extra = TRUE): the function name has been replaced by the method’s name and the arguments are those of the original call. match.call() gives the matched version with x = 10.

The help page documents one more rule that is easy to miss: UseMethod can be called with more than two arguments, a warning will be given and the additional arguments ignored. The observed behaviour on R 4.6.1 is stricter than the help page: UseMethod("g", x, 3) stops with unused argument (3), since UseMethod is a primitive that uses standard argument matching. Do not rely on either behaviour; give it one or two arguments.

The dispatch variables

The dispatch mechanism communicates with the method through special variables that it places in the evaluation frame of the method. .Generic is the name of the generic as a length-one character vector. .Method is the name of the method function (it has two elements for the Ops group generics, see the group generics page). .Class is the part of the class vector that remains to be tried by NextMethod(); the help page explains that NextMethod() adds an attribute named previous to .Class holding the value last used for dispatch, and shifts .Class along. .GenericCallEnv and .GenericDefEnv are the environment of the call to the generic and the one defining it (the latter is used to find registered methods), and for group generics there is also .Group. The values are the same for a method called through UseMethod() and one reached through NextMethod(), and you can see them evolve in a chain:

# UseMethod() puts .Generic, .Class and .Method into the frame of the method.
# nolint start: object_usage_linter.
trace_it <- function(x, ...) {
  UseMethod("trace_it")
}
trace_it.audio <- function(x, ...) {
  cat("in trace_it.audio\n")
  cat(".Generic:", .Generic, "\n")
  cat(".Method: ", .Method, "\n")
  cat(".Class:  ", .Class, "\n")
  NextMethod()
}
trace_it.book <- function(x, ...) {
  cat("in trace_it.book\n")
  cat(".Method: ", .Method, "\n")
  cat(".Class:  ", .Class, "\n")
  print(attributes(.Class))
  NextMethod()
}
trace_it.default <- function(x, ...) {
  cat("in trace_it.default\n")
  cat(".Method: ", .Method, "\n")
  print(.Class)
  invisible(x)
}
# nolint end

x <- structure(list(), class = c("audio", "book"))
trace_it(x)
#> in trace_it.audio
#> .Generic: trace_it
#> .Method:  trace_it.audio
#> .Class:   audio book
#> in trace_it.book
#> .Method:  trace_it.book
#> .Class:   book
#> $previous
#> [1] "audio" "book"
#>
#> in trace_it.default
#> .Method:  trace_it.default
#> character(0)
#> attr(,"previous")
#> [1] "book"
#> attr(,"previous")attr(,"previous")
#> [1] "audio" "book"

# Called directly, the method has no dispatch variables of its own.
tryCatch(trace_it.default(x), error = \(e) conditionMessage(e))
#> in trace_it.default
#> [1] "object '.Method' not found"

In the first method .Class is the whole class vector, "audio" "book"; in the second it has shifted along to "book" and carries the attribute previous; in the default method it is empty (character(0)), with the history of the earlier values nested in the attribute. A method called directly, as trace_it.default(x) was at the end of the script, has no dispatch variables: the error is object '.Method' not found. That is why the manual defines a method as a function that has been invoked via method dispatch, and why dispatch variables should be used to understand a dispatch, not to write logic. The nolint start/nolint end comments in the script exist because the static analysis done by lintr cannot know that .Generic, .Class and .Method exist at run time. Ordinary methods should not need them: NextMethod() is the supported way to continue, and the Language Definition says that the choice of the next method depends on the current values of .Generic and .Class, not on the object, which the next section demonstrates.

NextMethod and inheritance by delegation

UseMethod() picks one method and ends there. NextMethod() is how that method hands part of the work to the next one: it is the S3 form of inheritance, and the Language Definition describes it as a "simple inheritance mechanism". A typical method makes a few changes to its arguments, calls NextMethod(), receives the result and adjusts it a little; "Writing R Extensions" gives t.data.frame as the classic example, which converts the data frame to a matrix and then lets the default method of t() do the work (R-exts: Generic functions and methods).

# NextMethod() calls the next method in the class vector.
fmt <- function(x, ...) {
  UseMethod("fmt")
}
fmt.default <- function(x, ...) {
  extras <- list(...)
  cat("default: x =", x, "| extra arguments:", names(extras), "\n")
  "result of the default method"
}
fmt.second <- function(x, prefix = "second", ...) {
  cat("second: prefix =", prefix, "\n")
  x <- x + 100
  NextMethod(prefix = "changed in second")
}
fmt.first <- function(x, ...) {
  cat("first\n")
  NextMethod()
}

v <- structure(1, class = c("first", "second"))
fmt(v)
#> first
#> second: prefix = second
#> default: x = 101 | extra arguments: prefix
#> [1] "result of the default method"

# The modified x reached the default method (101), and the named argument
# prefix, which the call to fmt() did not contain, was added to the arguments
# of the next method. The choice of the next method does not depend on the
# object, only on .Class:
pick <- function(x, ...) {
  UseMethod("pick")
}
pick.a <- function(x, ...) {
  x <- structure(1, class = "c")
  NextMethod()
}
pick.b <- function(x, ...) "pick.b"
pick.c <- function(x, ...) "pick.c"
pick.default <- function(x, ...) "pick.default"
pick(structure(1, class = c("a", "b")))
#> [1] "pick.b"

# The usual pattern: change a little, call the next method, adjust the result.
fmt_tag <- function(x, ...) {
  UseMethod("fmt_tag")
}
fmt_tag.default <- function(x, ...) toupper(as.character(x))
fmt_tag.loud <- function(x, ...) {
  paste0(NextMethod(), "!")
}
fmt_tag(structure("hello", class = "loud"))
#> [1] "HELLO!"

# NextMethod() also finds the internal default of an internal generic.
"[.shout" <- function(x, i) {
  structure(NextMethod(), class = class(x))
}
w <- structure(c("a", "b", "c"), class = "shout")
class(w[2:3])
#> [1] "shout"
unclass(w[2:3])
#> [1] "b" "c"

The rules, as the Language Definition and the help page give them and as the output shows:

  • The next method is chosen from .Class, not from the object. pick.a() replaced x by an object of class c before calling NextMethod(), but the next method was still pick.b, the next element of the original class vector. Changing the object changes the arguments the next method receives, not which method it is.

  • The arguments are the same as in the current call, but their values are the current ones. The arguments of the next method have the same number, order and names as the call to the current method, but each is a promise to evaluate the name of the corresponding formal argument of the current method. That is why fmt.second() set x <- x + 100 and the default method saw 101: the modified value is what is passed on. Unevaluated arguments remain unevaluated and missing arguments remain missing.

  • Arguments given to NextMethod() modify the call. NextMethod(prefix = "changed in second") added a named argument; a named argument replaces an existing argument of the same name or is appended to the list, and unnamed arguments are appended. In the output the default method received an extra argument called prefix through its dots, because the original call did not contain one.

  • The search differs slightly from that of UseMethod(). Not finding generic.default is not an error for NextMethod(): the search continues to the generic itself, so that an internal generic such as [ with no separate default method can be reached. The last part of the script uses this: [.shout calls NextMethod() and the internal subsetting does the work, and then the method puts the class back, because the internal [ drops the class attribute (see the section on the shelf below).

  • It only works in a method found by dispatch. The help page warns that it should not be called except in methods called by UseMethod() or from internal generics, and in particular not inside anonymous calling functions.

The pattern in fmt_tag.loud() is the one to remember: paste0(NextMethod(), "!") calls the next method (here the default one, which upper-cases the text) and decorates its result. The same shape is used for format.audiobook() below.

NextMethod from outside a dispatch

A method can also be called directly, and then there are no dispatch variables to read. The Language Definition says what is supposed to happen: the first argument of NextMethod() is then used to find the generic, an error is signalled if it has not been supplied, so it is a good idea to always supply it, and the class used is the class attribute of the first argument. The script below shows the actual messages, each captured with tryCatch():

msg <- function(expr) {
  tryCatch(expr, error = \(e) conditionMessage(e))
}

tag <- function(x, ...) {
  UseMethod("tag")
}
tag.default <- function(x, ...) "tag.default"
tag.a <- function(x, ...) NextMethod()
tag.b <- function(x, ...) NextMethod("tag")
tag.c <- function(x, ...) NextMethod("tag")
x <- structure(1, class = c("a", "b", "c"))

# Through the generic everything is fine.
tag(x)
#> [1] "tag.default"

# Called directly, NextMethod() needs the name of the generic ...
msg(tag.a(x))
#> [1] "generic function not specified"
# ... and then the next class is worked out from the name of the current
# function. tag.c is the last class, so the next method is the default one.
tag.c(x)
#> [1] "tag.default"
# A chain that has to hop twice from a direct call is fragile.
msg(tag.b(x))
#> [1] "wrong value for .Method"

# A method stored in a list is called through an anonymous function: the
# name of the current method is unknown, so the dispatch cannot continue.
methods_list <- list(a = tag.a, b = tag.b)
msg(methods_list$a(x))
#> [1] "'NextMethod' called from an anonymous function"
msg(methods_list$b(x))
#> [1] "'NextMethod' called from an anonymous function"

# NextMethod() at top level, and in a function that is not named like a method.
msg(NextMethod())
#> [1] "'NextMethod' called from outside a function"
outside <- function(x) {
  NextMethod("tag")
}
msg(outside(x))
#> [1] "wrong value for .Method"

# A class vector that ends without a default method and without internal one.
nd <- function(x) {
  UseMethod("nd")
}
nd.a <- function(x) NextMethod()
msg(nd(structure(1, class = "a")))
#> [1] "no method to invoke"

generic function not specified is the error for NextMethod() without a name when the method was called directly. With NextMethod("tag") the continuation works if the next step is the default method (tag.c(x)), and it fails with wrong value for .Method when the chain has to hop more than once from a direct call. A method taken out of a list is called through an anonymous function, so 'NextMethod' called from an anonymous function is signalled; at top level the message is 'NextMethod' called from outside a function; and a function that is not named like a method (outside()) gives wrong value for .Method. The last message, no method to invoke, is the one for a class vector that ends without a default method and where the generic is an ordinary closure: there is nothing left to call. The practical rules are to call methods through the generic, to always write NextMethod() inside a function whose name is generic.class, and to supply the generic name only when you know that a method may be called directly.

Methods for the book class

The scenario’s scenario.R already defines the three methods every class should have, in the form recommended by the R FAQ. format.book() returns a character description; print.book() prints it and returns its argument invisibly; and summary.book() returns a new object of class summary.book instead of printing anything, which has its own print method (R FAQ 8.1: How should I write summary methods?):

format.book <- function(x, ...) {
  year <- if (is.na(x$year)) "n.d." else x$year
  sprintf("%s (%s, %s)", x$title, x$author, year)
}

print.book <- function(x, ...) {
  cat("<book>", format(x), "\n")
  invisible(x)
}

summary.book <- function(object, ...) {
  structure(
    list(
      title = object$title,
      has_isbn = !is.na(object$isbn),
      pages = object$pages
    ),
    class = "summary.book"
  )
}

print.summary.book <- function(x, ...) {
  cat("Book:", x$title, "\n")
  cat("ISBN known:", x$has_isbn, "\n")
  cat("Pages:", if (is.na(x$pages)) "unknown" else x$pages, "\n")
  invisible(x)
}

The FAQ’s reasoning is that summary.foo() should not print anything, but return an object of class summary.foo, with a method print.summary.foo() that prints it nicely and invisibly returns its argument. The benefit is that code can grab something computed by summary() inside a function without producing any output, and the same convention is used by summary.lm() and summary.data.frame(). print.book() is also written the recommended way: its arguments are (x, ...) like the generic’s, it builds the text with format() so that one function decides what a book looks like, and it ends with invisible(x). That last line is what makes y <- print(b) return the book and lets print() be used in the middle of a pipeline; it is also what stops the console from printing the book twice, and a print method without it is one of the older-tutorial habits listed at the end of the page. A method is also free to use data that the generic does not: print.book() ignores ... here, but a real one would pass it on to format() or cat().

Three more methods are worth adding to the scenario’s class, because they are what other code expects from an object that wants to behave like a record: as.character() (an internal generic, used by paste() and by several format helpers), as.data.frame() (an ordinary S3 generic, used by rbind() and by the data-frame functions) and [, for which the right answer on a single book is an informative error.

source("scenario.R")

b <- book("Dune", "Frank Herbert", "978-0-306-40615-7", 1965, "Classic", 412)

# Without a method, as.character() and paste() expose the raw list.
as.character(b)
#> [1] "Dune"          "Frank Herbert" "9780306406157" "1965"
#> [5] "Classic"       "412"
paste("Read:", b)
#> [1] "Read: Dune"          "Read: Frank Herbert" "Read: 9780306406157"
#> [4] "Read: 1965"          "Read: Classic"       "Read: 412"

# as.character() is an internal generic: a method for the class is found.
as.character.book <- function(x, ...) {
  format(x, ...)
}
as.character(b)
#> [1] "Dune (Frank Herbert, 1965)"
paste("Read:", b)
#> [1] "Read: Dune (Frank Herbert, 1965)"

# as.data.frame() is an S3 generic: the method keeps the generic's arguments.
args(as.data.frame)
#> function (x, row.names = NULL, optional = FALSE, ...)
#> NULL
as.data.frame.book <- function(x,
                               row.names = NULL, # nolint: object_name_linter.
                               optional = FALSE, ...) {
  as.data.frame.list(
    unclass(x),
    row.names = row.names, optional = optional, ...
  )
}
as.data.frame(b)
#>   title        author          isbn year   genre pages
#> 1  Dune Frank Herbert 9780306406157 1965 Classic   412
str(as.data.frame(b))
#> 'data.frame':	1 obs. of  6 variables:
#>  $ title : chr "Dune"
#>  $ author: chr "Frank Herbert"
#>  $ isbn  : chr "9780306406157"
#>  $ year  : int 1965
#>  $ genre : chr "Classic"
#>  $ pages : int 412

# A single book is one record; subsetting it with [ would break the invariant
# that every field is present, so the method refuses with a useful message.
`[.book` <- function(x, i, ...) {
  stop("a book is one record; use b$title or b[[\"title\"]]", call. = FALSE)
}
tryCatch(b["title"], error = \(e) conditionMessage(e))
#> [1] "a book is one record; use b$title or b[[\"title\"]]"
b[["title"]]
#> [1] "Dune"
b$title
#> [1] "Dune"
format(b)
#> [1] "Dune (Frank Herbert, 1965)"
str(b)
#> List of 6
#>  $ title : chr "Dune"
#>  $ author: chr "Frank Herbert"
#>  $ isbn  : chr "9780306406157"
#>  $ year  : int 1965
#>  $ genre : chr "Classic"
#>  $ pages : int 412
#>  - attr(*, "class")= chr "book"

The first calls show what happens without the method: as.character(b) returns the raw fields converted one by one, and paste("Read:", b) returns six strings, because paste() calls as.character() on an object and gets the list’s six elements. Once as.character.book exists, both calls use format(x), which is how the same description reaches paste(), sprintf("%s", ...) and others. as.data.frame.book() has the arguments of the generic (x, row.names, optional and ..., checked with args(as.data.frame) in the script), calls as.data.frame.list() on the unclassed fields and returns a one-row data frame whose columns keep their types (chr, int). The argument names row.names and optional are not a free choice: a method must have all the arguments of the generic, in the same order, which the section on argument consistency below explains, and R CMD check verifies it for the methods in a package.

[.book deliberately refuses. A book is one record, and a vector subset such as b["title"] would build a list without a class and without the other fields, a "book" that no longer satisfies the validator. Rather than silently returning something broken, the method stops with a message that points to the correct tools: $ and [[ still work because they have no method here and fall through to the default for lists. Note that the method receives only (x, i, ...) and does not call NextMethod(); an informative error is a legitimate method body.

A collection class: shelf

A single book is a record. A shelf is a collection of books, and it shows what a class has to do when its elements are themselves objects: keep the class when subsetting, validate what goes in, combine, print, summarise and convert. The representation is the simplest that works: a list of book objects with a label attribute and the class shelf. The three functions are the same pattern as for book: new_shelf() builds, validate_shelf() checks that every element is a book and that there is exactly one label, and shelf() is the helper that takes the books as arguments.

source("scenario.R")

# --- constructor, validator and helper for a collection of books -------------
new_shelf <- function(books = list(), label = character()) {
  stopifnot(is.list(books), is.character(label))
  structure(books, label = label, class = "shelf")
}

validate_shelf <- function(x) {
  is_book <- vapply(unclass(x), inherits, logical(1L), what = "book")
  if (!all(is_book)) {
    stop("every element of a shelf must be a book", call. = FALSE)
  }
  if (length(attr(x, "label")) != 1L) {
    stop("a shelf needs exactly one label", call. = FALSE)
  }
  x
}

shelf <- function(..., label = "unlabelled") {
  validate_shelf(new_shelf(list(...), label = label))
}

structure(books, label = label, class = "shelf") sets two attributes at once: the label and the class. The validator walks the elements with vapply(unclass(x), inherits, logical(1L), what = "book"), so a subclass such as audiobook is accepted because inherits() looks at the whole class vector. The next block builds a real shelf from the books data frame of the scenario, with a conversion function that turns row i into a call to book():

# --- build a shelf from the books data frame ---------------------------------
to_book <- function(i) {
  row <- as.list(books[i, ]) # nolint: object_usage_linter.
  if (!nzchar(row$isbn)) {
    row$isbn <- NA_character_
  }
  do.call(book, row) # nolint: object_usage_linter.
}
built <- lapply(seq_len(nrow(books)), \(i) tryCatch(to_book(i), error = \(e) e))
failed <- vapply(built, inherits, logical(1L), what = "error")
sum(failed)
#> [1] 1
vapply(built[failed], conditionMessage, character(1L))
#> [1] "invalid ISBN-13: 9780011001129"

reading_room <- do.call(shelf, c(built[!failed], list(label = "Reading room")))
class(reading_room)
#> [1] "shelf"
length(reading_room)
#> [1] 15
attributes(reading_room)[c("label", "class")]
#> $label
#> [1] "Reading room"
#>
#> $class
#> [1] "shelf"
#>
reading_room[[3]]
#> <book> the quiet streets (Mara Quill, 2019)

# length() needs no method: the shelf is a list, and length() of a list counts
# its elements. Iteration works the same way and keeps each element's class.
vapply(reading_room, \(b) class(b), character(1L))[1:3]
#> [1] "book" "book" "book"

# Without a method, [ drops the class and the label: the invariant is lost.
plain <- reading_room[1:2]
class(plain)
#> [1] "list"
attributes(plain)
#> NULL

One of the sixteen rows fails the validator (the ISBN with a wrong check digit, invalid ISBN-13: 9780011001129) and the loop kept the error object instead of stopping, so fifteen books went on the shelf; the empty ISBN of the other irregular row was converted to NA first, as the helper expects. Three facts about the object are in the output. length(reading_room) is 15 with no length method at all: the shelf is a list, and the internal default of length() counts a list’s elements. A length method for the book class itself, length.book, which makes one book count as one item instead of six fields, is written and run in OOP Overview and Base Types: internal generics; the scripts on this page do not define it, so length() of a book counts its six fields. reading_room[[3]] is a book, printed by print.book, because [[ extracts the element together with its own class. And reading_room[1:2] has lost the class and the label: the default [ drops all attributes except names, dim and dimnames (the section "Copying of attributes" of the Language Definition gives the rule: subsetting generally drops attributes, subassignment generally preserves them, R-lang: Copying of attributes). That is why a collection class needs a [ method.

# --- methods -----------------------------------------------------------------
`[.shelf` <- function(x, i, ...) {
  new_shelf(NextMethod(), label = attr(x, "label"))
}

`[[<-.shelf` <- function(x, i, value) {
  if (!inherits(value, "book")) {
    stop("only a book can be placed on a shelf", call. = FALSE)
  }
  NextMethod()
}

c.shelf <- function(...) {
  parts <- list(...)
  books <- unlist(
    lapply(parts, \(p) if (inherits(p, "book")) list(p) else unclass(p)),
    recursive = FALSE
  )
  label <- if (inherits(parts[[1L]], "shelf")) {
    attr(parts[[1L]], "label")
  } else {
    "unlabelled"
  }
  new_shelf(books, label = label)
}
c.book <- c.shelf

format.shelf <- function(x, ...) {
  vapply(unclass(x), format, character(1L), ...)
}

as.character.shelf <- function(x, ...) {
  format(x, ...)
}

print.shelf <- function(x, ...) {
  cat("<shelf> ", attr(x, "label"), " (", length(x), " books)\n", sep = "")
  if (length(x) > 0L) {
    writeLines(paste0(seq_along(x), ". ", format(x)))
  }
  invisible(x)
}

top <- reading_room[1:3]
class(top)
#> [1] "shelf"
attr(top, "label")
#> [1] "Reading room"
top
#> <shelf> Reading room (3 books)
#> 1. The Dispossessed (Ursula K. Le Guin, 1974)
#> 2. Pride and Prejudice (Jane Austen, 1813)
#> 3. the quiet streets (Mara Quill, 2019)
format(top)
#> [1] "The Dispossessed (Ursula K. Le Guin, 1974)"
#> [2] "Pride and Prejudice (Jane Austen, 1813)"
#> [3] "the quiet streets (Mara Quill, 2019)"
as.character(top[2])
#> [1] "Pride and Prejudice (Jane Austen, 1813)"
rev(top)
#> <shelf> Reading room (3 books)
#> 1. the quiet streets (Mara Quill, 2019)
#> 2. Pride and Prejudice (Jane Austen, 1813)
#> 3. The Dispossessed (Ursula K. Le Guin, 1974)

[.shelf calls NextMethod() to do the subsetting with the internal default, which returns a plain list, and then rebuilds the shelf with the same label. This is the standard shape of a [ method for a list-based class. [[<-.shelf is a replacement method: its last argument must be named value, and R CMD check verifies that, as the "Writing R Extensions" chapter on checking packages says; it validates the new element before handing over to the default. c.shelf collects shelves and books into one shelf: since c() dispatches on its first argument only, c.book <- c.shelf makes c(book1, book2) produce a shelf too; the opposite order, c(list_of_things, book), is not covered by any method, and that asymmetry is a general limitation of single dispatch. format.shelf, as.character.shelf and print.shelf build on format.book through vapply(unclass(x), format, ...), so that a change in how a book is described changes everything that shows books. Finally rev() has no shelf method at all: rev.default is x[length(x):1L], and it works because both length() and [ dispatch to the shelf’s own behaviour. When the building blocks of a class are generic, a lot of functions work for free.

# Subsetting by a logical vector built with vapply().
before_2000 <- vapply(
  reading_room,
  \(b) !is.na(b$year) && b$year < 2000L,
  logical(1L)
)
reading_room[before_2000]
#> <shelf> Reading room (3 books)
#> 1. The Dispossessed (Ursula K. Le Guin, 1974)
#> 2. Pride and Prejudice (Jane Austen, 1813)
#> 3. The Lamp at Noon (Le Guin, Ursula K., 1999)

# Replacing an element is checked by the [[<- method.
top[[2]] <- book("A replacement", "Iris Fenwick", year = 2024)
top
#> <shelf> Reading room (3 books)
#> 1. The Dispossessed (Ursula K. Le Guin, 1974)
#> 2. A replacement (Iris Fenwick, 2024)
#> 3. the quiet streets (Mara Quill, 2019)
tryCatch(top[[2]] <- "not a book", error = \(e) conditionMessage(e))
#> [1] "only a book can be placed on a shelf"

# c() dispatches on its first argument only.
c(top, book("One more", year = 2025))
#> <shelf> Reading room (4 books)
#> 1. The Dispossessed (Ursula K. Le Guin, 1974)
#> 2. A replacement (Iris Fenwick, 2024)
#> 3. the quiet streets (Mara Quill, 2019)
#> 4. One more (NA, 2025)
class(c(book("First"), book("Second")))
#> [1] "shelf"
tryCatch(shelf(1, 2), error = \(e) conditionMessage(e))
#> [1] "every element of a shelf must be a book"
tryCatch(
  shelf(book("A"), label = c("x", "y")),
  error = \(e) conditionMessage(e)
)
#> [1] "a shelf needs exactly one label"

# Sorting is subsetting with an order vector, so it keeps the shelf intact.
years <- vapply(reading_room, \(b) b$year, integer(1L))
oldest_first <- reading_room[order(years)]
oldest_first[1:3]
#> <shelf> Reading room (3 books)
#> 1. Pride and Prejudice (Jane Austen, 1813)
#> 2. The Dispossessed (Ursula K. Le Guin, 1974)
#> 3. The Lamp at Noon (Le Guin, Ursula K., 1999)

Subsetting with a logical vector built by vapply(), replacement with a validated [[<-, combining with c() and sorting with order() all go through the same [. The two tryCatch() calls at the end show the validator’s messages for the shelf: every element of a shelf must be a book and a shelf needs exactly one label. vapply(), lapply() and for iterate over a shelf as they would over any list, and each element keeps its own book class. The last block adds a summary method (returning an object with its own print method, following the FAQ again) and the conversion to a data frame:

# --- summary and conversion --------------------------------------------------
summary.shelf <- function(object, ...) {
  items <- unclass(object)
  years <- vapply(items, \(b) b$year, integer(1L))
  known <- years[!is.na(years)]
  structure(
    list(
      label = attr(object, "label"),
      n_books = length(object),
      with_isbn = sum(vapply(items, \(b) !is.na(b$isbn), logical(1L))),
      first_year = if (length(known) > 0L) min(known) else NA_integer_,
      last_year = if (length(known) > 0L) max(known) else NA_integer_
    ),
    class = "summary.shelf"
  )
}

print.summary.shelf <- function(x, ...) {
  cat("Shelf:", x$label, "\n")
  cat("Books:", x$n_books, "\n")
  cat("With ISBN:", x$with_isbn, "\n")
  cat("Years:", x$first_year, "to", x$last_year, "\n")
  invisible(x)
}

as.data.frame.shelf <- function(x,
                                row.names = NULL, # nolint: object_name_linter.
                                optional = FALSE, ...) {
  if (length(x) == 0L) {
    empty <- new_book() # nolint: object_usage_linter.
    return(as.data.frame.list(unclass(empty)))
  }
  rows <- lapply(unclass(x), \(b) as.data.frame.list(unclass(b)))
  do.call(rbind, rows)
}

summary(reading_room)
#> Shelf: Reading room
#> Books: 15
#> With ISBN: 14
#> Years: 1813 to 2023
s <- summary(reading_room)
class(s)
#> [1] "summary.shelf"
df <- as.data.frame(reading_room)
dim(df)
#> [1] 15  6
head(df[c("title", "year", "pages")], 3)
#>                 title year pages
#> 1    The Dispossessed 1974   387
#> 2 Pride and Prejudice 1813   432
#> 3   the quiet streets 2019   214
dim(as.data.frame(reading_room[0]))
#> [1] 0 6

summary.shelf() computes the number of books, those that have an ISBN and the range of years, ignoring the NA year, and print.summary.shelf() prints them. as.data.frame.shelf() binds the one-row data frames with do.call(rbind, rows): 15 rows and 6 columns, one per field, and for an empty shelf it returns a zero-row data frame with the right columns, built from the zero-length defaults of new_book(). For large collections a class that stores the fields in parallel vectors (a data frame with a class, or a vctrs-style record) would be more efficient than a list of small lists; that design belongs to Data Frames and to the vctrs package, and the shelf here is deliberately the simplest thing that shows the S3 mechanics.

Argument consistency: methods must look like their generic

The point of a generic is that the user (and any other method calling NextMethod()) can call it with the same arguments whichever class the object has. "Writing R Extensions" has a whole chapter of guidelines on this, with the cautionary tale of predict.lm, which originally had no ... argument although predict did, so that predict.glm could no longer use NextMethod() when it needed an extra argument (R-exts: Generic functions and methods). The rules are:

  • A method must have all the arguments of the generic, including ... if the generic has it. A method cannot decide that it only needs some of them, because the generic’s caller, and the next method in a NextMethod() chain, may pass any of them.

  • A method must have the arguments in exactly the same order as the generic. Users may match positionally, and the arguments of a method called by UseMethod() are those of the call to the generic.

  • If the generic specifies defaults, all methods should use the same defaults. The default used is the one in the method, but the default the user reads is the one in the generic’s documentation.

  • Keep generics simple. The manual recommends scale <- function(x, ...) UseMethod("scale") over a generic that lists every argument of every method: add parameters and defaults to the generic only if they make sense in all possible methods. The argument list of a new generic is the maximal set of arguments for methods written elsewhere, years later (R-exts: Adding new generics).

source("scenario.R")
b <- book("Dune", "Frank Herbert", year = 1965)
msg <- function(expr) {
  tryCatch(expr, error = \(e) conditionMessage(e))
}

# 1. A method must list the generic's arguments in the same order.
cite <- function(x, style, year) {
  UseMethod("cite")
}
cite.book <- function(x, year, style) {
  paste0(x$title, " | style=", style, " | year=", year)
}
cite(b, "long", TRUE)
#> [1] "Dune | style=TRUE | year=long"
cite(b, style = "long", year = TRUE)
#> [1] "Dune | style=long | year=TRUE"

# 2. A method must accept everything the generic accepts, including ...
cite2 <- function(x, ...) {
  UseMethod("cite2")
}
cite2.book <- function(x, style = "short") {
  paste(x$title, style)
}
cite2(b, style = "long")
#> [1] "Dune long"
msg(cite2(b, style = "long", width = 20))
#> [1] "unused argument (width = 20)"

# 3. Defaults declared in the generic are not what the method uses.
cite3 <- function(x, style = "short", ...) {
  UseMethod("cite3")
}
cite3.book <- function(x, style = "long", ...) {
  paste(x$title, style)
}
cite3(b)
#> [1] "Dune long"

# 4. Arguments after ... must be named in full, and a partial name is
#    silently swallowed by the dots.
cite4 <- function(x, ..., style = "short") {
  UseMethod("cite4")
}
cite4.book <- function(x, ..., style = "short") {
  paste(x$title, style, "| dots:", paste(names(list(...)), collapse = ","))
}
cite4(b, style = "long")
#> [1] "Dune long | dots: "
cite4(b, sty = "long")
#> [1] "Dune short | dots: sty"

# 5. The recommended shape: a simple generic, the details in the methods.
cite5 <- function(x, ...) {
  UseMethod("cite5")
}
cite5.book <- function(x, style = c("short", "long"), ...) {
  style <- match.arg(style)
  if (style == "short") {
    x$title
  } else {
    sprintf("%s (%s)", x$title, x$author)
  }
}
cite5(b)
#> [1] "Dune"
cite5(b, "long")
#> [1] "Dune (Frank Herbert)"
msg(cite5(b, "tiny"))
#> [1] "'arg' should be one of “short”, “long”"

The script is a gallery of the failures the rules prevent. In the first case cite.book lists year before style, so the positional call cite(b, "long", TRUE) gave the method style = TRUE and year = "long", while the named call worked: two calls that the user wrote identically do different things. In the second, a method without ... accepts style but rejects the extra argument that the generic would have passed, with unused argument (width = 20); any NextMethod() call that wants to pass a new argument along has the same problem, which is the predict.lm story. In the third, the generic’s default "short" was silently replaced by the method’s "long": the user reading the help page of the generic got the wrong default. In the fourth, an argument placed after ... must be named in full, because partial matching is suppressed after the dots, so sty = "long" did not set style: it went into ... and nothing complained. Case 5 is the recommended shape: a simple generic (x and ...), and a method that declares its own arguments with match.arg() for the fixed choices, including the standard error for an invalid one. match.arg() is described in Functions.

R CMD check enforces the first two rules for package code: "Writing R Extensions" says that the check examines whether S3 methods have all the arguments of the corresponding generic and whether the final argument of replacement functions is called value (R-exts: Checking packages). In a script, nothing checks anything, which is the reason to follow the rules by habit.

Making an existing function generic

A package that wants a method for a function that is not generic can turn it into one. The manual’s recipe is to keep the old definition as the default method and call the original through its namespace. It explicitly warns against the older advice of assigning foo.default <- base::foo, which captures the base function at the time of installation and goes out of date when R is patched or updated:

source("scenario.R")

# A package may turn an ordinary function into a generic: keep the old
# definition as the default method, and call it through base::.
toupper <- function(x) {
  UseMethod("toupper")
}
toupper.default <- function(x) {
  base::toupper(x)
}
toupper.book <- function(x) {
  x$title <- base::toupper(x$title)
  x
}

toupper("dune")
#> [1] "DUNE"
toupper(book("Dune", "Frank Herbert"))
#> <book> DUNE (Frank Herbert, n.d.)
environmentName(environment(base::toupper))
#> [1] "base"
environmentName(environment(toupper))
#> [1] "R_GlobalEnv"
rm(toupper, toupper.default, toupper.book)
toupper("dune")
#> [1] "DUNE"

The new toupper() shadows the base one in the global environment (for a package it would be exported and the base function stays reachable as base::toupper), the default method defers to the original, and the book method upper-cases the title and returns a book. The script then removes the definitions again, as the last call shows, and toupper("dune") is the base function once more. The manual notes a small performance cost: making a function generic with the old definition as the default adds a dispatch step, and it is never necessary to ask R Core to make a base function generic, because a package can take it over this way.

Finding, inspecting and testing methods

methods(), getS3method() and friends

Because methods are ordinary functions found by name, several tools answer "what methods exist?". methods(generic.function) lists the methods of a generic and methods(class = "book") lists the generics that have a method for a class; both return an object of class MethodsFunction, which is a character vector with an attribute info (a data frame with columns generic, visible, isS4 and from) and a print method. An asterisk after a name marks a method that is not exported from its namespace, which means it is registered but not visible by name (R documentation: methods). getS3method(f, class) retrieves a method by generic and class even when it is hidden in a namespace, getAnywhere(name) reports every place where an object of that name is found and in what form, exists("print.book") is the plain-lookup test, and utils::isS3method() and utils::isS3stdGeneric() answer the yes/no questions (R documentation: getS3method, R documentation: getAnywhere).

source("scenario.R")

# methods(class =) lists the generics that have a method for a class.
methods(class = "book")
#> [1] format  print   summary
#> see '?methods' for accessing help and source code
methods(class = "summary.book")
#> [1] print
#> see '?methods' for accessing help and source code

# methods(generic) lists the classes a generic knows: print has a lot of them.
length(methods(print)) > 100
#> [1] TRUE
length(methods(summary)) > 20
#> [1] TRUE

# The info attribute says where each method lives and whether it is visible.
info <- attr(methods(class = "book"), "info")
info[c("generic", "visible", "isS4")]
#>              generic visible  isS4
#> format.book   format    TRUE FALSE
#> print.book     print    TRUE FALSE
#> summary.book summary    TRUE FALSE

# Methods hidden in namespaces are marked with an asterisk ...
methods(coef)
#> [1] coef.aov*      coef.Arima*    coef.default*  coef.free1way* coef.listof*
#> [6] coef.maov*     coef.nls*
#> see '?methods' for accessing help and source code
# ... and cannot be found by name from the global environment,
exists("coef.aov")
#> [1] FALSE
exists("print.data.frame")
#> [1] TRUE
# but getS3method() and getAnywhere() find them.
class(getS3method("coef", "aov"))
#> [1] "function"
getAnywhere("coef.aov")$where
#> [1] "registered S3 method for coef from namespace stats"
#> [2] "namespace:stats"
head(deparse(getS3method("coef", "aov")), 1)
#> [1] "function (object, complete = FALSE, ...) "
utils::isS3method("coef.aov")
#> [1] TRUE
utils::isS3method("print.book")
#> [1] TRUE
getS3method("print", "nothing_here", optional = TRUE)
#> NULL

# The same function shows our own methods, which are in the global environment.
identical(getS3method("print", "book"), print.book)
#> [1] TRUE
environmentName(environment(getS3method("print", "book")))
#> [1] "R_GlobalEnv"

methods(class = "book") lists the three generics for which scenario.R defines a method, and methods(class = "summary.book") the single print method. methods(print) and methods(summary) are far too long to show (print has well over a hundred methods once the standard packages are loaded), which is why the script only tests their size. The result for coef shows what an installed package looks like: all methods end with an asterisk, because the stats namespace does not export them. exists("coef.aov") is FALSE and exists("print.data.frame") is TRUE, so some are visible and some are not; getS3method("coef", "aov") gets the former, and getAnywhere("coef.aov")$where explains that it is a registered S3 method for coef from the namespace stats. getS3method(..., optional = TRUE) returns NULL instead of an error when the method is missing. The info attribute shows visible = TRUE for the three book methods, because they live in the global environment. The methods() help page adds that the S3 functions listed are those that are named like methods and may not actually be methods (known exceptions are discarded), which leads to the section on naming clashes below.

An Introduction to R makes the same points with coef and getAnywhere("coef.aov"), and the 4.4.0 NEWS added an all.names argument to methods() and .S3methods() for the rare case where methods whose names start with a dot should be included (R-intro: Classes, generic functions and object orientation). The R FAQ entry for the question "Where have all the methods gone?" explains why so many S3 methods are hidden: they are in namespaces, which has the advantage that they cannot be called inadvertently with arguments of the wrong class, and the disadvantage that they are harder to view. getS3method("[", "terms") is the FAQ’s example of how to see one, and pkg:::name is the way to see an unexported function; the FAQ adds that the latter should not be used in your own code (R FAQ 7.26: Where have all the methods gone?).

Where a method has to be to be found

The lookup rule from the dispatch section has a consequence that surprises people who define methods inside functions: a method is found if it is visible in the environment of the call to the generic, or if it is registered. Both are true of methods defined at top level or in a package namespace; neither is true of a method defined inside a function body when the generic is called from somewhere else.

describe <- function(x, ...) {
  UseMethod("describe")
}
describe.default <- function(x, ...) "default method"
x <- structure(1, class = "foo")

# A method defined inside a function is found only by calls made from there.
direct <- function(x) {
  describe.foo <- function(x, ...) "local method" # nolint: object_usage_linter.
  describe(x)
}
direct(x)
#> [1] "local method"

# vapply() calls describe() from its own frame, so the local method is lost.
via_vapply <- function(x) {
  describe.foo <- function(x, ...) "local method" # nolint: object_usage_linter.
  vapply(list(x), describe, character(1L))
}
via_vapply(x)
#> [1] "default method"

# A helper defined elsewhere calls the generic from its own environment.
helper <- function(x) {
  describe(x)
}
via_helper <- function(x) {
  describe.foo <- function(x, ...) "local method" # nolint: object_usage_linter.
  helper(x)
}
via_helper(x)
#> [1] "default method"

# The same happens with local() at top level.
local({
  describe.foo <- function(x, ...) "method inside local()"
  print(describe(x))
})
#> [1] "method inside local()"
describe(x)
#> [1] "default method"

# Registering the method puts it where every call to the generic finds it.
.S3method("describe", "foo", function(x, ...) "registered method")
describe(x)
#> [1] "registered method"
via_vapply(x)
#> [1] "registered method"
exists("describe.foo")
#> [1] FALSE
methods(describe)
#> [1] describe.default describe.foo*
#> see '?methods' for accessing help and source code
body(getS3method("describe", "foo"))
#> [1] "registered method"

# A visible method in the calling environment wins over a registered one.
describe.foo <- function(x, ...) "method in the global environment"
describe(x)
#> [1] "method in the global environment"
rm(describe.foo)
describe(x)
#> [1] "registered method"

The first call works: describe(x) is called inside direct(), so describe.foo is visible in its calling environment. When the same generic is called from vapply() the calling environment is the vapply frame, so the local method is not found and the default answers; the same happens when the call is made in a helper function defined elsewhere. local() at top level behaves like a function body: found inside, lost outside. After .S3method("describe", "foo", ...) registers a method, it is found everywhere, even from inside vapply(), but exists("describe.foo") is FALSE, because registration does not create a variable, and methods(describe) shows describe.foo* with the asterisk for "not visible". Finally a method visible in the global environment (the calling environment) beats a registered method for the same class, which is a consequence of the search order. When scripts use vapply() or lapply() over objects with local methods, define the methods at top level or register them.

Registering methods

A package must not rely on the global environment: the standard S3 lookup might fail to find methods defined in a package that is imported but not attached, so the methods need to be registered. In the package’s NAMESPACE file the directive is S3method(generic, class). It makes print.book available for dispatch without exporting the function (Writing R Extensions: Registering S3 methods):

export(book, shelf)
S3method(print, book)
S3method(format, book)
S3method(summary, book)
S3method(print, summary.book)
S3method("[", shelf)
S3method("[[<-", shelf)
S3method(c, shelf)
S3method(print, shelf, print_shelf)

The directive takes the generic and the class; the third optional argument is the function to use as the method, for example S3method(print, shelf, print_shelf) when the function is not called print.shelf. Function and class names may be quoted, and reserved words and non-standard names such as [<- must be quoted. The generic must be visible to the package: since print is defined in base it needs no import, but for a generic from another package it must be imported with importFrom(). Export the generics, constructors and helpers, but export methods only through S3method(), not through export() or a wide exportPattern(), which would also export internal helpers. "Writing R Extensions" cautions that broad patterns are not recommended for production code (Writing R Extensions: Specifying imports and exports).

There are two more cases. For a method on a generic from a package you do not want to load until needed (a package in Suggests, for example), R 3.6.0 added delayed registration (listed in the R NEWS: Changes in R 3.6.0 among the changes to namespaces): S3method(pkg::gen, cls) registers gen.cls as a method for the generic gen of package pkg only when the namespace of pkg is loaded. The manual’s example wraps the directive in an if (getRversion() >= "3.6.0") condition for packages that must also install on older R versions. And for scripts, which have no NAMESPACE, R 4.0.0 added .S3method(generic, class, method) (listed as a new function in the 4.0.0 NEWS): the help page says that it should only be used in R scripts, and that package code should use the S3method directive. registerS3method(genname, class, method, envir) is the function that .S3method() calls (with envir set to the calling environment, so that the generic is searched first where .S3method() was called); it is documented with the namespace internals (R documentation: .S3method, R documentation: registerS3method).

Letting roxygen2 write the directives

Few packages write the NAMESPACE by hand: roxygen2 generates it from comments. The tag @export on a function that is recognisably a method (a name of the form generic.class where generic is a generic visible to the package) produces S3method(generic, class); on any other function it produces export(). A method for a generic from another package that should be registered lazily uses @exportS3Method pkg::gen, which produces the delayed-registration directive. The script below builds a throwaway package in a temporary directory, runs roxygen2::roxygenise() (roxygen2 8.1.1) and prints what it wrote:

# Build a throwaway package and let roxygen2 write its NAMESPACE.
pkg <- file.path(tempdir(), "shelfmate")
dir.create(file.path(pkg, "R"), recursive = TRUE)

writeLines(c(
  "Package: shelfmate",
  "Title: Books on a Shelf",
  "Version: 0.0.1",
  "Description: The book class of the shelf scenario.",
  "License: MIT",
  "Encoding: UTF-8"
), file.path(pkg, "DESCRIPTION"))

writeLines(c(
  "#' Create a book",
  "#' @param title The title.",
  "#' @return A book.",
  "#' @export",
  "book <- function(title) {",
  "  structure(list(title = title), class = \"book\")",
  "}",
  "",
  "#' Print a book",
  "#' @param x A book.",
  "#' @param ... Not used.",
  "#' @export",
  "print.book <- function(x, ...) {",
  "  cat(\"<book>\", x$title, \"\\n\")",
  "  invisible(x)",
  "}",
  "",
  "#' Subset a shelf",
  "#' @param x A shelf.",
  "#' @param i An index.",
  "#' @param ... Not used.",
  "#' @export",
  "`[.shelf` <- function(x, i, ...) {",
  "  structure(NextMethod(), class = \"shelf\")",
  "}",
  "",
  "#' Tidy a book",
  "#' @param x A book.",
  "#' @param ... Not used.",
  "#' @exportS3Method generics::tidy",
  "tidy.book <- function(x, ...) {",
  "  data.frame(title = x$title)",
  "}",
  "",
  "#' A helper that is not a method",
  "#' @param x Anything.",
  "#' @keywords internal",
  "#' @noRd",
  "print_helper <- function(x) x"
), file.path(pkg, "R", "book.R"))

invisible(capture.output(
  roxygen2::roxygenise(pkg),
  type = "message"
))

cat(readLines(file.path(pkg, "NAMESPACE")), sep = "\n")
#> # Generated by roxygen2: do not edit by hand
#>
#> S3method("[",shelf)
#> S3method(generics::tidy,book)
#> S3method(print,book)
#> export(book)
desc <- read.dcf(file.path(pkg, "DESCRIPTION"))
colnames(desc)
#> [1] "Package"                 "Title"
#> [3] "Version"                 "Description"
#> [5] "License"                 "Encoding"
#> [7] "Config/roxygen2/version"
unname(desc[1L, "Config/roxygen2/version"])
#> [1] "8.1.1"
list.files(file.path(pkg, "man"))
#> [1] "book.Rd"       "print.book.Rd" "sub-.shelf.Rd" "tidy.book.Rd"

The NAMESPACE has the three kinds of directive: export(book) for the helper, S3method(print,book) for a method found by name, and S3method("[",shelf) for a method of an internal generic (note that the quotes are generated for the non-standard name). @exportS3Method generics::tidy generated S3method(generics::tidy,book), the delayed-registration form of the previous paragraph, for a generic that lives in another package. Under roxygen2 8.x the version is recorded in the Config/roxygen2/version field of DESCRIPTION (here 8.1.1), not in the older note-style field that many tutorials still show. The helper tagged @noRd produced no Rd file and no directive. The same NAMESPACE generated by hand would have to be kept in step with the code; regenerating it after each change (devtools::document()) is the habit that prevents a method that works in load_all() and is not found in the installed package. Packages, namespaces and the NAMESPACE file are covered in Packages, Libraries and Namespaces, package creation in Creating Packages, and the documentation tags in Documentation with Rd, roxygen2 and Vignettes.

A method also needs documentation, but the manual lets it be brief: in the Rd usage section of the generic’s page, S3 methods are written with the \method{generic}{class}(arguments) macro, which keeps the usage in step with the generic’s arguments and lets R CMD check compare them (Writing R Extensions: Documenting functions). Roxygen2 writes the macro for you.

Inheritance with class vectors

S3 inheritance is nothing more than a class vector with more than one element and methods that cooperate through NextMethod(). The Language Definition states it in two sentences: the class attribute can have several elements, and the first inheritance is mainly handled through NextMethod(), which determines the method currently being evaluated, finds the next class from the class attribute and proceeds (R-lang: Inheritance). A subclass is therefore an object whose class vector starts with the subclass name and continues with the parent’s class vector, and the parent’s constructor, validator and methods are reused where they fit.

An audiobook is a book with a narrator and a duration in minutes. Its constructor takes a finished book, adds two fields and prepends the new class to the book’s own class vector; its validator calls the parent’s validator first (which accepts the extra fields, because validate_book() checks that every field has length 1 rather than the exact list of names) and then checks its own rule; and its helper builds the book with book(), so every parent rule applies:

source("scenario.R")

# A subclass: constructor, validator and helper, reusing the parent's pieces.
new_audiobook <- function(book, narrator = character(), minutes = integer()) {
  stopifnot(
    inherits(book, "book"),
    is.character(narrator),
    is.integer(minutes)
  )
  structure(
    c(unclass(book), list(narrator = narrator, minutes = minutes)),
    class = c("audiobook", class(book))
  )
}

validate_audiobook <- function(x) {
  validate_book(x) # nolint: object_usage_linter.
  if (!is.na(x$minutes) && x$minutes <= 0L) {
    stop("minutes must be positive", call. = FALSE)
  }
  x
}

audiobook <- function(title, ..., narrator = NA_character_,
                      minutes = NA_integer_) {
  validate_audiobook(
    new_audiobook(
      book(title, ...), # nolint: object_usage_linter.
      trimws(narrator),
      as.integer(minutes)
    )
  )
}

Three design decisions are worth stating. The subclass constructor is built from a book, not from the six raw fields, so there is one place that knows how to build a book. The class vector is created with class = c("audiobook", class(book)) rather than a literal c("audiobook", "book"), so a further subclass (c("audiobook", "book") becoming c("radio_drama", "audiobook", "book")) keeps working. And the validator relies on the parent’s validator instead of repeating its rules, so a change to the book rules reaches every subclass.

The methods of the subclass override or extend the parent’s. format.audiobook() takes the parent’s description and appends the narrator; summary.audiobook() takes the parent’s summary and adds the number of minutes; print.summary.audiobook() lets the parent’s method print what it knows and then adds its own line. Each begins or ends with NextMethod(), so the parent’s code is not repeated:

# Methods: format() extends the parent's result with NextMethod().
format.audiobook <- function(x, ...) {
  paste0(NextMethod(), ", read by ", x$narrator)
}

summary.audiobook <- function(object, ...) {
  out <- NextMethod()
  out$minutes <- object$minutes
  class(out) <- c("summary.audiobook", class(out))
  out
}

print.summary.audiobook <- function(x, ...) {
  NextMethod()
  cat("Minutes:", x$minutes, "\n")
  invisible(x)
}

An audiobook needs no print method of its own. The call print(a) found print.book (the first element of the class vector with a print method), which called format(x), and that dispatched again, this time to format.audiobook: a method in the parent’s code is extended by overriding one generic that it calls. This late binding, where the parent’s print behaviour changes because the subclass changed format, is what makes the pattern of printing by calling format() worthwhile.

a <- audiobook(
  "Dune", "Frank Herbert", "978-0-306-40615-7", 1965, "Classic", 412,
  narrator = "Simon Vance", minutes = 1260
)
class(a)
#> [1] "audiobook" "book"
inherits(a, "book")
#> [1] TRUE
inherits(a, c("book", "audiobook", "podcast"), which = TRUE)
#> [1] 2 1 0
names(a)
#> [1] "title"    "author"   "isbn"     "year"     "genre"    "pages"    "narrator"
#> [8] "minutes"

# print.book() is inherited; it calls format(), which finds format.audiobook().
a
#> <book> Dune (Frank Herbert, 1965), read by Simon Vance
format(a)
#> [1] "Dune (Frank Herbert, 1965), read by Simon Vance"
summary(a)
#> Book: Dune
#> ISBN known: TRUE
#> Pages: 412
#> Minutes: 1260
class(summary(a))
#> [1] "summary.audiobook" "summary.book"

# Which method would be used? Walk the class vector like UseMethod() does.
method_for <- function(generic, x) {
  for (cl in c(class(x), "default")) {
    if (!is.null(utils::getS3method(generic, cl, optional = TRUE))) {
      return(paste(generic, cl, sep = "."))
    }
  }
  NA_character_
}
generics <- c("print", "format", "summary", "toString")
vapply(generics, method_for, character(1L), x = a)
#>               print              format             summary            toString
#>        "print.book"  "format.audiobook" "summary.audiobook"  "toString.default"

# The order of the class vector is the order of the search.
reordered <- a
class(reordered) <- c("book", "audiobook")
format(reordered)
#> [1] "Dune (Frank Herbert, 1965)"
vapply(generics, method_for, character(1L), x = reordered)
#>              print             format            summary           toString
#>       "print.book"      "format.book"     "summary.book" "toString.default"

# The validators chain too.
tryCatch(audiobook("Dune", minutes = 0L), error = \(e) conditionMessage(e))
#> [1] "minutes must be positive"
tryCatch(audiobook("", minutes = 10L), error = \(e) conditionMessage(e))
#> [1] "a book needs a title"

inherits(a, "book") is TRUE for the subclass, and with which = TRUE it returns the positions 2 1 0 of "book", "audiobook" and "podcast" in the class vector of a (zero when the class is not there). method_for() walks the class vector the way UseMethod() does and reports the method that would be chosen (it ignores implicit classes and registered methods that getS3method() does not find from its own frame, so it is a teaching device, not a debugging tool); print.book, format.audiobook, summary.audiobook and toString.default are the answers for a. Reversing the class vector with class(reordered) <- c("book", "audiobook") changes the answers: the same object now uses format.book first, and nothing complained, because nothing in S3 verifies that the order makes sense. The order of the class vector is the order of the search, and the specific class must come first. The two tryCatch() calls at the end show the validators chained: the audiobook’s own rule fires for zero minutes, and the book’s rule for an empty title.

S3 inheritance has no way to say that a class is abstract, no multiple dispatch, and no check that a subclass keeps the parent’s promises: the parent’s methods trust that the object has the parent’s fields. Where a class system needs those guarantees, the formal systems exist: S4 Classes and Methods adds declared slots, validity, virtual classes and multiple dispatch, and The S7 Object System is the successor under development.

Testing for a class

The class vector can have several elements, so tests that treat class(x) as a single string are wrong in general, and they have become visibly wrong in two places in recent R versions. First, class(matrix(1:4, 2)) has length two since R 4.0.0, and the 4.0.0 NEWS points out that code which assumes the class of a matrix has length one is invalid. Second, R 4.2.0 turned the warning for an if or while condition of length greater than one into an error (R NEWS: Changes in R 4.2.0), so the common test if (class(x) == "book") stops as soon as x has two classes. The same 4.2.0 NEWS also records that the result of ks.test() now has class c("ks.test", "htest") and tells package authors who test the class to use inherits() and not ==.

source("scenario.R")
msg <- function(expr) {
  tryCatch(expr, error = \(e) conditionMessage(e))
}

plain <- book("Dune")
sub <- structure(c(unclass(plain), list(narrator = "N")),
  class = c("audiobook", "book")
)

# == on the class works for a one-element class vector only.
class(plain) == "book"
#> [1] TRUE
class(sub) == "book"
#> [1] FALSE  TRUE
msg(if (class(sub) == "book") "a book")
#> [1] "the condition has length > 1"

# inherits() answers the right question: is it a book, possibly a subclass?
inherits(plain, "book")
#> [1] TRUE
inherits(sub, "book")
#> [1] TRUE
inherits(sub, "audiobook")
#> [1] TRUE
inherits(plain, "audiobook")
#> [1] FALSE

# which = TRUE gives the position in the class vector, 0 when absent.
inherits(sub, c("audiobook", "book", "podcast"), which = TRUE)
#> [1] 1 2 0

# isa() requires all the classes, in any order.
isa(sub, "book")
#> [1] TRUE
isa(sub, c("book", "audiobook"))
#> [1] TRUE
isa(plain, c("book", "audiobook"))
#> [1] FALSE

# identical() on the class vector is the strict test: exactly this class.
identical(class(plain), "book")
#> [1] TRUE
identical(class(sub), "book")
#> [1] FALSE

# class<- accepts any character vector: nothing checks that it makes sense.
broken <- structure(list(), class = "book")
inherits(broken, "book")
#> [1] TRUE
msg(validate_book(broken))
#> [1] "missing value where TRUE/FALSE needed"
msg(format(broken))
#> [1] "argument is of length zero"

class(sub) == "book" returned FALSE TRUE for an object with two classes, and if (class(sub) == "book") stopped with the condition has length > 1. The reliable tests are:

  • inherits(x, "book") for "is it a book, possibly a subclass?", and inherits(x, c("a", "b"), which = TRUE) when the position matters;

  • isa(x, c("book", "audiobook")) when all the classes must be present (any order);

  • identical(class(x), "book") when the object must be exactly a book and not a subclass;

  • is.object(x) for "does it have a class attribute at all?".

Since R 4.3.0, inherits(x, what) accepts values for what that are not plain character vectors: a new generic, nameOfClass(), is called to resolve the class name, so that alternative object systems can plug into inherits() (R NEWS: Changes in R 4.3.0). The last lines of the script show the other side of the coin: nothing prevents structure(list(), class = "book"), and inherits() says TRUE for an object that has none of a book’s fields; the validator, run on it, fails with a message about missing values, and format() fails inside the method. A class test checks the label, not the contents.

Pitfalls

Where have all the methods gone?

R FAQ 7.26 explains why S3 methods are often not visible: many are hidden in namespaces, which has the advantage that they cannot be called inadvertently with arguments of the wrong class. The tools shown above (getS3method(), getAnywhere(), methods()) retrieve them, and pkg:::name reads an unexported function but should not be used in your own code. The other half of the question, "why is my method not called?", has a short checklist:

  • the method is defined where the call to the generic cannot see it (inside a function, in local(), in an environment that the generic’s caller does not have) and is not registered: see the vapply() example in the previous section;

  • the method is in a package namespace and is not registered: it is only found when the package is attached. The fix is the S3method() directive in NAMESPACE, or @export with roxygen2;

  • the method name is wrong: print.Book is not print.book (class names are case sensitive), or the class vector was reordered;

  • the class was lost on the way: [, c(), unlist(), lapply() results, rbind() and arithmetic often return plain vectors or lists, because the internal defaults drop the class (see the shelf section);

  • the arguments do not match the generic: unused argument appears when the generic passes something the method does not accept;

  • a function with a method-like name is called instead (next subsection).

Printing and the visible value

Automatic printing, which is what calls the print method, happens only for a visible value at the top level, as R FAQ 7.16 explains in the context of source(). Inside a for loop, inside a function whose value is not returned to the top level, or in a script run with source() without echo or print.eval, nothing is printed unless the code calls print() itself.

source("scenario.R")
pair <- list(book("First", year = 1990), book("Second", year = 2001))

# Only a visible value at top level is printed, by calling print() on it.
pair[[1]]
#> <book> First (NA, 1990)
invisible(pair[[1]])
(invisible(pair[[1]]))
#> <book> First (NA, 1990)

# Inside a loop, or a function that is not at the end of the line, nothing is
# printed automatically: call print() yourself.
for (b in pair) {
  b
}
for (b in pair) {
  print(b)
}
#> <book> First (NA, 1990)
#> <book> Second (NA, 2001)
show_all <- function(items) {
  for (b in items) {
    print(b)
  }
  invisible(items)
}
show_all(pair)
#> <book> First (NA, 1990)
#> <book> Second (NA, 2001)
res <- show_all(pair)
#> <book> First (NA, 1990)
#> <book> Second (NA, 2001)
length(res)
#> [1] 2

# print() returns its argument invisibly, which is what makes this work.
x <- print(pair[[2]])
#> <book> Second (NA, 2001)
identical(x, pair[[2]])
#> [1] TRUE

# A function that ends with an assignment returns its value invisibly too.
f <- function(b) {
  out <- format(b) # nolint: object_usage_linter.
}
f(pair[[1]])
(f(pair[[1]]))
#> [1] "First (NA, 1990)"

# source() prints top-level values only with echo or print.eval.
source(exprs = quote(pair[[1]]))
source(exprs = quote(pair[[1]]), print.eval = TRUE)
#> <book> First (NA, 1990)

The first expression printed the book because it was a visible value at the top level; the two forms with invisible() show that visibility, not the value, decides. The for (b in pair) b loop printed nothing at all, while print(b) printed both books. show_all() returns its argument invisibly, so calling it at top level prints once per print() inside the loop and does not print the list again; that is the idiom used by print methods (do the printing, return invisible(x)). A function whose last expression is an assignment (f()) also returns invisibly, so f(pair[[1]]) shows nothing, and wrapping the call in parentheses forces printing. source() prints top-level values only with echo = TRUE or print.eval = TRUE, which is why scripts that "work" in the console print nothing under Rscript or source(): a bare b line is not a statement that prints. See Idioms and Common Pitfalls for more of these habits.

Partial matching with $ on list-based objects

An S3 object built on a list is read with $, and $ on a list matches names partially. A misspelt or abbreviated field name silently returns the value of a field whose name starts with the typed text:

source("scenario.R")
b <- book("Dune", "Frank Herbert", year = 1965, pages = 412)

# $ on a list matches names partially: a misspelt field still "works".
b$pag
#> [1] 412
b$yea
#> [1] 1965
b$y
#> [1] 1965
b$au
#> [1] "Frank Herbert"

# A field that does not exist is NULL, not an error.
b$publisher
#> NULL
is.null(b$pub)
#> [1] TRUE

# [[ matches exactly unless asked otherwise.
b[["pag"]]
#> NULL
b[["pag", exact = FALSE]]
#> [1] 412

# The option warnPartialMatchDollar reports every partial match.
op <- options(warnPartialMatchDollar = TRUE)
withCallingHandlers(
  b$pag,
  warning = \(w) {
    cat("warning:", conditionMessage(w), "\n")
    invokeRestart("muffleWarning")
  }
)
#> warning: partial match of 'pag' to 'pages'
#> [1] 412
options(op)

# Two candidates are ambiguous and give NULL.
ambiguous <- structure(list(year = 1965L, yearly = 12L), class = "book")
ambiguous$yea
#> NULL
ambiguous$year
#> [1] 1965

b$pag returned the pages, b$yea the year, b$y the year again (the only field starting with y), and b$au the author; a name that matches nothing, b$publisher, is NULL, not an error, which means a typo can silently turn a number into NULL and then into a vector of length zero. [[ is exact unless asked with exact = FALSE. The option warnPartialMatchDollar makes every partial match on a $ warn: partial match of 'pag' to 'pages'. When two names match, the result is NULL (the last lines: year and yearly both start with yea). Use [[ with the full name in functions that matter, or turn the option on in tests; Subsetting and Indexing explains the rules for $ and [[ in general.

Comparing, printing the raw list and looking inside

A classed list is still a list, and the list tools see through the class. identical() compares the class attribute as well as the data, so a book and its unclass() copy are not identical, and all.equal() describes the first difference it finds. The == operator has no book method, so it falls to the default for lists and stops with comparison of these types is not implemented; to compare books by value, compare unclass() copies, define a method for Ops (S3 Group Generics and Operator Overloading) or compare chosen fields. To see an object without the method getting in the way, use unclass(), str(), attributes() or print.default():

source("scenario.R")
a <- book("Dune", "Frank Herbert", year = 1965)
b <- book("Dune", "Frank Herbert", year = 1965)
c2 <- book("Dune", "Frank Herbert", year = 1966)

# identical() and all.equal() compare the class attribute as well as the data.
identical(a, b)
#> [1] TRUE
identical(a, c2)
#> [1] FALSE
all.equal(a, b)
#> [1] TRUE
all.equal(a, c2)
#> [1] "Component “year”: Mean relative difference: 0.0005089059"
identical(a, unclass(a))
#> [1] FALSE
all.equal(unclass(a), unclass(c2))
#> [1] "Component “year”: Mean relative difference: 0.0005089059"
identical(unclass(a), unclass(b))
#> [1] TRUE

# == is not defined for a book: the default method works on the list.
tryCatch(a == b, error = \(e) conditionMessage(e))
#> [1] "comparison of these types is not implemented"

# Seeing inside an object: unclass(), str(), attributes() and names().
str(unclass(a))
#> List of 6
#>  $ title : chr "Dune"
#>  $ author: chr "Frank Herbert"
#>  $ isbn  : chr NA
#>  $ year  : int 1965
#>  $ genre : chr NA
#>  $ pages : int NA
attributes(a)
#> $names
#> [1] "title"  "author" "isbn"   "year"   "genre"  "pages"
#>
#> $class
#> [1] "book"
#>
names(a)
#> [1] "title"  "author" "isbn"   "year"   "genre"  "pages"
mode(a)
#> [1] "list"
typeof(a)
#> [1] "list"
is.list(a)
#> [1] TRUE
length(a)
#> [1] 6

# The same object with the print method bypassed.
print(unclass(a)[c("title", "year")])
#> $title
#> [1] "Dune"
#>
#> $year
#> [1] 1965
#>
print.default(a)
#> $title
#> [1] "Dune"
#>
#> $author
#> [1] "Frank Herbert"
#>
#> $isbn
#> [1] NA
#>
#> $year
#> [1] 1965
#>
#> $genre
#> [1] NA
#>
#> $pages
#> [1] NA
#>
#> attr(,"class")
#> [1] "book"

all.equal(a, c2) pointed at the differing component with Component “year”: Mean relative difference: 0.0005089059, and all.equal(unclass(a), unclass(c2)) gave the same message without the class attribute. str(unclass(a)) shows the six fields, attributes(a) shows names and class and nothing else, and print.default(a) prints the raw list, ending with attr(,"class") so that the class is visible. print(unclass(a)[c("title", "year")]) prints selected fields without the method. The print.default() call is also how a method delegates to the built-in printing; NextMethod() does the same job inside a print method.

Naming clashes: when a function is a method by accident

Any function named generic.class is a method, whether or not that was intended. A plain helper such as describe.list, written to describe a list of books, becomes the method for every plain list in the session, because the implicit class of a list is "list":

msg <- function(expr) {
  tryCatch(expr, error = \(e) conditionMessage(e))
}

# A name of the form generic.class is a method, whatever the author meant.
describe <- function(x, ...) {
  UseMethod("describe")
}
describe.default <- function(x, ...) "default"

# Meant as a helper "to describe a list of books" ...
describe.list <- function(x, ...) {
  paste(length(x), "items")
}
# ... but every plain list now dispatches to it.
describe(list(1, 2, 3))
#> [1] "3 items"
describe(list())
#> [1] "0 items"
describe(1:3)
#> [1] "default"

# A data frame has a class attribute, so the implicit class "list" is never
# tried for it: the default method answers until it gets a method of its own.
describe(data.frame(a = 1))
#> [1] "default"
describe.data.frame <- function(x, ...) "a data frame"
describe(data.frame(a = 1))
#> [1] "a data frame"

# Base R has two well-known near-misses: t.test() is not a method of t() for
# class "test", and sort.list() is not a method of sort() for lists.
utils::isS3stdGeneric(t)
#>    t
#> TRUE
utils::isS3method("t.test")
#> [1] FALSE
utils::isS3method("sort.list")
#> [1] FALSE
getS3method("t", "test", optional = TRUE)
#> NULL
is.null(getS3method("sort", "list", optional = TRUE))
#> [1] TRUE
msg(sort(list(2, 1)))
#> [1] "'x' must be atomic"

describe(list(1, 2, 3)) called the helper, which the author meant for something else. A data frame is not captured by it, because it has a class attribute and the implicit class "list" is tried only for objects without one; it would be captured once a method for data.frame exists, which is what the script shows next. Base R has two well-known near-misses, t.test() (is it the t() method for class test?) and sort.list() (is it the sort() method for lists?). Neither is a method: utils::isS3method() says FALSE for both, getS3method("t", "test", optional = TRUE) is NULL and sort(list(2, 1)) reports an error from the default method rather than from sort.list(). The NEWS for R 4.3.3 records one of these: getS3method("t", "test") no longer finds t.test(). The rule that follows is the one the manuals give: do not name functions in the style generic.class unless they are meant to be methods. Use describe_list(), book_describe() or describe_books(), and when you need a name that contains dots, check the prefix against the list of generics (methods() and utils::isS3stdGeneric()).

Version notes

Version Change Where it matters

R 3.5.0

S3 method lookup searches the namespace registry after the top level environment of the calling environment

the lookup order of a call to a generic

R 3.6.0

S3method() directives in NAMESPACE can do delayed registration, S3method(pkg::gen, cls)

methods for generics from packages in Suggests

R 4.0.0

.S3method() registers methods in scripts; .class2() returns the full vector used for dispatch; matrices also inherit from "array", and S3 methods for class "array" are dispatched for matrices

scripts without a NAMESPACE; debugging dispatch; code that assumed class(m) has length one

R 4.2.0

if and while conditions of length greater than one are an error; UseMethod() no longer crashes when it cannot dispatch on an object with a very long class vector (4.2.3)

if (class(x) == "book") for an object with several classes

R 4.3.0

inherits(x, what) accepts a non-character what, resolved by the new generic nameOfClass(); chooseOpsMethod() for Ops conflicts

alternative object systems such as S7

R 4.3.3

getS3method("t", "test") no longer finds t.test()

naming clashes in the lookup helpers

R 4.4.0

UseMethod() no longer forwards local variables of the generic into the method; methods() and .S3methods() gain all.names

methods that read variables set by the generic

R 4.6.0

str() of a list-like object whose class has an str() method that calls NextMethod() no longer prints a "List of" header from the default method

printing of classed lists

The R 3.5.0 and 3.6.0 items are in the R NEWS for the 3.x series. All the examples on this page were run on R 4.6.1. Behaviour that changed in 4.4.0 (the local variables) and 4.0.0 (the matrix class) is the part most likely to matter in old code; inherits(which = TRUE) is documented on the class help page and is older than the NEWS files kept for the 3.x series, so no introduction version is stated for it.

Where older tutorials differ

Much of what is written about S3 predates the constructor and validator habit, the invisible(x) convention and the registration mechanism. Following that older material produces classes that work interactively and fail when they are put in a package, or when somebody passes them a slightly different input:

msg <- function(expr) {
  tryCatch(expr, error = \(e) conditionMessage(e))
}

# The style of many older tutorials: a bare list, a class assignment, no checks.
b <- list(title = "Dune", year = "nineteen sixty-five", pages = 1:3)
class(b) <- "oldbook"
attr(b, "class")
#> [1] "oldbook"
inherits(b, "oldbook")
#> [1] TRUE

# A print method without ... and without invisible(x).
print.oldbook <- function(x) {
  cat("<oldbook>", x$title, "\n")
}
b
#> <oldbook> Dune
msg(print(b, digits = 3))
#> [1] "unused argument (digits = 3)"
y <- print(b)
#> <oldbook> Dune
is.null(y)
#> [1] TRUE
msg(b |> print() |> format())
#> <oldbook> Dune
#> [1] "NULL"

# The modern shape of the same method.
print.oldbook <- function(x, ...) {
  cat("<oldbook>", x$title, "\n")
  invisible(x)
}
y <- print(b, digits = 3)
#> <oldbook> Dune
identical(y, b)
#> [1] TRUE

# Nothing stopped the nonsense: wrong types, wrong lengths, a missing title.
str(unclass(b))
#> List of 3
#>  $ title: chr "Dune"
#>  $ year : chr "nineteen sixty-five"
#>  $ pages: int [1:3] 1 2 3
bad <- structure(list(), class = "oldbook")
msg(print(bad))
#> <oldbook>

The script shows several things at once. The class was assigned to a bare list with class(b) <- "oldbook", and attr(b, "class") reads it back: both work, but nothing was checked, so the year is a string and the pages are an integer vector of length three, and a completely empty structure(list(), class = "oldbook") prints <oldbook> with no title and no complaint. The old print.oldbook(x) has no ..., so print(b, digits = 3) stops with unused argument (digits = 3), and it ends with cat(), so it returns NULL: y <- print(b) gives NULL and a pipeline such as b |> print() |> format() ends up formatting NULL. The modern version has the generic’s arguments and ends with invisible(x).

Older tutorials Today Why

class(x) <- "book" on a bare list, no constructor

A constructor with structure(), a validator and a helper function

nothing else checks that an object has the fields its methods expect (R-lang: "Classes")

attr(x, "class") <- "book" or attributes(x) <- list(class = ...)

structure(..., class = ...) in the constructor, class(x) <- ... elsewhere; oldClass() when only the attribute is wanted

the replacement functions say what they do, and oldClass() never reports an implicit class

print.book <- function(x) cat(...)

print.book <- function(x, ...) that ends with invisible(x)

the generic passes ...; print() is documented to return its argument invisibly

summary.book that prints and returns something

summary.book returns a summary.book object with its own print method

R FAQ 8.1

if (class(x) == "book")

inherits(x, "book")

a class vector can have more than one element; length-two conditions are an error since R 4.2.0

Methods in a package left unregistered because exportPattern("^[^.]") exports everything

S3method(generic, class) in NAMESPACE or @export under roxygen2, with the generic imported

a method must be registered to be found when the package is only loaded, not attached

The roxygen2 version recorded in a note-style field of DESCRIPTION

Config/roxygen2/version: (roxygen2 8.x)

roxygen2 8.0.0 moved its settings to Config/roxygen2/*

.S3method() or registerS3method() calls in package code

S3method() in NAMESPACE (or @export with roxygen2); .S3method() in scripts

the help page for .S3method() says it is meant for scripts

foo.default <- base::foo to make a base function generic

foo.default <- function(object, ...) base::foo(object)

the assignment captures the function at install time and goes out of date (Writing R Extensions)

setOldClass() to declare an S3 class

nothing for S3; setOldClass() only when an S3 class must be used in an S4 slot or method signature

it belongs to the S4 system

x$na for x$name on a list-based object

[[ with the full name, options(warnPartialMatchDollar = TRUE) while testing

partial matching returns a wrong value silently

methods(print) to see everything

methods(class = "book") and getS3method()

the generic’s list is very long and shows only what the loaded packages register

The community books Advanced R (the S3 chapter, which also uses the constructor, validator and helper vocabulary) and R Packages cover the same ground in a tutorial style; they are named here as community material and are not part of the official documentation this page derives from. The official manuals are older than both and make a point that the newer material sometimes drops: S3 gives no safety net, and every guarantee is a convention that you have to keep.

Where to go next