S4 Classes and Methods

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. Almost everything on this page is the methods package, which ships with R and is attached by default; the worked examples also use stats4 and the recommended Matrix package 1.7-5 that ship with R, and one typeof() comparison uses the CRAN package S7 0.2.2. The roxygen2 section was checked with roxygen2 8.1.1 (CRAN). The lintr 3.4.0 default linters were used only to check the scripts. S4 is the formal object system of R, for objects that must be built the same way every time: a class declares its named, typed fields (slots), an object can be checked against the class, and functions are made generic so that the code that runs is chosen from the classes of one or more arguments. The methods package is its home, stats4 and Matrix are written with it, and the Bioconductor project (https://bioconductor.org/) builds its data structures on it. This page defines a Book class with the six fields of the running shelf scenario, a subclass, a collection, a few generics and methods, and then looks at how S4 behaves inside packages and in stats4::mle().

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, including the messages that R writes to stderr. The scripts also pass the default lintr linters; a # nolint comment marks a place where the name or idiom on show is what a default linter dislikes (R’s generics dictate argument names such as .Object and na.rm, and a script that starts with source("scenario.R") uses names the linter cannot see). The names books, loans, shelf_today, loan_days and the S3 constructor book() come from the running library-shelf scenario described on the section index, so a script that needs them starts with source("scenario.R"). Several consecutive code blocks below come from one script run from top to bottom (the Book model in particular grows over many sections); the prose between the blocks does not change the session. Where a message contains quotation marks, they are the typographic quotes R prints in a UTF-8 locale. This page is the S4 part of the object-oriented group: OOP Overview and Base Types compares all the systems, S3 Classes and Generics covers the informal system S4 interoperates with, and Reference Classes and R6 and The S7 Object System cover the others.

What S4 is, and where it is documented

S4 objects are "formal": a class has a definition stored in the session, the definition lists the slots and their classes, and new() refuses an object that does not fit. Methods belong to generic functions, not to classes, and dispatch can look at more than one argument. That differs from S3 (a class is only a class attribute and a method is only a function with a conventional name, dispatched on the first argument) and from Reference Classes and R6 (methods belong to an object that is changed in place). The ?Introduction help page of the methods package describes the target use: new classes of objects that represent the data and results of an application, usually in an R package, with functional methods implemented for them. It points to Reference Classes for the case where persistent objects are changed over time, and it presents setClass() to define a class and setMethod() to define a method as enough to get started.

The documentation is spread over several places, and it is worth knowing which is which:

  • The R Language Definition has no chapter on S4. Its chapter on object-oriented programming describes the S3 mechanism (R-lang: Object-oriented programming) and the S4 type shows up only in the table of typeof() values (R-lang: Basic types). The reference for S4 is the documentation of the methods package: ?Introduction for a first read, ?setClass, ?setGeneric, ?setMethod, ?validObject and ?new for the main functions, and ?Methods_Details and ?Classes_Details for how dispatch and inheritance work.

  • An Introduction to R mentions S4 only in a footnote of its section on the class of an object, which says that a different style using formal or S4 classes is provided by the methods package (An Introduction to R: The class of an object).

  • Writing R Extensions, section 1.5.6, is the authority for S4 in packages: the Depends or Imports line, and the NAMESPACE directives exportClasses, exportMethods, importClassesFrom and importMethodsFrom. It is covered in the package section below.

  • R Internals, section 1.12, describes how S4 objects, classes and method tables are stored. A few of its facts appear in the examples (the hidden .__C__ and .__T__ objects).

  • The R FAQ has one S4 entry, 8.5 "How can I debug S4 methods?", whose answer is trace() with a signature= argument.

  • John Chambers' book Extending R (2016), chapters 9 and 10, is the reference that every help page of the package cites. It is a book, not online documentation, so it is named here and not listed in the references.

The page follows the order in which a class is usually written: slots and instances, validity, initialization, generics and methods, inheritance, multiple dispatch, interaction with S3, group generics and accessors, introspection, and then packages and the stats4 example.

Classes and slots

The first class

setClass() defines a class: its name and its slots, a named character vector in which each element is the class the slot must hold. The six slots of Book are the six columns of the books table, in the same order, with the same types as book() produces. new() creates an instance and @ reads or writes a slot; slot() does the same by name. The slots and contains arguments of setClass() must be supplied by name, the help page says, for back compatibility with other arguments that are no longer recommended.

Rscript attaches the methods package, so none of these scripts calls library(methods). That has been the case since R 3.5.0, whose NEWS says that when no default packages are specified, Rscript now uses the same default packages as R (before, it left methods out), and that setting the environment variable R_SCRIPT_LEGACY to yes restores the previous behaviour.

# Rscript attaches the methods package by default (since R 3.5.0).
"package:methods" %in% search()
#> [1] TRUE

setClass("Book", slots = c(
  title = "character", author = "character", isbn = "character",
  year = "integer", genre = "character", pages = "integer"
))

b <- new("Book",
  title = "The Dispossessed", author = "Ursula K. Le Guin",
  isbn = "9780306406157", year = 1974L, genre = "Science fiction", pages = 387L
)
b
#> An object of class "Book"
#> Slot "title":
#> [1] "The Dispossessed"
#>
#> Slot "author":
#> [1] "Ursula K. Le Guin"
#>
#> Slot "isbn":
#> [1] "9780306406157"
#>
#> Slot "year":
#> [1] 1974
#>
#> Slot "genre":
#> [1] "Science fiction"
#>
#> Slot "pages":
#> [1] 387
#>

# The slots are read with @ (or slot()) and the object is an S4 object.
b@title
#> [1] "The Dispossessed"
slot(b, "year")
#> [1] 1974
isS4(b)
#> [1] TRUE
is.object(b)
#> [1] TRUE
class(b)
#> [1] "Book"
#> attr(,"package")
#> [1] ".GlobalEnv"
typeof(b)
#> [1] "S4"
str(b)
#> Formal class 'Book' [package ".GlobalEnv"] with 6 slots
#>   ..@ title : chr "The Dispossessed"
#>   ..@ author: chr "Ursula K. Le Guin"
#>   ..@ isbn  : chr "9780306406157"
#>   ..@ year  : int 1974
#>   ..@ genre : chr "Science fiction"
#>   ..@ pages : int 387

The default show() method prints the class name and every slot (the blank lines belong to the output). str() has an S4 layout, Formal class 'Book' [package ".GlobalEnv"] with 6 slots, and the class of the object carries a package attribute naming where the class was defined, here the global environment. typeof() is "S4": the object is neither a list nor an atomic vector, it is a bare object with attributes, and each slot is stored as an attribute. A class that contains a basic type behaves differently, which is explored in the section on S3 classes. isS4() tells an S4 object from an S3 one; is.object() is TRUE for both because both have a class attribute (the first is documented with isS4 and the second with is.object).

Where the data is, and what R refuses

A class definition is a promise about the shape of every instance, and R enforces the promise at the points where an object is made or a slot is assigned. Each message below is the real text of R 4.6.1. Two of them are easy to meet in practice: the slot types are strict, so year = 2021 (a double) is refused where year = 2021L is accepted, and the same check runs when a slot is assigned with @<-.

setClass("Book", slots = c(
  title = "character", author = "character", isbn = "character",
  year = "integer", genre = "character", pages = "integer"
))
b <- new("Book", title = "Loud Roofs", year = 2021L)

# Every failure below is caught so that the script keeps running; the text is
# the real message of R 4.6.1.
msg <- \(expr) tryCatch(expr, error = \(e) conditionMessage(e))

# An unknown slot name, in new() and when reading or writing with @.
msg(new("Book", subtitle = "none"))
#> [1] "invalid name for slot of class “Book”: subtitle"
msg(b@subtitle)
#> [1] "no slot of name \"subtitle\" for this object of class \"Book\""
msg(b@subtitle <- "none")
#> [1] "‘subtitle’ is not a slot in class “Book”"

# A value of the wrong class: new() validates, and so does the @<- assignment.
msg(new("Book", year = "2021"))
#> [1] "invalid class “Book” object: invalid object for slot \"year\" in class \"Book\": got class \"character\", should be or extend class \"integer\""
msg(b@year <- "2021")
#> [1] "assignment of an object of class “character” is not valid for @‘year’ in an object of class “Book”; is(value, \"integer\") is not TRUE"

# 2021 is a double, not an integer: the slot type is strict.
msg(new("Book", year = 2021))
#> [1] "invalid class “Book” object: invalid object for slot \"year\" in class \"Book\": got class \"numeric\", should be or extend class \"integer\""
b@year
#> [1] 2021
new("Book", year = 2021L)@year
#> [1] 2021

# An undefined class.
msg(new("Pamphlet"))
#> [1] "“Pamphlet” is not a defined class"

# An unnamed argument must be an object of a superclass.
msg(new("Book", "Loud Roofs"))
#> [1] "cannot use object of class “character” in new():  class “Book” does not extend that class"

The first message (invalid name for slot of class) comes from new(), the second from reading with @, the third from writing with @<-. The two "wrong class" messages differ: new() reports the failure as an invalid object (invalid class “Book” object: invalid object for slot "year" in class "Book": got class "character", should be or extend class "integer") because it ends in validObject(), whereas an assignment reports assignment of an object of class “character” is not valid for @‘year’ in an object of class “Book”; is(value, "integer") is not TRUE. The last message is a reminder of the rule for unnamed arguments of new(): they must be objects of a superclass, which is how new("AudioBook", a_book, narrator = ...) copies the slots of a Book (shown later).

There is a documented way around the class check of an assignment: slot<- has a check argument, and check = FALSE skips it. The result is an object that no longer fits its class, which validObject() reports when it is called.

# slot<- takes a check argument; with check = FALSE the class test is skipped
# and an invalid object can be built by hand, which validObject() then reports.
slot(b, "year", check = FALSE) <- "2021"
b@year
#> [1] "2021"
msg(validObject(b))
#> [1] "invalid class “Book” object: invalid object for slot \"year\" in class \"Book\": got class \"character\", should be or extend class \"integer\""

Inspecting a class

A class is also an object, and a handful of functions ask the session about it. getClass() (or showClass()) prints the slots and the inheritance; slotNames() and getSlots() return them; isVirtualClass() says whether instances can exist; isClass() says whether a class is defined; and setClass() returns a generator function, invisibly, which is a shorthand for new("Book", ...). The help page of setClass() recommends assigning the generator to the name of the class, and notes that a call through the generator records the package of the class, which removes any ambiguity if two packages define classes with the same name.

# setClass() returns a generator function, invisibly.
Book <- setClass("Book", slots = c( # nolint: object_name_linter.
  title = "character", author = "character", isbn = "character",
  year = "integer", genre = "character", pages = "integer"
))
class(Book)
#> [1] "classGeneratorFunction"
#> attr(,"package")
#> [1] "methods"
Book(title = "Paper Moons", year = 2005L, pages = 88L)@title
#> [1] "Paper Moons"

# What R knows about the class.
isVirtualClass("Book")
#> [1] FALSE
isClass("Book")
#> [1] TRUE
slotNames("Book")
#> [1] "title"  "author" "isbn"   "year"   "genre"  "pages"
getSlots("Book")
#>       title      author        isbn        year       genre       pages
#> "character" "character" "character"   "integer" "character"   "integer"
getClass("Book")
#> Class "Book" [in ".GlobalEnv"]
#>
#> Slots:
#>
#> Name:      title    author      isbn      year     genre     pages
#> Class: character character character   integer character   integer

# Unset slots take the default of their class: an empty vector.
empty <- new("Book")
empty@year
#> integer(0)
length(empty@title)
#> [1] 0
identical(empty, Book())
#> [1] TRUE

# S4 objects have value semantics and compare with identical().
copy <- empty
copy@title <- "Loud Roofs"
length(empty@title)
#> [1] 0
identical(empty, copy)
#> [1] FALSE

Slots that were not given take the default of their class, which for the vector classes is the empty vector, and an object with unset slots is a valid object as far as R is concerned: new("Book") ran without complaint although a book without a title makes no sense. A slot is also copied on assignment (S4 objects have the same value semantics as vectors, unlike Reference Class or R6 objects), and identical() compares two S4 objects slot by slot. The help for getClass() is getClass.

Prototypes, and the older representation()

A prototype gives the slots their default values, and a prototype of NA values is the usual choice for a record like a book. The help page of setClass() says that a more flexible approach than prototype= is an initialize() method, and recommends setValidity() rather than the validity= argument for "clearer code"; both arguments still work and are used below.

representation() is the older spelling of slots= and contains=: the first argument is a named vector of slot classes, and an unnamed element is a superclass. The slots argument of setClass() was added in R 3.0.0 (the NEWS calls it clearer and less ambiguous than representation), and the current help page lists representation, access, version and S3methods as deprecated since R 3.0.0. It still works, and older code and tutorials use it everywhere, which is why it is shown here; new code uses slots= and contains=. The same script shows two related facts: prototype() is the matching helper of representation(), and a subclass may redefine an inherited slot only with a subclass of the inherited class.

# A prototype gives the slots their default values. Here every slot defaults
# to a missing value of the right type instead of an empty vector.
setClass("Book",
  slots = c(
    title = "character", author = "character", isbn = "character",
    year = "integer", genre = "character", pages = "integer"
  ),
  prototype = list(
    title = NA_character_, author = NA_character_, isbn = NA_character_,
    year = NA_integer_, genre = NA_character_, pages = NA_integer_
  )
)
new("Book")@year
#> [1] NA
new("Book", title = "Paper Moons")@pages
#> [1] NA

# representation() is the older spelling of slots= and contains=. The help page
# of setClass() lists it as deprecated since R 3.0.0, but it still works.
setClass("OldBook", representation(title = "character", pages = "integer"))
getSlots("OldBook")
#>       title       pages
#> "character"   "integer"
setClass("OldBook2", slots = c(title = "character", pages = "integer"))
identical(getSlots("OldBook"), getSlots("OldBook2"))
#> [1] TRUE

# prototype() is the matching helper for the prototype= argument.
setClass("Pamphlet",
  slots = c(title = "character", pages = "integer"),
  prototype = prototype(pages = 8L)
)
new("Pamphlet")@pages
#> [1] 8

# Slots may be redefined only by a subclass of the inherited slot class.
setClass("Base", slots = c(code = "character"))
msg <- \(expr) tryCatch(expr, error = \(e) conditionMessage(e))
msg(setClass("Derived", contains = "Base", slots = c(code = "numeric")))
#> [1] "Definition of slot ‘code’, in class “Derived”, as “numeric” conflicts with definition, inherited from class “Base”, as “character”"

The second chunk of the script shows that an unnamed character vector as slots defines slots of the unrestricted class "ANY". That is rarely what a class should do, since the point of slots is the type, but it is the shortest way to a throwaway class.

# An unnamed character vector gives slot names only: each slot is class "ANY".
setClass("Loose", slots = c("first", "second"))
getSlots("Loose")
#>  first second
#>  "ANY"  "ANY"
loose <- new("Loose", first = 1, second = "two")
loose@second
#> [1] "two"

Virtual classes and class unions

A virtual class is a class from which no instance can be made. It exists to hold common structure (slots, methods, a place in the inheritance tree) or to name a set of classes. setClass() makes a class virtual when it is called with only a class name, with contains = "VIRTUAL" (the form used on this page), or with the older representation("VIRTUAL"); the help page adds that the "VIRTUAL" marker "does not carry over to subclasses", so a class that extends a virtual class is an ordinary class unless it says otherwise. isVirtualClass() asks the question, and the error of new() on a virtual class is trying to generate an object from a virtual class ("Item").

The Book model of this page derives from a virtual class Item, so that "something that can sit on a shelf" has a name in the hierarchy and later code can write methods for "Item". The first chunk of the script is the smallest example; the diagram near the end of the page shows where Item sits.

source("scenario.R")

# A virtual class cannot be instantiated; it only holds shared structure.
setClass("Item", contains = "VIRTUAL")
isVirtualClass("Item")
#> [1] TRUE
tryCatch(new("Item"), error = \(e) conditionMessage(e))
#> [1] "trying to generate an object from a virtual class (\"Item\")"
setClass("Pamphlet", contains = "Item", slots = c(title = "character"))
isVirtualClass("Pamphlet")
#> [1] FALSE
is(new("Pamphlet", title = "Opening hours"), "Item")
#> [1] TRUE

A class union is a virtual class defined by its members rather than by inheritance: setClassUnion("characterOrNULL", c("character", "NULL")) makes a class that every character vector and NULL belongs to, without changing those classes. The usual reason is a slot that may hold a value or nothing, which a plain class cannot express because NULL is not a character vector; the union is the type of the slot. Methods written for the union apply to all the members. The scenario has two such fields: an unreturned loan has no return date, and a loan may carry a free-text note or none. The Loan class below keeps the loans columns (loan_id, isbn, member, loan_date, return_date) and adds the optional note. "Date" is one of the S3 classes that the methods package has already registered, so it can be used as a slot class and as a union member without setOldClass().

# A class union is a virtual class defined by its members, which can be basic
# types and even NULL: "this slot holds a character string or nothing".
setClassUnion("characterOrNULL", c("character", "NULL"))
setClassUnion("DateOrNULL", c("Date", "NULL"))
isVirtualClass("characterOrNULL")
#> [1] TRUE
is("text", "characterOrNULL")
#> [1] TRUE
is(NULL, "characterOrNULL")
#> [1] TRUE
is(1, "characterOrNULL")
#> [1] FALSE
getClass("DateOrNULL")
#> Extended class definition ( "ClassUnionRepresentation" )
#> Virtual Class "DateOrNULL" [in ".GlobalEnv"]
#>
#> No Slots, prototype of class "NULL"
#>
#> Known Subclasses:
#> Class "NULL", directly
#> Class "Date", directly
#> Class ".NULL", by class "NULL", distance 2, with explicit coerce

setClass("Loan",
  slots = c(
    loan_id = "character", isbn = "character", member = "character",
    loan_date = "Date", return_date = "DateOrNULL", note = "characterOrNULL"
  )
)
new("Loan")@return_date
#> NULL
still_out <- new(
  "Loan",
  loan_id = "L054", isbn = "9780141439518", member = "Hugo Silva",
  loan_date = as.Date("2026-06-02")
)
is.null(still_out@return_date)
#> [1] TRUE
still_out@note <- "reserved by Ana Ruiz"
still_out@note
#> [1] "reserved by Ana Ruiz"
still_out@note <- NULL
still_out@note
#> NULL
tryCatch(still_out@note <- 42, error = \(e) conditionMessage(e))
#> [1] "assignment of an object of class “numeric” is not valid for @‘note’ in an object of class “Loan”; is(value, \"characterOrNULL\") is not TRUE"

The print of getClass("DateOrNULL") lists the union’s members as Known Subclasses, including the internal .NULL class that makes NULL a legal member. A union is a virtual class, so the usual rules apply: nothing can be instantiated from it, a slot of the union type accepts any member (NULL and "text" above) and refuses everything else (the last message shows the check of an assignment to the note slot). More members can be added to a union later with setIs(), as ?Methods_Details notes, and for dispatch a union behaves like any other superclass of its members.

A method written for the union applies to every member, which is the second use of unions: one method instead of one per member class.

# A method written for a union applies to every member.
setGeneric("note_length", function(x) standardGeneric("note_length"))
#> [1] "note_length"
setMethod("note_length", "characterOrNULL", function(x) {
  if (is.null(x)) 0L else nchar(x)
})
note_length(NULL)
#> [1] 0
note_length("reserved by Ana Ruiz")
#> [1] 20
note_length(still_out@note)
#> [1] 0

Validity

A class definition says what type each slot has, not what values are sensible. The checks on values go into a validity method: a function of one argument (the object) that returns TRUE, or a character vector describing every problem it found. validObject() runs it, new() runs it through the default initialize() method, and it can be attached to a class with setValidity() or with the validity= argument of setClass() (the help page prefers the separate setValidity() call "for clearer code"). The model uses both: setValidity() for Book and the validity= argument for AudioBook and Shelf, so each style has an example.

The rules below copy validate_book() of the running scenario: every field has length one, the title is not empty, an ISBN, when present, is a valid ISBN-13, and the year is at most 2100. The slots are first checked for length, because every later test assumes a single value, and a length failure returns early. Otherwise the method collects every problem and returns all of them, which the help page recommends over stopping at the first, and validObject() accumulates them in one message. Validity methods never call stop() themselves; they return the text and validObject() raises the error.

Two details of the first chunk are worth a comment. setValidity() returns the class definition visibly, so a top-level call prints it, as it did here; wrapping the call in invisible() or assigning the result silences it, and inside a package nobody sees it. And Item is the virtual class of the previous section, repeated in one line so that the script is self-contained.

source("scenario.R")

# Item is a virtual class without slots: nothing can be created from it.
setClass("Item", contains = "VIRTUAL")
setClass("Book",
  contains = "Item",
  slots = c(
    title = "character", author = "character", isbn = "character",
    year = "integer", genre = "character", pages = "integer"
  ),
  prototype = list(
    title = NA_character_, author = NA_character_, isbn = NA_character_,
    year = NA_integer_, genre = NA_character_, pages = NA_integer_
  )
)

# A validity method returns TRUE, or a character vector with every problem it
# found. The rules are the ones of validate_book() in scenario.R.
# nolint start: object_usage_linter.
check_book <- function(object) {
  fields <- c("title", "author", "isbn", "year", "genre", "pages")
  lens <- vapply(fields, \(f) length(slot(object, f)), integer(1))
  if (any(lens != 1L)) {
    return(paste0("slot ", names(lens)[lens != 1L], " must have length 1"))
  }
  errors <- character()
  if (is.na(object@title) || !nzchar(object@title)) {
    errors <- c(errors, "a book needs a title")
  }
  if (!is.na(object@isbn) && !is_valid_isbn13(object@isbn)) {
    errors <- c(errors, paste("invalid ISBN-13:", object@isbn))
  }
  if (!is.na(object@year) && object@year > 2100L) {
    errors <- c(errors, paste("implausible year:", object@year))
  }
  if (length(errors)) errors else TRUE
}
# nolint end
setValidity("Book", check_book)
#> Class "Book" [in ".GlobalEnv"]
#>
#> Slots:
#>
#> Name:      title    author      isbn      year     genre     pages
#> Class: character character character   integer character   integer
#>
#> Extends: "Item"

When validity runs

new() ends by calling validObject(), so the objects created in the next chunk are checked: the first passes, the next three are refused with the real messages. When several rules fail the message has one numbered line per problem, each repeated with the invalid class "Book" object: prefix (here shown with writeLines()), and a violation of the length rule is reported by the same method.

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

# new() runs the validity method after filling the slots.
b <- new("Book", title = "Loud Roofs", isbn = "9780002000291", year = 2021L)
msg(new("Book", title = "Loud Roofs", isbn = "9780002000292"))
#> [1] "invalid class “Book” object: invalid ISBN-13: 9780002000292"
writeLines(msg(new("Book", title = "", isbn = "9780002000292", year = 2500L)))
#> invalid class “Book” object: 1: a book needs a title
#> invalid class “Book” object: 2: invalid ISBN-13: 9780002000292
#> invalid class “Book” object: 3: implausible year: 2500
msg(new("Book", title = c("A", "B")))
#> [1] "invalid class “Book” object: slot title must have length 1"

The checking stops at the edge of new(). A slot assignment with @<- checks that the value has the class the slot declares, and nothing else, so b@year <- 3000L is accepted silently and b@year is 3000; the invalid state is found only when something calls validObject() again. validObject(object, test = TRUE) returns the problems as a character vector instead of signalling an error, which is the form to use in code that wants to decide for itself. This is the most frequent surprise for people who come from a language where a setter validates: in S4 the validation is explicit, and the place for a validating setter is an accessor function (see the accessor section).

# Assigning to a slot checks the class of the value, not the validity method.
b@year <- 3000L
b@year
#> [1] 3000
msg(validObject(b))
#> [1] "invalid class “Book” object: implausible year: 3000"
b@year <- 2021L
validObject(b)
#> [1] TRUE

# test = TRUE returns the problems instead of signalling an error.
b@title <- ""
validObject(b, test = TRUE)
#> [1] "a book needs a title"
b@title <- "Loud Roofs"

A subtler case is new("Book") with no data arguments. The default initialize() method calls validObject() only when there are arguments to initialize, so an empty new() returns the prototype without checking it. The object it returns has NA for the title and is invalid by the rules above, which validObject(empty, test = TRUE) confirms. This is not a bug but a design choice that matters elsewhere: R itself builds empty objects (to convert an object to a superclass, for example), and those objects must not trigger errors. It is also why a constructor function should always pass at least the required slots.

# new() without data arguments skips validation: it only copies the prototype.
empty <- new("Book")
empty@title
#> [1] NA
validObject(empty, test = TRUE)
#> [1] "a book needs a title"

# The method is stored in the class definition.
identical(getValidity(getClass("Book")), check_book)
#> [1] TRUE

Order of validity checks, and complete

validObject() works bottom up, as the help page puts it: it checks the slots against their classes, then the superclasses (running the validity method of each one, from the most distant), then the validity method of the object’s own class. The method of a superclass is called with the object converted to that superclass, and methods "do not have to check validity of superclasses": validObject() calls them. If the slots or a superclass produce errors, the class’s own method is not called at all, which is easy to see with the AudioBook subclass defined in the inheritance section. The complete = TRUE argument additionally calls validObject() recursively on the object in each slot, which matters for a slot that itself holds S4 objects, such as the list of books in a shelf.

Initialization and constructors

initialize() methods

new("Book", ...) begins by copying the prototype of the class and then calls the generic function initialize(.Object, ...) with that copy and the other arguments. The default method fills the slots from the named arguments, treats unnamed arguments as objects from a superclass whose slots are copied, and calls validObject() (the details are in the help page ?new, and the methods for the basic classes are in ?initialize-methods). A class-specific method is the place to compute slot values from arguments that are not slots, or to normalise input before it is validated. The help page of new() states two rules for such methods: include ... among the formal arguments, and pass the arguments on with callNextMethod(), "which helps the definition of future subclasses of your class".

The initialize method for Book mirrors the S3 constructor book() of the scenario: it trims the title, author and genre, normalises the ISBN with normalise_isbn(), and converts the year and pages to integers, so new("Book", year = 2019) works where the plain default would reject a double. The arguments are modified in a list, and the list is handed to the next method, the default one, with do.call(callNextMethod, ...); the default method then fills the slots and runs validObject(), which is why normalisation comes before validation. The comparison with book() at the end of the chunk is slot by slot, and all six are identical.

# An initialize() method normalises the arguments, then hands them on. It must
# also work when it is called with no arguments at all (see the pitfalls).
# nolint start: object_name_linter, object_usage_linter.
setMethod("initialize", "Book", function(.Object, ...) {
  args <- list(...)
  text <- intersect(names(args), c("title", "author", "genre"))
  args[text] <- lapply(args[text], trimws)
  whole <- intersect(names(args), c("year", "pages"))
  args[whole] <- lapply(args[whole], as.integer)
  if (!is.null(args$isbn) && !anyNA(args$isbn)) {
    args$isbn <- normalise_isbn(args$isbn)
  }
  do.call(callNextMethod, c(list(.Object), args))
})
# nolint end

q <- new(
  "Book",
  title = "  the quiet streets ", author = "Mara Quill",
  isbn = "978-0-00-100010-0", year = 2019, genre = "Fiction ", pages = 214
)
q@title
#> [1] "the quiet streets"
q@isbn
#> [1] "9780001000100"
q@year
#> [1] 2019

# new("Book", ...) now agrees with the S3 constructor book() slot by slot.
s3 <- book(
  "  the quiet streets ", "Mara Quill", "978-0-00-100010-0", 2019,
  "Fiction ", 214
)
fields <- slotNames("Book")
vapply(fields, \(f) identical(slot(q, f), s3[[f]]), logical(1))
#>  title author   isbn   year  genre  pages
#>   TRUE   TRUE   TRUE   TRUE   TRUE   TRUE
msg(new("Book", title = "   "))
#> [1] "invalid class “Book” object: a book needs a title"

The method also has to survive being called with no arguments, as in new("Book"), which is why it only touches the arguments that exist. A method that insists on an argument fails in places far from its definition (see the pitfalls section for the real error).

A constructor function

The generator returned by setClass() and the call new("Book", ...) both work, but a hand-written constructor function is the usual public interface of a class: the function has the name of the class, documented arguments with defaults, and one place to convert and check the input (the generator of setClass() is the simplest form of the same idea). Here Book() has the same arguments and defaults as book(), and R itself reports a missing title with the ordinary argument "title" is missing, with no default error because the argument has no default.

# A constructor function is the user-facing helper: named arguments with the
# same defaults as book(), and a missing title is reported by R itself.
# nolint start: object_name_linter.
Book <- function(title, author = NA_character_, isbn = NA_character_,
                 year = NA_integer_, genre = NA_character_,
                 pages = NA_integer_) {
  new(
    "Book",
    title = title, author = author, isbn = isbn,
    year = year, genre = genre, pages = pages
  )
}
# nolint end
Book("Paper Moons", "Noor Haddad", "978-0-00-900091-1", 2005, "Poetry", 88)@isbn
#> [1] "9780009000911"
msg(Book())
#> [1] "argument \"title\" is missing, with no default"

The function is deliberately thin. The conversion and validation live in initialize() and the validity method, so a call to new("Book", ...) and a call to Book(...) behave the same, and a subclass constructor can reuse the logic by calling new() with its own class name.

Generics and methods

A generic function

A generic function is an ordinary function whose body calls standardGeneric("name"); it dispatches to the method that matches the classes of the arguments. setGeneric(name, def) creates it and returns the name. A method is a function stored in the generic’s method table under a signature, the classes of some of the formal arguments; setMethod(name, signature, definition) stores it. The definition must have the formal arguments of the generic (... may stand for extra ones), and by default every formal argument except ... is in the signature. The call to setGeneric() is optional when the function already exists, but it is the way to declare a new one.

setGeneric("pages_per_year", function(x, ...) standardGeneric("pages_per_year"))
#> [1] "pages_per_year"

# The method divides the pages by the age of the book on shelf_today.
# nolint start: object_usage_linter.
setMethod("pages_per_year", "Book", function(x, ...) {
  age <- max(as.integer(format(shelf_today, "%Y")) - x@year, 1L)
  x@pages / age
})
# nolint end
pages_per_year(Book("The Dispossessed", year = 1974, pages = 387))
#> [1] 7.442308
pages_per_year(Book("Garden of Small Fires", pages = 96))
#> [1] NA
msg(pages_per_year("not a book"))
#> [1] "unable to find an inherited method for function ‘pages_per_year’ for signature ‘x = \"character\"’"
msg(pages_per_year())
#> [1] "unable to find an inherited method for function ‘pages_per_year’ for signature ‘x = \"missing\"’"

The generic pages_per_year() is called on a Book, and the method divides the pages by the age of the book on shelf_today (at least one year). A book without a year has no age and the result is NA. A call on an object of no suitable class fails with unable to find an inherited method for function ... for signature ..., and since R 4.4.0 the message names the signature argument (x = "character"), a change recorded in the NEWS as including "the signature argument names" in the dispatch error. A missing argument is dispatched as the class "missing", which is why the zero-argument call reports x = "missing".

Default methods, and generics made from existing functions

What a generic does when no method matches depends on how it was made. If setGeneric() is given an existing function (setGeneric("describe")), that function becomes the default method, the one for the class "ANY", and it keeps working for every class without a method. If it is given a definition that is more than a call to standardGeneric(), that definition is the default method. If the definition only calls standardGeneric(), there is no default method and a call with an unsupported class fails with the dispatch error seen above; the help page of setGeneric() recommends, when no general default makes sense, a default method that signals an error with a message that explains why a specific method is needed. And setGeneric("nchar") on a function of another package creates a copy of the function in the current environment, whose default method is the original, which masks the original for code that runs from there; the original keeps running inside base, which is the situation that the R Internals manual describes as "somewhat problematic" for functions in base namespaces. In a package that is why methods for existing functions are exported with exportMethods() and not by exporting a new function.

# An ordinary function becomes the default method of a generic of the same name.
describe <- function(x, ...) paste("an object of class", class(x)[1])
setGeneric("describe")
#> [1] "describe"
setClass("Pamphlet", slots = c(title = "character"))
setMethod("describe", "Pamphlet", function(x, ...) {
  paste("a pamphlet called", x@title)
})
describe(new("Pamphlet", title = "Opening hours"))
#> [1] "a pamphlet called Opening hours"
describe(1)
#> [1] "an object of class numeric"
existsMethod("describe", "ANY")
#> [1] TRUE
# A definition that is more than a call to standardGeneric() is the default.
setGeneric(
  "shelf_mark",
  function(x, ...) paste("no shelf mark for", class(x)[1])
)
#> [1] "shelf_mark"
shelf_mark("anything")
#> [1] "no shelf mark for character"
existsMethod("shelf_mark", "ANY")
#> [1] TRUE

# A generic whose definition only calls standardGeneric() has no default method.
setGeneric("catalogue", function(x, ...) standardGeneric("catalogue"))
#> [1] "catalogue"
existsMethod("catalogue", "ANY")
#> [1] FALSE
tryCatch(catalogue(1), error = \(e) conditionMessage(e))
#> [1] "unable to find an inherited method for function ‘catalogue’ for signature ‘x = \"numeric\"’"
# setGeneric() on a function of another package creates a copy in the global
# environment. The copy masks the original for code that runs from there, while
# the original keeps running inside base.
setGeneric("nchar")
#> [1] "nchar"
identical(nchar, base::nchar)
#> [1] FALSE
nchar("shelf")
#> [1] 5
isGeneric("nchar")
#> [1] TRUE
existsMethod("nchar", "ANY")
#> [1] TRUE

The arguments valueClass, signature and group of setGeneric() change the generic in ways that are rarely needed (valueClass checks the class of the result, signature limits the arguments that take part in dispatch, group makes the generic a member of a group generic); the pitfalls section shows the effect of the first two.

show(), and why print() is not the S4 way

The method that decides how an S4 object looks at the console is show(), a generic from the methods package. The autoprinter calls it for any S4 object, print() of an S4 object calls it too, and an object inside a list is displayed by it. An S3-style print.Book() is not used by the autoprinter of an S4 object (a script in the S3 section shows it), so a print method is the wrong tool; setMethod("show", "Book", ...) is the right one. The only argument of the generic is called object, and a method uses the same name. This show() prints the same line as format.book() of the scenario, prefixed with <Book> where the S3 version prints <book>.

# show() is what the console calls to display an S4 object.
setMethod("show", "Book", function(object) {
  year <- if (is.na(object@year)) "n.d." else object@year
  line <- sprintf("%s (%s, %s)", object@title, object@author, year)
  cat("<Book>", line, "\n")
})
paper <- Book(
  "Paper Moons", "Noor Haddad", "978-0-00-900091-1", 2005, "Poetry", 88
)
paper
#> <Book> Paper Moons (Noor Haddad, 2005)
print(paper)
#> <Book> Paper Moons (Noor Haddad, 2005)
list(paper)
#> [[1]]
#> <Book> Paper Moons (Noor Haddad, 2005)
#>

Inheritance

contains = "Book" makes AudioBook a subclass: it has all the slots of Book and its own, and it inherits every method of Book until it overrides one. The slot order lists the new slots first and then the inherited ones (narrator, minutes, then the six of Book). The class has its own validity= function (the other style of attaching a validity method) and an initialize() method of its own that converts minutes to an integer and then hands the rest to callNextMethod(.Object, ...).

callNextMethod() calls the next method: the one that would have been called if the current method did not exist. The method of AudioBook runs its own part first, and the call passes the arguments on, so the Book method (the one that trims and normalises) still runs. The help page notes that the next method is chosen from the signature of the current method, not from the actual classes of the arguments, so changing an argument before the call does not change which method is selected.

# AudioBook extends Book: it has the six slots of Book plus two of its own.
setClass("AudioBook",
  contains = "Book",
  slots = c(narrator = "character", minutes = "integer"),
  prototype = list(narrator = NA_character_, minutes = NA_integer_),
  validity = function(object) {
    if (length(object@narrator) != 1L || length(object@minutes) != 1L) {
      return("narrator and minutes must have length 1")
    }
    if (!is.na(object@minutes) && object@minutes <= 0L) {
      return("minutes must be positive")
    }
    TRUE
  }
)
# nolint start: object_name_linter.
setMethod("initialize", "AudioBook", function(.Object, ..., minutes) {
  if (missing(minutes)) {
    callNextMethod(.Object, ...)
  } else {
    callNextMethod(.Object, ..., minutes = as.integer(minutes))
  }
})
# nolint end
slotNames("AudioBook")
#> [1] "narrator" "minutes"  "title"    "author"   "isbn"     "year"     "genre"
#> [8] "pages"

ab <- new(
  "AudioBook",
  title = " Notes on Gravity ", author = "Elias Moreau", year = 2017,
  narrator = "Hugo Silva", minutes = 735
)
ab@title
#> [1] "Notes on Gravity"
ab@minutes
#> [1] 735
ab
#> <Book> Notes on Gravity (Elias Moreau, 2017)

The AudioBook was printed with the Book method of show(), because it inherits it; the narrator and minutes slots are not displayed yet. Validity follows the order explained above, and the three messages show it: with an empty title and a valid narrator, the Book rule fires; with a good title and minutes = -5, the AudioBook rule fires; with both problems only the Book message appears, because the subclass method is not reached.

# Validity runs bottom up: the Book rules first, then the AudioBook rules.
# An error in a superclass rule stops the chain.
msg(new("AudioBook", title = "", narrator = "Hugo Silva", minutes = 735))
#> [1] "invalid class “AudioBook” object: a book needs a title"
msg(new("AudioBook", title = "Quiet", narrator = "Hugo Silva", minutes = -5))
#> [1] "invalid class “AudioBook” object: minutes must be positive"
msg(new("AudioBook", title = "", minutes = -5))
#> [1] "invalid class “AudioBook” object: a book needs a title"

An unnamed argument to new() is an object of a superclass whose slots are copied into the new object, a way to promote a Book to an AudioBook and add the new slots. The default initialize() method defines it; because the Book method above only normalises named arguments, the call passes through unharmed.

# An unnamed argument is an object of a superclass whose slots are copied.
from_paper <- new("AudioBook", paper, narrator = "Ana Ruiz", minutes = 90)
from_paper@title
#> [1] "Paper Moons"
from_paper@narrator
#> [1] "Ana Ruiz"

is(), extends() and inherits()

is(object, "Class") asks whether the object belongs to the class, including through inheritance; with one argument it lists the object’s class and all its superclasses, nearest first. extends("AudioBook", "Book") asks the same about two class names, existsMethod() asks whether a method is defined for exactly that signature, and hasMethod() also counts inherited ones. The base function inherits() knows about S4 inheritance too, so inherits(ab, "Book") is TRUE; is() also understands the relations between basic types and class unions, which inherits() does not (is(1L, "numeric") is TRUE, inherits(1L, "numeric") is FALSE). The method table has no entry for pages_per_year on AudioBook yet, so existsMethod() is FALSE and hasMethod() is TRUE, and the call itself dispatches to the Book method.

is(ab, "Book")
#> [1] TRUE
is(ab, "Item")
#> [1] TRUE
is(paper, "AudioBook")
#> [1] FALSE
is(ab)
#> [1] "AudioBook" "Book"      "Item"
extends("AudioBook", "Book")
#> [1] TRUE
inherits(ab, "Book")
#> [1] TRUE
existsMethod("pages_per_year", "AudioBook")
#> [1] FALSE
hasMethod("pages_per_year", "AudioBook")
#> [1] TRUE
pages_per_year(new("AudioBook", title = "Loud", year = 2021, pages = 198))
#> [1] 39.6

as(object, "Class") converts an object to another class. Going up the inheritance tree is automatic: as(ab, "Book") returns a plain Book with the extra slots dropped. With strict = FALSE, an object that already is a Book is returned unchanged, still an AudioBook, which is cheaper and is what method code uses when it only needs the superclass behaviour. A virtual superclass such as Item has no instances, and as(ab, "Item") returns a Book. Conversions that are not inheritance need a setAs() method, shown with S3 below.

# as() converts up to the superclass; the extra slots are dropped.
plain <- as(ab, "Book")
class(plain)
#> [1] "Book"
#> attr(,"package")
#> [1] ".GlobalEnv"
is(plain, "AudioBook")
#> [1] FALSE
# strict = FALSE keeps the object as it is when it already is a Book.
class(as(ab, "Book", strict = FALSE))
#> [1] "AudioBook"
#> attr(,"package")
#> [1] ".GlobalEnv"
# A virtual class has no instances: converting to Item gives a Book.
class(as(ab, "Item"))
#> [1] "Book"
#> attr(,"package")
#> [1] ".GlobalEnv"

Showing the hierarchy

getClass() and showClass() print the position of a class in the hierarchy: the slots, what the class extends (and at what distance) and its known subclasses. The distance is the number of generations between the classes, and method selection prefers the smaller distance. A class has the implicit superclass "ANY", at a larger distance than any real class.

getClass("Item")
#> Virtual Class "Item" [in ".GlobalEnv"]
#>
#> No Slots, prototype of class "S4"
#>
#> Known Subclasses:
#> Class "Book", directly
#> Class "AudioBook", by class "Book", distance 2
getClass("Book")
#> Class "Book" [in ".GlobalEnv"]
#>
#> Slots:
#>
#> Name:      title    author      isbn      year     genre     pages
#> Class: character character character   integer character   integer
#>
#> Extends: "Item"
#>
#> Known Subclasses: "AudioBook"
showClass("AudioBook")
#> Class "AudioBook" [in ".GlobalEnv"]
#>
#> Slots:
#>
#> Name:   narrator   minutes     title    author      isbn      year     genre
#> Class: character   integer character character character   integer character
#>
#> Name:      pages
#> Class:   integer
#>
#> Extends:
#> Class "Book", directly
#> Class "Item", by class "Book", distance 2

Refining and extending a method with callNextMethod()

A method for a subclass often needs the superclass behaviour plus a change. callNextMethod() with no arguments calls the next method with the current arguments; the help page says the next method sees the same actual arguments as the current one, as x = x in the call, so a local modification of x before the call is seen by the next method. In the AudioBook method of pages_per_year() the pages are estimated from the running time when the book has none, and the Book method then does the division. The show() method of AudioBook prints the Book line by calling callNextMethod() and adds a line for the narrator.

# A method for the subclass may refine the one of the superclass. With no
# arguments, callNextMethod() passes on the arguments as they are now, so the
# local change to x is seen by the Book method.
setMethod("pages_per_year", "AudioBook", function(x, ...) {
  if (is.na(x@pages) && !is.na(x@minutes)) {
    x@pages <- as.integer(round(x@minutes / 2))
  }
  callNextMethod()
})
pages_per_year(new("AudioBook", title = "Loud", year = 2021, pages = 198))
#> [1] 39.6
pages_per_year(new("AudioBook", title = "Notes", year = 2017, minutes = 735))
#> [1] 40.88889

# show() for AudioBook adds a line to the output of the Book method.
setMethod("show", "AudioBook", function(object) {
  callNextMethod()
  cat("  read by", object@narrator, "for", object@minutes, "minutes\n")
})
ab
#> <Book> Notes on Gravity (Elias Moreau, 2017)
#>   read by Hugo Silva for 735 minutes

The callNextMethod() call has two forms, and the choice matters in initialize() methods. With no arguments it re-sends the current arguments. With arguments (callNextMethod(.Object, ...)) it sends those, and the dispatch is unchanged: the arguments "have no effect on selecting the next method". A further caveat is in the help page’s section "Ambiguous Selection": when a method for two arguments is reached through a group generic or a class with two parents, the next method may be ambiguous, and the method should call a specific function or re-call the generic instead.

Multiple dispatch

S4 dispatch looks at every argument in the signature of the generic, not only the first, which is the feature that distinguishes it from S3 (where UseMethod() dispatches on the first argument, and the operators' double dispatch is a special case, see S3 Group Generics and Operator Overloading). A signature lists classes in the order of the generic’s arguments; arguments left out of a method’s signature, and trailing ones, are taken as "ANY".

Two special classes appear in signatures. "ANY" matches an argument of any class, which makes it the catch-all of a position, and "missing" matches an argument that was not supplied in the call, which makes it possible to give a one-argument form of a two-argument generic. A generic can also have methods that treat "no second argument" differently from "any second argument", which is the typical use of "missing".

The generic matches(x, y) below asks whether y identifies the book x. A second book matches when both have the same ISBN; a character string matches when it normalises to the book’s ISBN, so the hyphenated form from a catalogue matches the bare digits of loans$isbn; anything else does not match; and matches(x) with no second argument asks whether the book has an ISBN to match at all. Method selection first looks for an exact match of the classes of the actual arguments, then at the superclasses, taking the nearest by distance for each argument: matches(lamp, 9780006000624) is a call with a double, no method exists for "numeric", so the "ANY" method answers FALSE. The last call, matches(ab, lamp) with the AudioBook, finds the (Book, Book) method through inheritance (distance 1 for ab, 0 for lamp) and answers FALSE because ab has no ISBN at that point.

# Dispatch on two arguments: matches(x, y) asks whether y identifies x.
setGeneric("matches", function(x, y) standardGeneric("matches"))
#> [1] "matches"
# nolint start: object_usage_linter.
setMethod("matches", signature("Book", "Book"), function(x, y) {
  !is.na(x@isbn) && identical(x@isbn, y@isbn)
})
setMethod("matches", signature("Book", "character"), function(x, y) {
  !is.na(x@isbn) && identical(x@isbn, normalise_isbn(y))
})
# nolint end
setMethod("matches", signature("Book", "ANY"), function(x, y) FALSE)
setMethod("matches", signature("Book", "missing"), function(x, y) {
  !is.na(x@isbn)
})

lamp <- Book(
  "The Lamp at Noon", "Le Guin, Ursula K.", "978-0-00-600062-4", 1999
)
matches(lamp, lamp)
#> [1] TRUE
matches(lamp, "978-0-00-600062-4")
#> [1] TRUE
matches(lamp, 9780006000624)
#> [1] FALSE
matches(lamp)
#> [1] TRUE
matches(Book("Rivers of Glass"))
#> [1] FALSE
matches(ab, lamp)
#> [1] FALSE

The script that defined Loan in the section on class unions continues here, and uses "missing" for a sensible default argument: is_overdue(loan, today) has a method for ("Loan", "Date"), and a method for ("Loan", "missing") that calls it with the scenario’s shelf_today.

# Dispatch on a missing argument: today defaults to shelf_today.
setGeneric("is_overdue", function(x, today) standardGeneric("is_overdue"))
#> [1] "is_overdue"
# nolint start: object_usage_linter.
setMethod("is_overdue", signature("Loan", "Date"), function(x, today) {
  end <- if (is.null(x@return_date)) today else x@return_date
  as.integer(end - x@loan_date) > loan_days
})
setMethod("is_overdue", signature("Loan", "missing"), function(x, today) {
  is_overdue(x, shelf_today)
})
# nolint end
is_overdue(still_out)
#> [1] TRUE
is_overdue(still_out, as.Date("2026-06-10"))
#> [1] FALSE

Applying the generic to all the loans reproduces the count of the scenario’s reference results: the loans table has 25 returned loans and 7 unreturned ones that exceed the 21-day period on shelf_today, 32 in all, and one row (the impossible date 2025-02-30) is NA.

# The loans table as Loan objects: an unreturned loan gets a NULL return date.
loan_from_row <- function(r) {
  new("Loan",
    loan_id = r$loan_id, isbn = r$isbn, member = r$member,
    loan_date = r$loan_date,
    return_date = if (is.na(r$return_date)) NULL else r$return_date
  )
}
rows <- lapply(seq_len(nrow(loans)), \(i) loans[i, ])
all_loans <- lapply(rows, loan_from_row)
length(all_loans)
#> [1] 60
flags <- vapply(all_loans, is_overdue, logical(1))
table(flags, useNA = "ifany")
#> flags
#> FALSE  TRUE  <NA>
#>    27    32     1

# The same count on the data frame. The row with the impossible loan date has
# no age at all, so it is NA in both.
end <- loans$return_date
end[is.na(end)] <- shelf_today
days_out <- as.integer(end - loans$loan_date)
sum(days_out > loan_days, na.rm = TRUE)
#> [1] 32
sum(is.na(days_out))
#> [1] 1

Ambiguity

When two methods are equally good, R does not fail: it picks one, and prints a note. The help page describes the rule. Each argument produces a list of candidate classes with their distances, a method is "best" if it has the least distance for every argument, and when several are best, "the first method lexicographically in the ordering" is selected and a message names the others. The page adds that this is a message, not a warning, because the end user usually cannot do anything about it; package authors are told to check their packages with testInheritedMethods(). The fix is always a method for the combination that is ambiguous (the last two calls of the chunk). The note is printed once for each combination of classes, then the selection is cached.

# A call that fits two methods equally well is ambiguous. R picks one, prints
# a note on stderr (once per combination of classes) and carries on.
setGeneric("pair", function(x, y) standardGeneric("pair"))
#> [1] "pair"
setMethod("pair", signature("Book", "ANY"), function(x, y) "Book, ANY")
setMethod("pair", signature("ANY", "Book"), function(x, y) "ANY, Book")
pair(lamp, lamp)
#> Note: method with signature ‘Book#ANY’ chosen for function ‘pair’,
#>  target signature ‘Book#Book’.
#>  "ANY#Book" would also be valid
#> [1] "Book, ANY"
pair(lamp, lamp)
#> [1] "Book, ANY"
pair(ab, lamp)
#> Note: method with signature ‘ANY#Book’ chosen for function ‘pair’,
#>  target signature ‘AudioBook#Book’.
#>  "Book#ANY" would also be valid
#> [1] "ANY, Book"
# A method for the pair (Book, Book) removes the ambiguity.
setMethod("pair", signature("Book", "Book"), function(x, y) "Book, Book")
pair(ab, lamp)
#> [1] "Book, Book"

The note is worth recognising: it names the method that was chosen, the target signature of the call, and the other candidates. callNextMethod() can reintroduce the ambiguity, as the help page of callNextMethod() explains, so a method for the exact pair should avoid it and call a specific function instead.

Working with S3 classes

S3 objects have no definition for S4 to inspect, so using one in a signature or as a slot class means telling R about it. setOldClass("book") registers an S3 class under its name; for a class vector, setOldClass(c("ebook", "book")) registers the inheritance too. After that the class can appear in signatures, as a slot class, or in a class union, and is() reports its relationships. The help for setOldClass() explains that many widely used S3 classes of the standard distribution come pre-defined (the list .OldClassesList of the methods package includes Date, POSIXct, POSIXlt, difftime, rle, density and the model classes mlm, aov and anova), so they need no explicit registration.

A script can see the registration matter. A method for "book" accepts the S3 book of the scenario; an object of the S3 subclass ebook is refused with unable to find an inherited method, because R does not know ebook extends book until setOldClass(c("ebook", "book")) says so. The chunk also defines a conversion with setAs(): as(old, "Book") turns an S3 book into an S4 Book by calling the constructor, and the reverse conversion, for which no setAs() exists, fails with the message no method or default for coercing “Book” to “book”. Without the call to setOldClass(), setMethod() on an unregistered class prints a message of the form in method for ‘who’ with signature ‘"book"’: no definition for class “book” but still stores the method, and an S3 object whose first class is that name dispatches to it; the registration is what makes inheritance work.

# S3 classes take part in S4 dispatch once they are registered.
setOldClass("book")
setGeneric("title_of", function(x) standardGeneric("title_of"))
#> [1] "title_of"
setMethod("title_of", "book", function(x) x$title)
setMethod("title_of", "Book", function(x) x@title)
old <- book("Ten Winters", "Priya Raman", "9780013001331", 2022, "Fiction")
title_of(old)
#> [1] "Ten Winters"
title_of(lamp)
#> [1] "The Lamp at Noon"
is(old, "book")
#> [1] TRUE

# A subclass of an S3 class is known only once its whole class vector is.
ebook <- structure(unclass(old), class = c("ebook", "book"))
msg(title_of(ebook))
#> [1] "unable to find an inherited method for function ‘title_of’ for signature ‘x = \"ebook\"’"
setOldClass(c("ebook", "book"))
title_of(ebook)
#> [1] "Ten Winters"
is(ebook)
#> [1] "ebook"    "book"     "oldClass"

# setAs() defines what as(x, "Book") does for an S3 book.
setAs("book", "Book", function(from) {
  Book(from$title, from$author, from$isbn, from$year, from$genre, from$pages)
})
as(old, "Book")
#> <Book> Ten Winters (Priya Raman, 2022)
class(as(old, "Book"))
#> [1] "Book"
#> attr(,"package")
#> [1] ".GlobalEnv"
msg(as(lamp, "book"))
#> [1] "no method or default for coercing “Book” to “book”"

An S4 object can use S3 generics and methods: S3 dispatch follows the S4 class inheritance of the object. format.Book() is found for an AudioBook, inherits() knows the hierarchy (inherits(ab, c("Item", "AudioBook", "Book"), which = TRUE) returns the position of each name in the class vector), and .class2() gives the full class vector used for dispatch. One exception is printing: the autoprinter of an S4 object calls show(), so an S3 print.Book() is seen only by an explicit print() call. Objects of an S3 class are not S4 (isS4() is FALSE), although is.object() is TRUE for both. The Methods_for_S3 help page is the reference for the interplay.

setClass("Item", contains = "VIRTUAL")
setClass("Book",
  contains = "Item",
  slots = c(title = "character", year = "integer")
)
setClass("AudioBook", contains = "Book", slots = c(minutes = "integer"))
ab <- new("AudioBook", title = "Notes on Gravity", year = 2017L, minutes = 735L)

# S3 dispatch follows the S4 inheritance of the object.
format.Book <- function(x, ...) paste0("Book: ", x@title)
format(ab)
#> [1] "Book: Notes on Gravity"
inherits(ab, "Book")
#> [1] TRUE
inherits(ab, c("Item", "AudioBook", "Book"), which = TRUE)
#> [1] 3 1 2
.class2(ab)
#> [1] "AudioBook" "Book"      "Item"

# The autoprinter of an S4 object calls show(); an explicit print() call
# dispatches S3 methods, so a print.Book() would only be seen there.
print.Book <- function(x, ...) { # nolint: object_name_linter.
  cat("S3 print method\n")
}
ab
#> An object of class "AudioBook"
#> Slot "minutes":
#> [1] 735
#>
#> Slot "title":
#> [1] "Notes on Gravity"
#>
#> Slot "year":
#> [1] 2017
#>
print(ab)
#> S3 print method
rm(print.Book)

# An S4 object has a class attribute, so is.object() is TRUE; isS4() tells the
# two systems apart.
isS4(ab)
#> [1] TRUE
is.object(ab)
#> [1] TRUE
isS4(structure(list(), class = "book"))
#> [1] FALSE

Classes that contain a basic type

A class that extends a basic type or an S3 class gets a data part, visible as the pseudo-slot .Data, and its objects have the type of the basic class. Tagged below contains "character": its objects have type character, length() and toupper() work on the data, and toupper() returns a Tagged again, with the slot kept. A class with only slots has type "S4". R 4.4.0 renamed the C-level type S4SXP to OBJSXP to allow other object systems to use the same type, as the R Internals manual describes it ("S4 and other objects which do not consist solely of a simple type"); typeof() still reports "S4" for an object with the S4 bit set and reports "object" for an object that has the type but not the bit, as the S7 0.2.2 object in the script does. In other words the type of a classical S4 object did not change in 4.4.0, only the name of the C type behind it.

# An S4 object with only slots has the type "S4" ...
setClass("Plain", slots = c(a = "numeric"))
typeof(new("Plain"))
#> [1] "S4"

# ... but a class that contains a basic type takes that type: the data part.
setClass("Tagged", contains = "character", slots = c(tag = "character"))
t1 <- new("Tagged", c("a", "b"), tag = "t")
typeof(t1)
#> [1] "character"
t1@.Data
#> [1] "a" "b"
length(t1)
#> [1] 2
toupper(t1)
#> An object of class "Tagged"
#> [1] "A" "B"
#> Slot "tag":
#> [1] "t"
#>
is(t1, "character")
#> [1] TRUE

setClass("Handler", contains = "function", slots = c(name = "character"))
typeof(new("Handler", function(x) x, name = "identity"))
#> [1] "closure"

# OBJSXP (R 4.4.0) is the SEXP type behind both S4 objects and objects of other
# object systems. typeof() reports "S4" when the S4 bit is set and "object"
# when it is not, as for an S7 object (S7 0.2.2).
library(S7)
Pamphlet <- new_class( # nolint: object_name_linter.
  "Pamphlet",
  properties = list(title = class_character)
)
leaflet <- Pamphlet(title = "Opening hours")
typeof(leaflet)
#> [1] "object"
isS4(leaflet)
#> [1] FALSE

Methods for existing functions

setMethod("toupper", "Book", ...) needs no setGeneric() first: setMethod() calls it. When the function belongs to another package, is not primitive, and does not dispatch S3 methods, R prints a message the first time setMethod() is called, Creating a generic function for ‘toupper’ from package ‘base’ in the global environment, and creates the generic with the original as default method. That is why toupper("unchanged") still works. The message goes to stderr, not stdout, and appears in the output of the script because the scripts were run with both streams merged.

Functions that already dispatch S3 methods, such as summary(), format() and print(), take another route. summary() is an S3 generic written with UseMethod(), and the help page of setGeneric() lists that case among the ones where no message is printed; setMethod("summary", "Shelf", ...) just registers the S4 method. Primitive functions such as length(), [[ and the arithmetic operators need no generic created at all (the R Internals manual explains that dispatch for primitives piggybacks on S3 dispatch in C code, and that it works for the primitives that are internally S3 generic). The help page ?Methods_for_Nongenerics describes the choices and why creating a generic for a function that lives in another package is, in general, something to do in the package that defines methods and not at the console.

# Methods for functions that are not generic yet: setMethod() creates an
# implicit generic from the base function and says so (the message goes to
# stderr).
setMethod("toupper", "Book", function(x) {
  x@title <- toupper(x@title)
  x
})
#> Creating a generic function for ‘toupper’ from package ‘base’ in the global environment
toupper(lamp)
#> <Book> THE LAMP AT NOON (Le Guin, Ursula K., 1999)
toupper("unchanged")
#> [1] "UNCHANGED"
isGeneric("toupper")
#> [1] TRUE
existsMethod("toupper", "ANY")
#> [1] TRUE

Accessors and replacement generics

Slots are an implementation detail that a package may want to change, and @ bypasses everything: it neither validates nor normalises. A common convention in S4 packages, and the style of the Bioconductor ones, is to expose slots through accessor generics: a getter isbn(x) and a setter isbn<-, a replacement function in the sense of the base language (see Functions). With S4 the pair is two generics, and the setter’s method is defined with setReplaceMethod("isbn", ...), shorthand for a setMethod() call on the generic named isbn<-. The setter’s last argument must be called value. A setter can do what @<- cannot: here it normalises the ISBN, and then calls validObject() so that an invalid ISBN is refused at the point where it is set.

# Accessor generics: the getter and the replacement function (a generic whose
# name ends in "<-") validate and normalise, which a plain @<- does not.
setGeneric("isbn", function(x) standardGeneric("isbn"))
#> [1] "isbn"
setGeneric("isbn<-", function(x, value) standardGeneric("isbn<-"))
#> [1] "isbn<-"
setMethod("isbn", "Book", function(x) x@isbn)
setReplaceMethod("isbn", "Book", function(x, value) {
  x@isbn <- normalise_isbn(value) # nolint: object_usage_linter.
  validObject(x)
  x
})
rivers <- Book(
  "Rivers of Glass", "Tomas Arden", year = 2012, genre = "Non-fiction",
  pages = 352
)
isbn(rivers)
#> [1] NA
msg(isbn(rivers) <- "978-0-00-700073-0")
#> [1] "invalid class “Book” object: invalid ISBN-13: 9780007000730"
isbn(rivers) <- "978-0-00-700073-9"
isbn(rivers)
#> [1] "9780007000739"
isbn(ab) <- "978-0-01-000104-4"
isbn(ab)
#> [1] "9780010001044"

The accessor is inherited by AudioBook without any extra code. Note the difference with the first validity chunk: b@year <- 3000L was accepted, whereas a setter that calls validObject() refuses the invalid ISBN. It is also the place to maintain invariants between slots, and the place where a later version of the class can move a slot without breaking callers. slot() and slot<- (documented with ?slot) are the programmatic form, for code that gets the slot name as a string.

Group generics

Groups of related functions can share one method. ?S4groupGeneric lists the groups: Arith (+, -, *, ^, %%, %/%, /), Compare (==, >, <, !=, <=, >=), Logic (&, |), their union Ops, Math (abs, sqrt, exp, cumsum and the rest of the group), Math2 (round, signif), Summary (max, min, range, prod, sum, any, all) and Complex. A method defined for the group applies to each member that has no method of its own for the same signature: the help page states that group methods are selected before inherited methods but after directly specified ones, so a method for + alone still wins over one for Arith. The method finds out which member was called with .Generic, or calls it with callGeneric(). getGroupMembers() lists the members of a group. The members are basic functions, many of them primitives, that are not S4 generic by default, but formal methods can still be defined for them individually and through the groups; two members of Math, log and trunc, have an extra ... argument that a group method cannot see, so they need a method of their own to be called with it. The S3 version of group generics, with its own conventions, is in S3 Group Generics and Operator Overloading.

The Compare method for two books orders them by year; callGeneric(e1@year, e2@year) calls whichever of the six operators was used, on the years. The Summary method for the shelf, in the next section, is the other group used by the model.

# A group generic covers a whole family of operators with one method. Compare
# contains ==, >, <, !=, <= and >=; callGeneric() calls the operator at hand.
getGroupMembers("Compare")
#> [1] "==" ">"  "<"  "!=" "<=" ">="
getGroupMembers("Ops")
#> [1] "Arith"   "Compare" "Logic"
setMethod("Compare", signature("Book", "Book"), function(e1, e2) {
  callGeneric(e1@year, e2@year)
})
lamp < paper
#> [1] TRUE
lamp > paper
#> [1] FALSE
lamp == lamp
#> [1] TRUE

A collection class

A shelf holds books, and a class for the collection is where S4’s validity and its generics for existing functions come together. Shelf has one slot, a list, and a validity function that checks that every element is a Book. The scenario’s books table is then converted row by row into Book objects. Fifteen of the sixteen rows are accepted: the blank ISBN of "Rivers of Glass" is treated as an absent ISBN (the NA of the class), and titles with stray blanks are trimmed by the initializer; the row of "Orchard Logic" is refused with the real validity message, because its check digit is wrong. The rest of the script uses the shelf.

# A collection class: a Shelf holds a list of books, all of them Book objects.
setClass("Shelf",
  slots = c(books = "list"),
  validity = function(object) {
    ok <- vapply(object@books, is, logical(1), "Book")
    if (all(ok)) TRUE else paste("element", which(!ok), "is not a Book")
  }
)
msg(new("Shelf", books = list(lamp, "not a book")))
#> [1] "invalid class “Shelf” object: element 2 is not a Book"

# Build the books of the scenario; the rows that fail report why.
built <- lapply(seq_len(nrow(books)), \(i) {
  r <- books[i, ]
  isbn <- if (nzchar(r$isbn)) r$isbn else NA_character_
  tryCatch(
    Book(r$title, r$author, isbn, r$year, r$genre, r$pages),
    error = \(e) conditionMessage(e)
  )
})
ok <- vapply(built, is, logical(1), "Book")
sum(ok)
#> [1] 15
unlist(built[!ok])
#> [1] "invalid class “Book” object: invalid ISBN-13: 9780011001129"
shelf <- new("Shelf", books = built[ok])

Methods for length(), [[ and show() make the shelf behave like a collection: they are methods for primitive and generic functions, and nothing needs to be created first (the show() method is registered on the generic of the methods package). summary() of the shelf counts books by genre after lower-casing, since the data has "Fiction", "fiction" and "Fiction ", and the trimming in the Book initializer removed the trailing blank. A call to summary(c(1, 2, 3, 4)) still works because the original function is the default method.

setMethod("length", "Shelf", function(x) length(x@books))
setMethod("[[", "Shelf", function(x, i, ...) x@books[[i]])
setMethod("show", "Shelf", function(object) {
  cat("<Shelf>", length(object), "books\n")
  for (b in head(object@books, 3L)) show(b)
  if (length(object) > 3L) cat("...\n")
})
length(shelf)
#> [1] 15
shelf[[2]]
#> <Book> Pride and Prejudice (Jane Austen, 1813)
shelf
#> <Shelf> 15 books
#> <Book> The Dispossessed (Ursula K. Le Guin, 1974)
#> <Book> Pride and Prejudice (Jane Austen, 1813)
#> <Book> the quiet streets (Mara Quill, 2019)
#> ...
# summary() is an S3 generic, so no message is printed for it: the S4 method is
# registered on the existing function.
setMethod("summary", "Shelf", function(object, ...) {
  genre <- vapply(object@books, \(b) tolower(b@genre), character(1))
  table(genre)
})
summary(shelf)
#> genre
#>         classic         fiction         mystery     non-fiction          poetry
#>               2               4               2               2               2
#> science fiction
#>               3
summary(c(1, 2, 3, 4))
#>    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
#>    1.00    1.75    2.50    2.50    3.25    4.00

The Summary group generic fits a collection naturally. A method for "Shelf" that extracts the years and calls callGeneric(years, ..., na.rm = na.rm) covers max(), min(), range() and the other members at once, so range(shelf, na.rm = TRUE) is the oldest and newest publication year, and without na.rm = TRUE the result is NA because one book has no year, as for an ordinary vector.

# Summary is the group generic of max(), min(), range(), sum() and friends.
getGroupMembers("Summary")
#> [1] "max"   "min"   "range" "prod"  "sum"   "any"   "all"
# nolint start: object_name_linter.
setMethod("Summary", "Shelf", function(x, ..., na.rm = FALSE) {
  years <- vapply(x@books, \(b) b@year, integer(1))
  callGeneric(years, ..., na.rm = na.rm)
})
# nolint end
min(shelf, na.rm = TRUE)
#> [1] 1813
range(shelf, na.rm = TRUE)
#> [1] 1813 2023
max(shelf)
#> [1] NA

The whole model in one picture

The figure shows the classes of this page as a Mermaid class diagram. Item is the virtual root, Book implements it with its six slots and the two methods defined for it, AudioBook adds narrator and minutes, Shelf holds a list of books, and Loan uses the class unions characterOrNULL and DateOrNULL (shown as the one union characterOrNULL here, with the date one in the slot type). The stereotypes in double angle brackets are written as HTML entities in the source of the diagram, since the browser would otherwise take <<interface>>-style text for an HTML tag.

classDiagram class Item { <<virtual class>> } class Book { <<S4 class>> title : character author : character isbn : character year : integer genre : character pages : integer show() pages_per_year() } class AudioBook { <<S4 class>> narrator : character minutes : integer } class characterOrNULL { <<class union>> } class Shelf { <<S4 class>> books : list } class Loan { <<S4 class>> loan_id : character isbn : character member : character loan_date : Date return_date : DateOrNULL note : characterOrNULL } Item <|-- Book Book <|-- AudioBook Shelf o-- Book Loan --> characterOrNULL : note

Finding out what is defined

The tools for looking inside S4 are the reason the system is practical to debug. They answer five kinds of question.

  • Is it generic? isGeneric("name"). Which generics exist: getGenerics().

  • Which methods does a generic have? showMethods("name") lists the signatures; with classes = "AudioBook" it lists every generic that has a method for that class, directly or by inheritance. Entries marked (inherited from: ...) are cached selections, so the listing grows as the session dispatches more calls. methods(class = "Book") from utils includes S4 methods in a compact listing.

  • Is there a method for exactly this signature? existsMethod("name", "Class"). For a method that would be found, directly or by inheritance, hasMethod().

  • What is the method? getMethod("name", "Class") returns the method for exactly that signature, or fails with no method found for function ... and signature ...; selectMethod() returns the method that dispatch would choose, including an inherited one or a default.

  • What will happen if I remove it? removeMethod() removes a method and returns TRUE, and a second call returns FALSE with a warning; removeClass() removes a class definition.

All of them are documented on ?getMethod (with existsMethod, hasMethod and selectMethod), ?showMethods and ?GenericFunctions (with isGeneric and getGenerics), and the method found by getMethod() can be printed with its Signatures: target is the signature it was asked for and defined the signature of the method found. Printed methods in Rscript have no source references, so they show the deparsed code (x@minutes/2 for x@minutes / 2).

# Looking at what is defined.
isGeneric("pages_per_year")
#> [1] TRUE
showMethods("pages_per_year")
#> Function: pages_per_year (package .GlobalEnv)
#> x="AudioBook"
#> x="Book"
#>
existsMethod("pages_per_year", "Book")
#> [1] TRUE
hasMethod("pages_per_year", "AudioBook")
#> [1] TRUE
getMethod("pages_per_year", "AudioBook")
#> Method Definition:
#>
#> function (x, ...)
#> {
#>     if (is.na(x@pages) && !is.na(x@minutes)) {
#>         x@pages <- as.integer(round(x@minutes/2))
#>     }
#>     callNextMethod()
#> }
#>
#> Signatures:
#>         x
#> target  "AudioBook"
#> defined "AudioBook"
class(selectMethod("pages_per_year", "AudioBook"))
#> [1] "MethodDefinition"
#> attr(,"package")
#> [1] "methods"
msg(getMethod("pages_per_year", "Shelf"))
#> [1] "no method found for function 'pages_per_year' and signature Shelf"
showMethods(classes = "AudioBook")
#> Function: coerce (package methods)
#> from="AudioBook", to="Book"
#> from="AudioBook", to="Item"
#>
#> Function: coerce<- (package methods)
#> from="AudioBook", to="Book"
#>
#> Function: initialize (package methods)
#> .Object="AudioBook"
#>
#> Function: isbn (package .GlobalEnv)
#> x="AudioBook"
#>     (inherited from: x="Book")
#>
#> Function: isbn<- (package .GlobalEnv)
#> x="AudioBook"
#>     (inherited from: x="Book")
#>
#> Function: matches (package .GlobalEnv)
#> x="AudioBook", y="Book"
#>     (inherited from: x="Book", y="Book")
#>
#> Function: pages_per_year (package .GlobalEnv)
#> x="AudioBook"
#>
#> Function: pair (package .GlobalEnv)
#> x="AudioBook", y="Book"
#>     (inherited from: x="Book", y="Book")
#>
#> Function: show (package methods)
#> object="AudioBook"
#>
methods(class = "Book")
#>  [1] coerce         coerce<-       Compare        initialize     isbn
#>  [6] isbn<-         matches        pages_per_year pair           show
#> [11] title_of       toupper
#> see '?methods' for accessing help and source code

Classes and generics are objects too, stored in the environment where they were defined under hidden names. The R Internals manual says the class definition is kept in an object named .__C__ followed by the class name, and the methods of each generic in a "methods table" named .__T__ followed by the generic and its package, which is why neither shows in ls() without all.names = TRUE. The listing of the script shows both kinds, for the classes of the model and for the generics it extended (show:methods, length:base, summary:base, toupper:base).

# Classes and method tables are objects with hidden names in the environment
# where they were defined (R Internals: S4 classes and S4 methods).
grep("^[.]__", ls(all.names = TRUE), value = TRUE)
#>  [1] ".__C__AudioBook"                 ".__C__book"
#>  [3] ".__C__Book"                      ".__C__ebook"
#>  [5] ".__C__Item"                      ".__C__Shelf"
#>  [7] ".__T__[[:base"                   ".__T__coerce:methods"
#>  [9] ".__T__Compare:methods"           ".__T__initialize:methods"
#> [11] ".__T__isbn:.GlobalEnv"           ".__T__isbn<-:.GlobalEnv"
#> [13] ".__T__length:base"               ".__T__matches:.GlobalEnv"
#> [15] ".__T__pages_per_year:.GlobalEnv" ".__T__pair:.GlobalEnv"
#> [17] ".__T__show:methods"              ".__T__summary:base"
#> [19] ".__T__Summary:base"              ".__T__title_of:.GlobalEnv"
#> [21] ".__T__toupper:base"

Debugging S4 methods

The R FAQ answer to "How can I debug S4 methods?" is trace() with signature=: the tracer is inserted in the method that will be dispatched for that signature. trace() accepts an expression or function to run on entry (tracer), prints the call unless print = FALSE, and untrace() with the same signature removes it. The NEWS of R 4.5.0 adds that debug() and debugonce() also accept a string naming an S4 generic, and fixes debugonce() with a signature= for a method with a simple body. The general debugging tools are in Debugging.

# FAQ 8.5: trace() with signature= instruments the method that dispatch picks.
trace(
  "pages_per_year", signature = "Book",
  tracer = quote(cat("entering the Book method\n")), print = FALSE
)
#> [1] "pages_per_year"
pages_per_year(Book("Loud Roofs", year = 2021, pages = 198))
#> entering the Book method
#> [1] 39.6
untrace("pages_per_year", signature = "Book")
pages_per_year(Book("Loud Roofs", year = 2021, pages = 198))
#> [1] 39.6

Last, removing methods and classes is possible and useful in a long interactive session, but it is not something a package does. The last chunk removes a method (a later call on a pair that depended on it fails with the dispatch error), repeats the removal to show the warning, and removes the Shelf class.

# Removing a method (and a class) is possible, mostly useful while developing.
removeMethod("matches", signature("Book", "ANY"))
#> [1] TRUE
msg(matches(lamp, 1L))
#> [1] "unable to find an inherited method for function ‘matches’ for signature ‘x = \"Book\", y = \"integer\"’"
withCallingHandlers(
  removeMethod("matches", signature("Book", "ANY")),
  warning = \(w) {
    cat("warning:", conditionMessage(w), "\n")
    invokeRestart("muffleWarning")
  }
)
#> warning: no method found for function ‘matches’ and signature "Book", "ANY"
#> [1] FALSE
removeClass("Shelf")
#> [1] TRUE
isClass("Shelf")
#> [1] FALSE

S4 pitfalls collected

The first group is about what S4 objects are not. They are not lists, so $, [ and [[ are not defined, and arithmetic, as.character(), cat() and paste() have no meaning for them until a method gives them one; length() of a plain S4 object is 1; c(b, b) makes a list. S4 objects have value semantics: assignment copies and b2@year <- 1L does not change b, unlike the Reference Class and R6 objects of Reference Classes and R6. The messages are the real ones of R 4.6.1.

setClass("Book", slots = c(title = "character", year = "integer"))
b <- new("Book", title = "Loud Roofs", year = 2021L)
msg <- \(expr) tryCatch(expr, error = \(e) conditionMessage(e))

# S4 objects are not lists: the usual operators say so.
msg(b$title)
#> [1] "$ operator not defined for this S4 class"
msg(b[1])
#> [1] "object of type 'S4' is not subsettable"
msg(b[["title"]])
#> [1] "this S4 class is not subsettable"
msg(b + 1)
#> [1] "non-numeric argument to binary operator"
msg(as.character(b))
#> [1] "no method for coercing this S4 class to a vector"
msg(cat(b))
#> [1] "argument 1 (type 'S4') cannot be handled by 'cat'"
length(b)
#> [1] 1
class(c(b, b))
#> [1] "list"

# Assignment copies: S4 objects have value semantics, like vectors.
b2 <- b
b2@year <- 1L
b@year
#> [1] 2021

The second group is about initialize(). A method for initialize() is called for an empty object during conversion and validation, so it must work when called with only .Object. The method below insists on title, a mistake that works until the class gets a subclass: the validity check of StrictSub converts the object to Strict (the code of validObject() calls as(object, superClass)), which builds an empty Strict through initialize(), and the failure is reported as argument "title" is missing, with no default from a call that looks unrelated to the method. The fix is the pattern of the earlier chunk: inspect what was supplied and act on it.

# An initialize() method that insists on an argument breaks new("Book") and,
# with it, everything that builds an empty object first: here the validity
# check of a subclass, which converts the object to its superclass.
setClass("Strict",
  slots = c(title = "character"),
  validity = function(object) {
    if (length(object@title) == 1L && nzchar(object@title)) TRUE else "no title"
  }
)
# nolint start: object_name_linter.
setMethod("initialize", "Strict", function(.Object, ..., title) {
  callNextMethod(.Object, ..., title = trimws(title))
})
# nolint end
msg(new("Strict"))
#> [1] "argument \"title\" is missing, with no default"
setClass("StrictSub", contains = "Strict", slots = c(note = "character"))
msg(new("StrictSub", title = "a", note = "n"))
#> [1] "argument \"title\" is missing, with no default"

The third group is evaluation. All the arguments in the signature of a generic are evaluated before dispatch, even those a method does not use; arguments outside the signature keep the lazy evaluation of ordinary functions (see Lazy Evaluation and Promises). The effect is visible in the second argument of a generic: until a method mentions it in a signature the argument is inactive and stays lazy (the help page says trailing arguments are inactive until a method uses them), and the moment a method does, the argument is evaluated, with the message error in evaluating the argument 'y' in selecting a method for function 'lazy': y was evaluated. setGeneric(..., signature = "x") keeps y lazy for good, which matters for generics with arguments that are used symbolically, such as the expression of with().

# Arguments in the signature of a generic are evaluated before dispatch, even
# if the method never looks at them; arguments outside the signature stay lazy.
setGeneric("lazy", function(x, y) standardGeneric("lazy"))
#> [1] "lazy"
setMethod("lazy", "Book", function(x, y) "one-argument method ran")
msg(lazy(b, stop("y was evaluated")))
#> [1] "one-argument method ran"
setMethod("lazy", signature("Book", "numeric"), function(x, y) "two arguments")
msg(lazy(b, stop("y was evaluated")))
#> [1] "error in evaluating the argument 'y' in selecting a method for function 'lazy': y was evaluated"

setGeneric("lazy_x", function(x, y) standardGeneric("lazy_x"), signature = "x")
#> [1] "lazy_x"
setMethod("lazy_x", "Book", function(x, y) "y was never evaluated")
msg(lazy_x(b, stop("y was evaluated")))
#> [1] "y was never evaluated"

The last group is valueClass. A generic can declare the class of its result, and the check is real, but it adds a requirement that every method written later must satisfy; most code leaves it out.

# valueClass= checks the class of the result, which is rarely worth it.
setGeneric(
  "n_chars", function(x) standardGeneric("n_chars"),
  valueClass = "numeric"
)
#> [1] "n_chars"
setMethod("n_chars", "Book", function(x) as.character(nchar(x@title)))
msg(n_chars(b))
#> [1] "invalid value from generic function ‘n_chars’, class “character”, expected “numeric”"

Other traps, in the form of a short list:

  • Redefining a class with setClass() replaces the definition silently and keeps the methods, but an object created before the change does not have the new slots: in a test, setClass("A", slots = c(x = "numeric")), then a <- new("A", x = 1), then a second setClass("A", slots = c(x = "numeric", y = "character")) makes validObject(a) fail with slots in class definition but not in object: "y". Re-create the objects after changing a class in an interactive session.

  • A class that contains = "numeric" (or another basic type) has the type of that class, typeof() is no longer "S4" and the object participates in the arithmetic of the type; a class can contain at most one basic type.

  • The slot names class and Class are not allowed, and names that begin with a dot have special meanings, so slot names should start with a letter.

  • A generic function is identified by its name and the package it belongs to (?Methods_Details). Two packages that each call setGeneric() with the same name but for different base functions define two unrelated generics, which is how clashes between packages appear; in a package the generic of another package is imported and its methods are added, not redefined (see the next section).

  • slot<- with check = FALSE can build objects that violate their class; validObject() is the audit.

  • R FAQ 8.5 is the only entry of the FAQ that is about S4.

S4 in packages

Most S4 code lives in packages, and most package problems with S4 are NAMESPACE problems. Writing R Extensions, section 1.5.6 "Namespaces with S4 classes and methods", is the authority. This section follows it, and a cut-down version of the Book model was turned into a small package (a shelfS4 package with Book, AudioBook, a generic and a show method, installed into a temporary library and loaded) to check the directives and the error messages on R 4.6.1.

DESCRIPTION: methods and Collate

The manual says that a package using formal classes and methods "should have" Depends: methods in its DESCRIPTION, together with import(methods) or importFrom(methods, ...) in the NAMESPACE; a footnote adds that Imports: methods may suffice but that package code is little exercised without the methods package on the search path and may not be fully robust to that. The NEWS agrees with both halves: R 4.4.0 made the methods package "more robust to not being attached" and made R CMD check note S4-style exports without a strong dependence on it, and R 4.5.0 changed an internal function of methods to use requireNamespace() instead of require(), so loading class definitions no longer attaches packages. The roxygen2 example below uses Imports: methods and @import methods, which is what the check package for this page used and installed without complaint; the manual’s Depends form is the conservative choice.

Class definitions are evaluated in the order of the files in R/, which is alphabetical by default, and a class must exist when a subclass, a slot or a method refers to it. That is what the Collate field of DESCRIPTION controls ("a Collate field can be used for controlling the collation order" says Writing R Extensions). A package that puts AudioBook in audiobook.R and Book in book.R is collated audiobook.R first and fails to install with the real message below; the cure is to list the files in dependency order. roxygen2 writes the field itself from @include tags (the roxygen2 section below shows it).

Package: shelfS4
Title: S4 Classes for a Library Shelf
Version: 0.0.1
Description: Book, AudioBook and Shelf classes with validity checking and
    show methods.
License: MIT + file LICENSE
Encoding: UTF-8
Imports: methods
Collate:
    'book.R'
    'audiobook.R'
    'shelf.R'

With Collate: 'audiobook.R' 'book.R' in the same package, R CMD INSTALL stops while it prepares the package code for lazy loading, and the output ends with these lines (the temporary paths are left out):

** byte-compile and prepare package for lazy loading
Error in reconcilePropertiesAndPrototype(name, slots, prototype, superClasses,  :
  no definition was found for superclass “Book” in the specification of class “AudioBook”
Error: unable to load R code in package ‘shelfS4’
Execution halted
ERROR: lazy loading failed for package ‘shelfS4’

The same applies to files that define methods for a generic defined in another file: the generic has to come first. In a package with several files, a layout that many S4 packages use is AllClasses.R (every setClass() and setValidity()), AllGenerics.R (every setGeneric()), then one file of methods per class, with a Collate: field listing them in that order.

NAMESPACE directives

The NAMESPACE is where S4 differs most from plain packages, because classes and methods are not functions that export() can name. The directives from section 1.5.6 are:

Directive What it does

import(methods) or importFrom(methods, ...)

Makes the methods functions available to the package code; required by R CMD check for any package that uses S4.

exportClasses(Book, AudioBook)

Exports the class definitions. Every class used outside the package must be listed; exportClassPattern() exports by regular expression (and defaults to the same pattern as exportPattern()).

exportMethods(show, pages_per_year)

Exports the methods the package defines for the named generics. Exporting the methods of a generic from the package also exports the generic when the package owns it; exporting a generic exports its methods.

export(Book, pages_per_year)

Exports ordinary objects: the constructor function and a generic that the package defines itself (this is "more natural" for a local generic, and required for an implicit generic created without any methods).

importClassesFrom(pkg, ...)

Imports named classes of another package when the whole namespace is not imported.

importMethodsFrom(pkg, ...)

Imports the generics, and their methods, of another package for the named functions.

A NAMESPACE for the model is short. exportMethods(show) exports the show method for Book and AudioBook, and the manual’s remark that importMethodsFrom() also imports any generics defined in that namespace for the methods is what makes importMethodsFrom(shelfS4, pages_per_year) enough for a package that only extends the generic.

import(methods)
export(Book, pages_per_year)
exportClasses(Book, AudioBook, Shelf)
exportMethods(show, pages_per_year, length, summary)

Two points are easy to miss. A generic from another package should be imported and extended, not redefined: the manual advises using the generics that stats4 exports (coef, vcov, …​) in preference to importing coef from stats and creating a second generic, because "this enables multiple packages to unambiguously set methods on those generics". And methods for primitive functions (length, [[, +) are always exported by the machinery whether or not they appear in exportMethods(), as the R Internals manual says ("Methods for primitives which are internally S4 generic are always exported"), so the line for length above is for clarity.

R 4.1.0 made R CMD check warn when a package exports an S4 class or method that does not exist, and R 4.4.0 added the note about the dependence on methods already mentioned. A package whose tests pass can still be missing a class from exportClasses(), so the check that matters is loading it in a fresh session and trying new() on each class that is meant to be public (the model was loaded and checked this way).

roxygen2

roxygen2 generates all of this from comments (see its article "Documenting S4" for the full list of supported calls; it points out that, unlike S3 and S7 methods, all S4 methods must be documented). @slot name description documents a slot in the class’s help page, @export before setClass() writes exportClasses() and before setMethod() writes exportMethods(), @export before setGeneric() writes export(), @exportClass, @exportMethod, @importClassesFrom and @importMethodsFrom are the explicit forms, and @include other.R writes the Collate field. @import methods in a package-level block writes import(methods). The documentation of S4 classes follows the topic naming of the Rd conventions (Book-class for a class, show,Book-method for a method), so class?Book and methods?show find it. roxygen2 8.1.1, which wrote the files below in the check package, records its version in Config/roxygen2/version.

#' A book on the shelf
#'
#' @slot title Title, a character string of length one.
#' @slot year Year of publication, an integer of length one.
#' @slot pages Number of pages, an integer of length one.
#' @export
setClass("Book", slots = c(title = "character", year = "integer",
                           pages = "integer"))

#' Pages per year
#'
#' @param x An object.
#' @param ... Passed to methods.
#' @return A number.
#' @export
setGeneric("pages_per_year", function(x, ...) standardGeneric("pages_per_year"))

#' @rdname pages_per_year
#' @export
setMethod("pages_per_year", "Book", function(x, ...) {
  x@pages / max(2026L - x@year, 1L)
})

#' @include book.R
NULL

#' An audio book
#' @slot minutes Running time in minutes.
#' @export
setClass("AudioBook", contains = "Book", slots = c(minutes = "integer"))

The generated NAMESPACE (with the @import methods block in R/shelfS4-package.R) and the Collate: field that @include produced were these. The Collate: field lists book.R before audiobook.R because of the @include book.R tag, and the package-level file comes last.

# Generated by roxygen2: do not edit by hand

export(Book)
export(pages_per_year)
exportClasses(AudioBook)
exportClasses(Book)
exportMethods(pages_per_year)
exportMethods(show)
import(methods)

Collate:
    'book.R'
    'audiobook.R'
    'shelfS4-package.R'

Documenting S4 classes and methods for R CMD check follows the conventions of Writing R Extensions section 2.1.3: an \alias of the form class-class for a class and generic,signature_list-method for a method (with ANY for unspecified arguments), \S4method{generic}{signature}(arguments) in a \usage section when a method’s usage needs to be spelled out, and promptClass() and promptMethods() to generate skeletons. A package with methods for functions defined elsewhere is responsible for documenting its methods but not the function or its default method.

Rscript, scripts and the methods package

Scripts run with Rscript have methods attached (since R 3.5.0, see the start of the page), so setClass() works without library(methods). When methods is not attached, as with --default-packages=base or with R_SCRIPT_LEGACY=yes set, the functions are not found by name, and the call has to go through the namespace: methods::setClass() works but new() and isVirtualClass() need the prefix too. Neither the commands nor the failures are specific to S4, but they are the first thing to check when a script fails with could not find function "setClass". The commands and outputs below were run on R 4.6.1.

Rscript -e 'cat("package:methods" %in% search(), "\n")'
# TRUE
R_SCRIPT_LEGACY=yes Rscript -e 'cat("package:methods" %in% search(), "\n")'
# FALSE
Rscript --default-packages=base -e 'setClass("A", slots = c(x = "numeric"))'
# Error in setClass("A", slots = c(x = "numeric")) :
#   could not find function "setClass"
# Execution halted
Rscript --default-packages=base -e 'methods::setClass("A", slots = c(x = "numeric")); new("A", x = 1)'
# Error in new("A", x = 1) : could not find function "new"
# Execution halted

The option --default-packages, the environment variable R_SCRIPT_DEFAULT_PACKAGES and the environment variable R_DEFAULT_PACKAGES choose the packages loaded at start-up, in that order of precedence. The NEWS of R 4.4.0 and 4.5.0 made the machinery that finds class definitions less dependent on methods being on the search path, but a script that calls new() or setClass() by their short names still needs it attached. A script run by Rscript therefore needs no library(methods) line.

Bioconductor and the Matrix package

S4 is the system of the Bioconductor project (https://bioconductor.org/). Its core data structures are S4 classes, among them the packages S4Vectors (low-level classes for vector-like and table-like data that other packages build on), GenomicRanges (ranges on a genome) and SummarizedExperiment (a matrix of assays with row and column annotations). The style of those packages is the one of this page: a constructor function named like the class, accessor generics instead of direct slot access, validity methods and show methods. Reading their source is a good source of idioms; the packages of CRAN and the tidyverse that do not use S4 are equally valid R, and the choice of system is the subject of OOP Overview and Base Types.

The recommended package Matrix (version 1.7-5 ships with R 4.6.1) is the most widely used S4 package of the base distribution. Matrix::Matrix() returns an object of a class chosen from a hierarchy of virtual classes (Matrix, sparseMatrix, …​) and concrete ones such as dgCMatrix, a sparse double matrix in compressed column format, and the usual S4 tools apply to it. The script builds a 0/1 matrix of which member borrowed which book from the loans table and then asks it the class questions of this page. isVirtualClass("Matrix") is TRUE, is() follows the hierarchy (a dgCMatrix is a sparseMatrix is a Matrix), and slotNames("dgCMatrix") lists the compressed-column representation (i, p, x, Dim, Dimnames, factors).

# Matrix ships with R as a recommended package and is written with S4 classes.
library(Matrix)
packageVersion("Matrix") >= "1.7.0"
#> [1] TRUE

# Which books were borrowed by which member? A sparse 0/1 matrix.
source("scenario.R")
complete <- nzchar(loans$member) & nchar(loans$isbn) == 13L
pairs <- unique(loans[complete, c("member", "isbn")])
pairs$member <- trimws(tolower(pairs$member))
pairs <- unique(pairs)
members <- sort(unique(pairs$member))
isbns <- sort(unique(pairs$isbn))
borrowed <- sparseMatrix(
  i = match(pairs$member, members), j = match(pairs$isbn, isbns),
  x = 1, dimnames = list(members, isbns)
)
dim(borrowed)
#> [1]  8 15
class(borrowed)
#> [1] "dgCMatrix"
#> attr(,"package")
#> [1] "Matrix"
isS4(borrowed)
#> [1] TRUE
is(borrowed, "sparseMatrix")
#> [1] TRUE
is(borrowed, "Matrix")
#> [1] TRUE
isVirtualClass("Matrix")
#> [1] TRUE
isVirtualClass("sparseMatrix")
#> [1] TRUE
isVirtualClass("dgCMatrix")
#> [1] FALSE
slotNames("dgCMatrix")
#> [1] "i"        "p"        "Dim"      "Dimnames" "x"        "factors"
length(borrowed@x) == sum(borrowed != 0)
#> [1] TRUE

A worked example: stats4::mle()

The stats4 package ships with R and is the smallest complete example of S4 in the standard distribution: it defines classes (mle, summary.mle, profile.mle), a constructor function (mle()) and S4 methods for generics such as coef, vcov, logLik, confint, update and profile (from stats), summary (from base) and show (from methods). Its NAMESPACE is the one that Writing R Extensions quotes in section 1.5.6, so the package-side rules of the previous section can be read in a real package. The example fits a model to the shelf scenario’s loan durations. The loans that were returned have a number of days out between loan and return; the model assumes the durations are normally distributed, and the maximum likelihood estimates of the mean and the standard deviation are the sample mean and the standard deviation with divisor n. (The Poisson model would be the natural choice for a count of days, but the data are overdispersed, the variance of the durations being about three and a half times their mean, so the normal model is the better demonstration.)

mle(minuslogl, start, method, lower, upper, nobs, ...) takes the negative log-likelihood as a function whose arguments are the parameters; their defaults serve as the start values when start is missing (the help page says so), and the optimiser is optim(), with method = "L-BFGS-B" when bounds are given. The covariance matrix is the inverse of the Hessian at the optimum. The standard deviation must stay positive, so the fit uses a lower bound of 0.01 for sigma and -Inf for mu, and nobs is passed so that BIC() and nobs() work. The estimates from the optimiser agree with the closed form to the precision shown after round(); the script rounds every printed number because the last digits of an iterative optimiser may differ between platforms.

source("scenario.R")
library(stats4)

# How long were the returned books out? One count of days per loan.
returned <- loans[!is.na(loans$return_date) & !is.na(loans$loan_date), ]
days <- as.integer(returned$return_date - returned$loan_date)
days <- days[days >= 0L]
length(days)
#> [1] 51
round(c(mean = mean(days), sd = sd(days)), 3)
#>   mean     sd
#> 20.333  8.585

# The negative log-likelihood of a normal model for the number of days.
nll <- function(mu = 20, sigma = 8) {
  -sum(dnorm(days, mean = mu, sd = sigma, log = TRUE))
}
fit <- mle(nll, method = "L-BFGS-B", lower = c(-Inf, 0.01), nobs = length(days))

The object is an instance of an S4 class defined in stats4, isS4() is TRUE, and the class is not virtual. is(fit) is just "mle" (it extends nothing). getSlots("mle") shows what the fit keeps: the call, the estimated and full coefficient vectors, the fixed parameters, the covariance matrix, the minimum of the negative log-likelihood, the optimiser’s details, the function itself, nobs and the method. The summary has its own class, summary.mle, with three slots.

# The result is an S4 object of a class defined in stats4.
class(fit)
#> [1] "mle"
#> attr(,"package")
#> [1] "stats4"
isS4(fit)
#> [1] TRUE
isVirtualClass("mle")
#> [1] FALSE
is(fit)
#> [1] "mle"
getSlots("mle")
#>        call        coef    fullcoef       fixed        vcov         min
#>  "language"   "numeric"   "numeric"   "numeric"    "matrix"   "numeric"
#>     details   minuslogl        nobs      method
#>      "list"  "function"   "integer" "character"
slotNames("summary.mle")
#> [1] "call"   "coef"   "m2logL"

coef(), vcov(), logLik(), AIC(), BIC() and nobs() all work on the fit. The first three are methods of S4 generics created from stats functions and exported by stats4; AIC() and BIC() are stats generics whose default methods use logLik() and nobs(). R 4.6.0 changed those default methods to check whether the methods namespace is loaded rather than whether stats4 is, with the NEWS adding that they still use stats4::logLik and stats4::nobs and that packages no longer need to load stats4 explicitly; the same release made confint.default() work on S4 objects as long as suitable coef() and vcov() methods exist. The estimate and the closed form agree: the mean 20.33 and the divide-by-n deviation 8.5. The covariance matrix is diagonal for a normal model, with the variance of the mean estimated by sigma^2 / n (1.417 in the output) and that of the deviation by sigma^2 / (2 * n) (0.708).

# coef(), vcov(), logLik(), AIC(), BIC() and nobs() are generics with methods
# for "mle"; the estimates are the sample mean and the divide-by-n deviation.
round(coef(fit), 2)
#>    mu sigma
#> 20.33  8.50
round(sqrt(mean((days - mean(days))^2)), 2)
#> [1] 8.5
round(vcov(fit), 3)
#>          mu sigma
#> mu    1.417 0.000
#> sigma 0.000 0.708
round(logLik(fit), 2)
#> 'log Lik.' -181.51 (df=2)
round(AIC(fit), 2)
#> [1] 367.03
nobs(fit)
#> [1] 51

summary() is a method too: it returns a summary.mle object whose slots are the coefficient table with standard errors and the deviance (-2 log L, slot m2logL).

# summary() has a method too and returns an object of class summary.mle.
s <- summary(fit)
class(s)
#> [1] "summary.mle"
#> attr(,"package")
#> [1] "stats4"
round(s@coef, 2)
#>       Estimate Std. Error
#> mu       20.33       1.19
#> sigma     8.50       0.84
round(s@m2logL, 2)
#> [1] 363.03

The generics belong to stats; the methods belong to stats4. showMethods("coef") lists the signatures, including the "mle" and "summary.mle" methods that stats4 registered, and existsMethod() confirms the methods for "mle". This is the structure that the package section described: a package extends a generic it does not own with exportMethods(coef) and imports the generic it uses.

# The generics come from stats; stats4 adds the methods.
showMethods("coef")
#> Function: coef (package stats)
#> object="ANY"
#> object="mle"
#> object="summary.mle"
#>
isGeneric("coef")
#> [1] TRUE
existsMethod("coef", "mle")
#> [1] TRUE
existsMethod("vcov", "mle")
#> [1] TRUE

Last, confint() profiles the likelihood (it computes the profile with profile() and prints a message while it works) and returns 95 percent intervals. The interval for mu is close to the Wald interval that vcov() implies, and the one for sigma is visibly asymmetric, because the profile does not assume symmetry. The call is rounded to one decimal for the same reason as the others.

# confint() profiles the likelihood and reports its progress as a message.
round(confint(fit), 1)
#> Profiling...
#>       2.5 % 97.5 %
#> mu     18.0   22.7
#> sigma   7.1   10.5

Version notes

The changes below were found in the NEWS files of the R releases (the 3.x entries in the separate NEWS.3 file, the 4.x ones in the release NEWS) and, where it is stated, checked against R 4.6.1.

Version Change Source

R 3.0.0

setClass() gets the slots argument, "clearer and less ambiguous" than representation; the help pages now list representation, access, version and S3methods as deprecated.

NEWS 3.0.0, ?setClass

R 3.5.0

Rscript uses the same default packages as R, so methods is attached; R_SCRIPT_LEGACY=yes restores the old behaviour (checked: still honoured by 4.6.1).

NEWS 3.5.0

R 4.0.0

The plot() generic moves to base; packages that define S4 generics for plot() must be re-installed.

NEWS 4.0.0

R 4.1.0

R CMD check warns when a package exports S4 classes or methods that do not exist; show() of a hidden S4 generic prints better.

NEWS 4.1.0

R 4.2.0

setAs() finds the right class when several packages define a class with the same name.

NEWS 4.2.0

R 4.3.3

Arguments are forwarded correctly to methods of S4 generics with ... in the middle of the formal arguments.

NEWS 4.3.3

R 4.4.0

The internal type S4SXP is renamed OBJSXP, to allow other object systems; typeof() of an S4 object is still "S4" (checked on 4.6.1), and "object" for an OBJSXP object without the S4 bit, as S7 objects are. sort() and zapsmall() become implicit S4 generics. The error for a failed S4 dispatch names the signature arguments. R CMD check notes S4-style exports without a strong dependence on methods. The methods package is more robust when it is not attached.

NEWS 4.4.0

R 4.5.0

The deprecated MethodsList functions and the "MethodsList" class are removed from methods. debug() and debugonce() accept a string naming an S4 generic. .requirePackage() no longer attaches packages.

NEWS 4.5.0

R 4.6.0

The default AIC() and BIC() methods check for the methods namespace instead of stats4; confint.default() works on S4 objects with coef() and vcov() methods; the S4 method for norm(<ANY>, type = <missing>) uses type = "O".

NEWS 4.6.0

R 4.6.1

as() coerces S4 objects to S4 superclasses that extend old-style classes correctly, instead of returning only the S3 part.

NEWS 4.6.1

The 3.x entries are in the NEWS.3 file. The detail that surprises readers who have seen "object" in discussions of R 4.4.0 is the typeof() row: the renaming of the C type did not change what typeof() reports for a classical S4 object, and the checked value on R 4.6.1 is "S4" (see the data part section above for the script). Compose(), Funcall() and Partial() of R-devel have nothing to do with S4 and are not used here.

Where older tutorials differ

Many S4 tutorials were written for R 2.x or 3.x, and the code still runs, which is how their habits survive.

Older tutorial Current practice Why Version

setClass("Book", representation(title = "character"))

setClass("Book", slots = c(title = "character")) and contains =

slots is clearer and representation() is listed as deprecated in ?setClass

R 3.0.0

setClass(...) return value ignored, and new("Book", ...) in every call

Book <- setClass(...) and Book(...), or a hand-written constructor function

the generator records the package of the class and reads better; a constructor is the place for defaults and conversion

?setClass, value

Reading and writing slots with @ everywhere

Accessor generics and replacement generics (isbn(x), isbn(x) <- value) for the public interface

@<- checks the class of the value and nothing else; an accessor can validate and normalise

always

A print method (print.Book or setMethod("print", "Book", ...)) to control how an object displays

setMethod("show", "Book", ...)

the autoprinter of an S4 object calls show(); print() of an S4 object calls it too

always

library(methods) at the top of every script

nothing

Rscript attaches methods since R 3.5.0 (earlier Rscript did not, which is where the habit comes from)

R 3.5.0

Expecting validity to run on every assignment

Call validObject() after changes made with @<-, or assign through an accessor that does

validity runs only in initialize() and in validObject()

always

setGeneric("area", function(shape) standardGeneric("area"), valueClass = "numeric")

setGeneric("area", function(shape) standardGeneric("area"))

valueClass adds an error for methods that return another class and little else

always

prototype = prototype(...) and validity = for everything

an initialize() method and a separate setValidity() call

the setClass() help page recommends both for flexibility and clarity

?setClass

A method that insists on all its arguments in initialize()

a method that accepts being called with none

R builds empty objects when it converts or validates

always

Declaring everything an S4 class "to be safe"

Use S4 where multiple dispatch or formal validation earns its keep; S3, Reference Classes, R6 or S7 elsewhere

see the comparison in OOP Overview and Base Types

always

The rows about representation() and Rscript are the ones that cause confusion in practice, because old code works and the new text looks different.

Where to go next