OOP Overview and Base Types

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 base R (the base, methods, utils and stats packages that ship with R). The comparison of the five object systems also runs two CRAN packages, R6 2.6.1 and S7 0.2.2, and the "who uses what" example loads three more: Matrix 1.7-5 (a recommended package that ships with R), ggplot2 4.0.3 and shiny 1.14.0. The lintr 3.4.0 default linters were used only to check the scripts. R has more than one answer to the question "what is this object?". The first answer is its type, the way the interpreter stores it; the second is its class, the label that method dispatch looks at; and on top of those sit five different object systems that use the class label in five different ways. This page separates the vocabularies (typeof(), mode(), storage.mode(), class()), explains the base machinery that every system builds on (the class attribute, inherits(), is.object(), primitives and internal generics) and then compares S3, S4, Reference Classes, R6 and S7 on the same one-field object.

Every example is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines that start with #> are what it printed. The scripts also pass the default lintr linters. The names books, shelf_today and the book() constructor come from the running library-shelf scenario described on the section index: a script that needs them starts with source("scenario.R"). Where a message contains quotation marks, they are the quotation marks R printed in the UTF-8 locale the scripts ran in. The details of each system have their own pages: S3 Classes and Generics, S3 Group Generics and Operator Overloading, S4 Classes and Methods, Reference Classes and R6 and The S7 Object System; this page is the map that tells you which one to open.

Objects, types and classes

The R Language Definition starts its chapter on objects with the statement that R gives no direct access to memory: it provides a number of specialised data structures called objects, referred to through symbols, and the symbols are objects too. The function typeof() returns the type of an object. Underneath, every object is a pointer to the same C structure (SEXPREC), and the different R data types are values of the C type SEXPTYPE, which decides how the rest of the structure is used (R-lang: Objects). Four questions about an object are easy to confuse, so it helps to name them once:

Question Function Answer

What is it made of?

typeof(x)

One of about two dozen internal types: "double", "list", "closure", "environment" and so on.

What kind of thing is it, in the language of the S book?

mode(x) and storage.mode(x)

A coarser grouping of the type, kept for compatibility with S: "numeric" covers both integers and doubles, "function" covers closures and primitives.

What does method dispatch call it?

class(x), oldClass(x), .class2(x)

A character vector: the class attribute if there is one, otherwise an implicit class derived from the type and the dimensions.

Is it an "object" in the object-oriented sense?

is.object(x)

TRUE when the object carries a class attribute (the internal OBJECT bit is set); this is what internal generics test before they try to dispatch.

R is deliberately relaxed about the relationship between these questions. A "class" in R is not a declaration that the compiler checks; it is a character vector stored as an attribute (or computed on the fly) and read by whichever generic function happens to be called. The R Language Definition says as much in its chapter on object-oriented programming: R does not have a full-fledged object-oriented system, but a class system and a mechanism for dispatching on the class of an object, and the authors caution users to apply it in a straightforward manner (R-lang: Object-oriented programming). The rest of this page works through what that means, from the bottom (types) to the top (the five systems).

The SEXPTYPE table

R Internals lists the internal types in a table of 24 values: types 0 to 10 and 13 to 25 are in use, and 11 and 12, which were once used for internal factors and ordered factors, have been withdrawn. The numbers are stored in saved objects, so the gap cannot be reused. The table below summarises it and adds what typeof() returns for each type (the strings come from the table in the Language Definition, which also marks the types a user cannot easily get hold of, and from the typeof() help page). R Internals names the first entry NILSXP; it is the NULL object, and the manual does not use the spelling NULLSXP.

SEXPTYPE No. typeof() What it is

NILSXP

0

"NULL"

NULL

SYMSXP

1

"symbol"

symbols (names)

LISTSXP

2

"pairlist"

pairlists; mostly internal, used for argument lists

CLOSXP

3

"closure"

functions written in R: formals, body, environment

ENVSXP

4

"environment"

environments

PROMSXP

5

"promise"

promises, the implementation of lazy evaluation

LANGSXP

6

"language"

calls, including formulae

SPECIALSXP

7

"special"

primitive functions that receive their arguments unevaluated

BUILTINSXP

8

"builtin"

primitive functions that receive evaluated arguments

CHARSXP

9

"char"

internal strings, the elements of a character vector (rarely visible)

LGLSXP

10

"logical"

logical vectors

INTSXP

13

"integer"

integer vectors

REALSXP

14

"double"

numeric (double) vectors

CPLXSXP

15

"complex"

complex vectors

STRSXP

16

"character"

character vectors

DOTSXP

17

"..."

the dot-dot-dot object

ANYSXP

18

"any"

a type that matches all types; there are no objects of this type

VECSXP

19

"list"

lists (generic vectors)

EXPRSXP

20

"expression"

expression vectors, most often the result of parse()

BCODESXP

21

"bytecode"

compiled byte code

EXTPTRSXP

22

"externalptr"

external pointers

WEAKREFSXP

23

"weakref"

weak references

RAWSXP

24

"raw"

raw vectors

OBJSXP

25

"object" or "S4"

objects that do not consist solely of a simple type such as a vector or a function; S4 objects are the classical case

Two points of the table are worth remembering. First, typeof() reports the vector type "double" for what R documentation calls "numeric", and "character" for STRSXP: the SXP names are the C names, and the R-level strings are different. Second, functions come in three types, CLOSXP, SPECIALSXP and BUILTINSXP, which the manual sometimes lumps into a pseudo-type FUNSXP with code 99 when a type is stored in an integer; the last two are the primitives that the section on primitives below explains. The script builds one object for each type that an R program can reach and prints what typeof() says.

library(S7)

# The SEXPTYPEs that an R program can reach, with typeof() of an example.
reachable <- c(
  NILSXP = "NULL", SYMSXP = "quote(x)", LISTSXP = "pairlist(a = 1)",
  CLOSXP = "function(x) x", ENVSXP = "globalenv()",
  LANGSXP = "quote(f(x))", SPECIALSXP = "`if`", BUILTINSXP = "sum",
  LGLSXP = "TRUE", INTSXP = "1L", REALSXP = "1", CPLXSXP = "1i",
  STRSXP = "'a'", VECSXP = "list(1)", EXPRSXP = "expression(x)",
  EXTPTRSXP = "new('externalptr')", RAWSXP = "as.raw(1)",
  OBJSXP = "new_class('K')()"
)

data.frame(
  sexptype = names(reachable),
  example = unname(reachable),
  typeof = vapply(
    reachable, \(code) typeof(eval(str2lang(code))), character(1L),
    USE.NAMES = FALSE
  )
)
#>      sexptype            example      typeof
#> 1      NILSXP               NULL        NULL
#> 2      SYMSXP           quote(x)      symbol
#> 3     LISTSXP    pairlist(a = 1)    pairlist
#> 4      CLOSXP      function(x) x     closure
#> 5      ENVSXP        globalenv() environment
#> 6     LANGSXP        quote(f(x))    language
#> 7  SPECIALSXP               `if`     special
#> 8  BUILTINSXP                sum     builtin
#> 9      LGLSXP               TRUE     logical
#> 10     INTSXP                 1L     integer
#> 11    REALSXP                  1      double
#> 12    CPLXSXP                 1i     complex
#> 13     STRSXP                'a'   character
#> 14     VECSXP            list(1)        list
#> 15    EXPRSXP      expression(x)  expression
#> 16  EXTPTRSXP new('externalptr') externalptr
#> 17     RAWSXP          as.raw(1)         raw
#> 18     OBJSXP   new_class('K')()      object

The types that are missing from the output are the ones a user is not meant to hold: CHARSXP, PROMSXP, DOTSXP, ANYSXP, BCODESXP and WEAKREFSXP. Promises appear indirectly (every function argument is one, see Lazy Evaluation and Promises), ... is reachable through helpers such as ...length(), and compiled byte code is hidden behind the closure that owns it. The last row is the newest: the S7 class built in the script is an OBJSXP object, and typeof() returns "object" for it. The section on OBJSXP below returns to that.

typeof(), mode(), storage.mode() and class()

The next script asks all four questions about twenty objects, one per row. The objects are written as code strings and evaluated with str2lang() so that the first column shows what was asked. Two of the objects come from the shelf scenario (shelf_today and the data frame books); the factor, the formula and the matrix stand for classes that base R and stats define.

source("scenario.R")

# One object per row, written as the code that makes it.
codes <- c(
  "NULL", "TRUE", "1L", "2.5", "1i", "'a'", "as.raw(1)", "list(1)",
  "quote(x)", "quote(x + 1)", "expression(x)", "sum", "`if`", "mean",
  "globalenv()", "matrix(1:4, 2)", "factor('a')", "shelf_today", "books",
  "y ~ x"
)

describe_object <- function(code) {
  x <- eval(str2lang(code))
  c(
    typeof = typeof(x),
    mode = mode(x),
    storage.mode = storage.mode(x),
    class = paste(class(x), collapse = "/")
  )
}

kinds <- t(vapply(codes, describe_object, character(4L)))
noquote(kinds)
#>                typeof      mode        storage.mode class
#> NULL           NULL        NULL        NULL         NULL
#> TRUE           logical     logical     logical      logical
#> 1L             integer     numeric     integer      integer
#> 2.5            double      numeric     double       numeric
#> 1i             complex     complex     complex      complex
#> 'a'            character   character   character    character
#> as.raw(1)      raw         raw         raw          raw
#> list(1)        list        list        list         list
#> quote(x)       symbol      name        symbol       name
#> quote(x + 1)   language    call        language     call
#> expression(x)  expression  expression  expression   expression
#> sum            builtin     function    function     function
#> `if`           special     function    function     function
#> mean           closure     function    function     function
#> globalenv()    environment environment environment  environment
#> matrix(1:4, 2) integer     numeric     integer      matrix/array
#> factor('a')    integer     numeric     integer      factor
#> shelf_today    double      numeric     double       Date
#> books          list        list        list         data.frame
#> y ~ x          language    call        language     formula

Reading the table row by row shows where the four vocabularies diverge:

  • Integers and doubles share the mode "numeric" and differ in typeof() and storage.mode(). In the S language both were "numeric" and the storage mode distinguished them; the manual says storage.mode() is generally used when calling code in another language such as C or Fortran, to make sure an R object has the data type the routine expects (R-lang: Objects).

  • Functions have one mode, "function", and three types: "closure" for mean, "builtin" for sum and "special" for if. The class of all three is "function".

  • Symbols have the type "symbol" but the mode "name" (and the class "name"); calls have the type "language" and the mode and class "call". The help page adds that some calls have the mode "(", which is S-compatible.

  • Attributes change the class but not the type. The matrix is still an integer vector; the factor is still an integer vector with attributes; the Date is still a double; the data frame is still a list; and the formula is still a call. This is why typeof() is the question to ask when you want to know how the object is stored, and class() the question when you want to know how it will be treated.

mode() and storage.mode() also have replacement forms. mode(x) <- "character" changes the mode through the matching as. function, so it is only supported for modes that have one, and attributes are preserved. storage.mode(x) <- "double" is the more efficient primitive version, which works for the internal types listed under typeof(). The mode "single" is a pseudo-mode: setting it records the attribute "Csingle" and the real storage mode stays "double". The pitfalls section below shows both replacement forms in a script.

The help page for typeof() says the possible values are the entries of a table in the R sources and lists those that a user is likely to see: the vector types, "NULL", "closure", "special", "builtin", "environment" and "S4", plus the rarer "symbol", "pairlist", "promise", "object", "language", "char", "...", "any", "expression", "externalptr", "bytecode" and "weakref". Note that "object" and "S4" are both in the list; the section on OBJSXP shows when each appears.

Implicit class and the dispatch class

An object without a class attribute still has a class: the implicit class. class(x) computes it, and the help page lists the rules. A matrix gets "matrix" and "array", an array gets "array", a function "function", NULL gets "NULL", and other objects get the result of typeof() (which the help page says is similar to mode()), with "integer" and "double" reported as "integer" and "numeric". For calls the class is "call", except that there are extra classes for the special calls if, for, while, (, {, <- and =.

m <- matrix(1:4, 2)
class(m)
#> [1] "matrix" "array"
oldClass(m)
#> NULL
.class2(m)
#> [1] "matrix"  "array"   "integer" "numeric"
inherits(m, "matrix")
#> [1] TRUE
inherits(m, "integer")
#> [1] FALSE
is.object(m)
#> [1] FALSE

The matrix has no class attribute (oldClass() is NULL, is.object() is FALSE), yet class(m) has two elements. This is the R 4.0.0 change: the NEWS entry says matrix objects now also inherit from the class "array", so that class(diag(1)) is c("matrix", "array"), and that this invalidates code that wrongly assumed class(matrix_obj) has length one; it adds that S3 methods for class "array" are now dispatched for matrix objects (R NEWS: Changes in R 4.0.0). inherits(m, "matrix") is TRUE, because inherits() works on class(x), but inherits(m, "integer") is FALSE: the implicit class used for dispatch is longer than class() says, and inherits() does not use it.

# The implicit class of other objects without a class attribute.
class(array(1:8, c(2L, 2L, 2L)))
#> [1] "array"
class(1L)
#> [1] "integer"
class(2.5)
#> [1] "numeric"
class(sum)
#> [1] "function"
class(NULL)
#> [1] "NULL"
class(quote(x + 1))
#> [1] "call"
class(quote(x <- 1))
#> [1] "<-"
class(quote(if (a) b))
#> [1] "if"
.class2(1L)
#> [1] "integer" "numeric"
.class2(2.5)
#> [1] "double"  "numeric"
.class2(sum)
#> [1] "function"

The longer vector is .class2(), a function R 4.0.0 added, whose NEWS entry calls it the full character vector of class names used for S3 method dispatch. The help page says it is for didactic, diagnostic or debugging use, and that using it for other reasons may be a misuse. For a matrix of integers it returns "matrix", "array", "integer", "numeric": a method for numeric can serve a matrix of integers when none for matrix, array or integer exists. For an integer it returns "integer", "numeric", and for a double "double", "numeric". The next script writes five methods for one generic and shows which one answers for which object.

# S3 dispatch walks .class2(x), then falls back to the default method.
describe <- function(x) UseMethod("describe")
describe.integer <- function(x) "integer method"
describe.numeric <- function(x) "numeric method"
describe.matrix <- function(x) "matrix method"
describe.function <- function(x) "function method"
describe.default <- function(x) "default method"

describe(1L)
#> [1] "integer method"
describe(2.5)
#> [1] "numeric method"
describe(m)
#> [1] "matrix method"
describe(sum)
#> [1] "function method"
describe("a")
#> [1] "default method"

describe(1L) is answered by the integer method and describe(2.5) by the numeric method, because "double" has no method and "numeric" is next in the implicit class; deleting describe.integer would make the integer use the numeric method too. The matrix finds describe.matrix before it finds anything for integer, and a character string, whose implicit class is "character", falls through to describe.default. A note from the help page is worth keeping in mind: UseMethod() dispatches on the class returned by class() with these interpolated implicit classes, whereas group generics dispatch on oldClass() for efficiency, and internal generics dispatch only on objects for which is.object() is true. The details of dispatch are the subject of S3 Classes and Generics.

The class attribute: oldClass(), is.object(), inherits() and unclass()

When an object does carry a class attribute, the attribute is the class. The functions that read and write it are small, and each one answers a different question. The script below uses the book from the shelf scenario, which is a list of six length-one fields with the class "book".

source("scenario.R")

dispossessed <- book(
  "The Dispossessed", "Ursula K. Le Guin", "978-0-306-40615-7",
  1974, "Science fiction", 387
)

typeof(dispossessed)
#> [1] "list"
mode(dispossessed)
#> [1] "list"
class(dispossessed)
#> [1] "book"
oldClass(dispossessed)
#> [1] "book"
is.object(dispossessed)
#> [1] TRUE
inherits(dispossessed, "book")
#> [1] TRUE
inherits(dispossessed, "list")
#> [1] FALSE
is.list(dispossessed)
#> [1] TRUE
  • class(x) and oldClass(x) agree for an object with a class attribute. They differ for implicit classes, as the matrix showed: oldClass() returns only the attribute and gives NULL otherwise. Both, together with their replacement forms, .class2(), unclass() and is.object(), are primitive functions; inherits() and isa() are not.

  • is.object(x) is TRUE for the book, because the class attribute is set, and FALSE for the matrix and for every plain vector. S4 objects have a class attribute too, so the OBJECT bit is set for them as well (R Internals: S4 objects). The primitive functions that are internal generics look at this bit and do nothing when it is not set, so an object has to be an "object" before the object systems can see it.

  • inherits(x, "list") is FALSE even though the type is "list" and is.list(x) is TRUE. inherits() asks about the class vector, not about the type. A book is a list in the sense of storage and not in the sense of class, and inherits(x, "list") would be TRUE only if "list" were in the class vector.

  • unclass(x) returns a copy without the class attribute, which is how a method looks at the underlying structure: validate_book() in the scenario starts with unclass(x) for that reason. It is not allowed for objects that cannot be copied, namely environments and external pointers.

# What the type and the class say about the unclassed list.
plain <- unclass(dispossessed)
typeof(plain)
#> [1] "list"
class(plain)
#> [1] "list"
is.object(plain)
#> [1] FALSE
names(plain)
#> [1] "title"  "author" "isbn"   "year"   "genre"  "pages"

After unclass() the object is a plain list: class() returns the implicit class "list", is.object() is FALSE, and the six field names are visible. Nothing about the data changed; only the label did. That is the whole mechanism of S3: objects are ordinary vectors, lists or functions, and the label decides which functions treat them specially.

inherits(), its which argument and isa()

inherits(x, what, which = FALSE) returns TRUE if any of the names in what matches any element of class(x). With which = TRUE it returns an integer vector as long as what, in which each element is the position in class(x) of the matching class and 0 means no match. That is how to find out how far down the inheritance chain a class sits. The sibling isa(x, what) is stricter: it is TRUE only if all elements of what are contained in class(x) (for an S4 object it uses is()).

# inherits() with which = TRUE gives the position in the class vector.
x <- structure(list(), class = c("student", "person"))
inherits(x, c("person", "student", "teacher"), which = TRUE)
#> [1] 2 1 0
inherits(x, c("person", "student", "teacher"))
#> [1] TRUE
inherits(x, "student", which = FALSE)
#> [1] TRUE
isa(x, c("student", "person"))
#> [1] TRUE
isa(x, "person")
#> [1] TRUE

The first call returns 2 1 0: "person" is the second class of x, "student" the first and "teacher" is not there. The NEWS file contains no change to how which or missing matches behave, so the semantics are the ones in the help page. The one change to inherits() in recent versions is the what argument, described next.

inherits() with a class object: nameOfClass() (R 4.3.0)

R 4.3.0 let what be something other than a character vector. A new generic, nameOfClass(), is called to resolve the class name from what; the NEWS entry says this supports experimenting with alternative object systems (R NEWS: Changes in R 4.3.0). An S7 class object is the practical example: inherits(obj, Class) works with the class object, not only with its name.

library(S7)

book_s7 <- new_class("BookS7", properties = list(title = class_character))
s7_book <- book_s7(title = "Ten Winters")

# R 4.3.0: inherits() accepts a non-character `what` through nameOfClass().
inherits(s7_book, "BookS7")
#> [1] TRUE
inherits(s7_book, book_s7)
#> [1] TRUE
nameOfClass(book_s7)
#> [1] "BookS7"
nameOfClass("BookS7")
#> NULL

# A class object of your own can take part by defining the method.
shelf_class <- structure(list(name = "book"), class = "shelf_class")
nameOfClass.shelf_class <- function(x) x$name
inherits(structure(list(), class = "book"), shelf_class)
#> [1] TRUE

The S7 package defines a nameOfClass() method for its class objects, so inherits(s7_book, book_s7) is TRUE; the generic returns NULL when it has no method for its argument, and inherits() then reports an error for a what that is neither a character vector nor an object with such a method. The last call shows that a class object of your own can take part by defining one S3 method. The same release added two other hooks, chooseOpsMethod() for resolving two competing methods of an Ops group generic and the S3 generic @ operator; they are covered in S3 Group Generics and Operator Overloading and The S7 Object System.

Assigning a class

class(x) <- value and oldClass(x) <- value set the attribute; assigning NULL or an empty character vector removes it. Assigning a class does not check anything about the data, with a few exceptions that R enforces because the class would otherwise lie about the structure.

x <- 1:6
class(x) <- "tally"
class(x)
#> [1] "tally"
typeof(x)
#> [1] "integer"
is.object(x)
#> [1] TRUE
inherits(x, "integer")
#> [1] FALSE
is.integer(x)
#> [1] TRUE

# Without a method, the class attribute is only printed as an attribute.
x
#> [1] 1 2 3 4 5 6
#> attr(,"class")
#> [1] "tally"
unclass(x)
#> [1] 1 2 3 4 5 6

# Assigning NULL or an empty vector removes the class attribute.
class(x) <- NULL
class(x)
#> [1] "integer"
is.object(x)
#> [1] FALSE

# Some assignments are checked, or change the object.
y <- 1:6
tryCatch(class(y) <- "matrix", error = conditionMessage)
#> [1] "invalid to set the class to matrix unless the dimension attribute is of length 2 (was 0)"
class(y) <- "numeric"
typeof(y)
#> [1] "integer"
z <- NULL
tryCatch(class(z) <- "tally", error = conditionMessage)
#> [1] "attempt to set an attribute on NULL"

An integer vector with the class "tally" is still an integer (is.integer() is TRUE), but inherits(x, "integer") is FALSE, because its class vector no longer contains "integer"; without a method for "tally", printing falls back to the default and shows the attribute. class(y) <- "matrix" is refused when the dimension attribute does not have length 2, and NULL cannot hold attributes at all. The help page also says that for classes with a formal (S4) definition, directly replacing the class this way is strongly deprecated: as(object, value) is the way to coerce an object to a class.

Primitives and internal functions

Most of the functions that ship with R are written in R. Typing mean shows its source, a one-line call to UseMethod("mean"), and that is what R FAQ 7.40 means when it says that in most cases typing the name of a function prints its source code, with the exception of code that is hidden in a namespace or compiled (R FAQ 7.40). A smaller set of functions are not written in R at all: they are the C code compiled into the interpreter and called directly, and they print as .Primitive("name"). The Language Definition calls them builtin objects and special forms, and notes that, from the R language, they are just another kind of function that is.primitive() can tell apart from interpreted functions (R-lang: Builtin objects and special forms).

sum
#> function (..., na.rm = FALSE)  .Primitive("sum")
typeof(sum)
#> [1] "builtin"
is.primitive(sum)
#> [1] TRUE
formals(sum)
#> NULL
body(sum)
#> NULL
environment(sum)
#> NULL
args(sum)
#> function (..., na.rm = FALSE)
#> NULL

sum prints as function (..., na.rm = FALSE) .Primitive("sum") because there is no R code to print: the line is the signature R keeps for documentation and argument matching, followed by the marker. Everything an R function has is absent. formals() and body() return NULL because a primitive has neither, environment() returns NULL because it was not created in an environment, and args() is the supported way to see its interface: it returns a closure with the same formals and a NULL body. is.primitive(x) is defined as typeof(x) being either "builtin" or "special".

Builtin or special

The two types differ in one thing only: when the arguments are evaluated. A builtin has its arguments evaluated and passed to the C code, in accordance with call-by-value; a special receives the unevaluated arguments and decides for itself, which is necessary for the language constructs, the assignment operators, && and ||, quote(), substitute(), missing() and on.exit(). Subsetting and subassignment operators and @ are specials too (for both extraction and replacement forms, $ and @ take a symbol argument, and [ and [[ allow missing arguments). R Internals stresses that being special or builtin is a separate property from being primitive or .Internal: quote is a special primitive, + is a builtin primitive, cbind is a special .Internal and grep is a builtin .Internal (R Internals: Argument evaluation).

`if`
#> .Primitive("if")
typeof(`if`)
#> [1] "special"
typeof(`*`)
#> [1] "builtin"
typeof(`[`)
#> [1] "special"
typeof(quote)
#> [1] "special"

Closures for comparison

mean is a closure: it has formals, a body and an environment, and environmentName() shows it lives in the base namespace. A closure can be compiled to byte code (its printed form then shows a bytecode line and an environment line with an address, which this page does not reproduce), but it remains a "closure" to typeof().

# A closure: written in R, with formals, a body and an environment.
typeof(mean)
#> [1] "closure"
is.primitive(mean)
#> [1] FALSE
names(formals(mean))
#> [1] "x"   "..."
body(mean)
#> UseMethod("mean")
environmentName(environment(mean))
#> [1] "base"

.Primitive() and .Internal()

Two functions give access to the C table behind all this. .Primitive(name) looks a primitive up by name, and the help page says it is almost never used: the backquoted name, or get(name, envir = baseenv()), does the same and does not depend on knowing which functions are primitive, which changes as R evolves. .Internal(call) performs a call to internal code that is built into the interpreter. Its help page tells readers that only R developers can add to the list of internal functions and that using the function is a job for experts; it is itself a primitive, as the script shows. You cannot set attributes on primitive functions.

# .Primitive() looks a primitive up by name, and .Internal is itself one.
identical(.Primitive("sum"), sum)
#> [1] TRUE
typeof(.Internal)
#> [1] "special"
tryCatch(structure(sum, note = "mine"), error = conditionMessage)
#> [1] "cannot set an attribute on a 'builtin'"

# A closure that wraps .Internal(): the call is visible in the body.
internal_line <- grep(".Internal", deparse(body(grepl)), fixed = TRUE)
trimws(deparse(body(grepl))[internal_line])
#> [1] ".Internal(grepl(as.character(pattern), x, ignore.case,"

R Internals explains the design: C code compiled into R is called either directly, as a primitive, or through the .Internal interface, which is very similar to .External except in syntax. R keeps a table (R_FunTab in the source file src/main/names.c) of R function names and C functions, which says how many arguments each takes, whether they are evaluated before the call, and whether the function is "internal", reachable only through .Internal, or directly accessible, in which case it prints as .Primitive. Functions that use .Internal() wrapped in a closure are generally preferred, because the closure ensures standard handling of named and default arguments and allows R code to run before the C code: grepl() above converts a factor or a number to character in R, then calls .Internal(grepl(...)). Primitives are the exceptions, used for convenience and efficiency, and because some are needed for basic operations (.Internal itself must be a primitive). Primitives make no use of R code, so they differ from ordinary functions in argument matching: positional matching is important for call, switch, .C and .subset, to avoid partial matching of the first argument, and the arithmetic, comparison and logical operators discard argument names when called as functions.

Which functions are primitive is not stable. The manual’s list "is subject to change", and it contains the language elements and operators ({, (, if, for, while, function, [, [[, $, @, the assignment operators and the arithmetic, comparison and logic operators), the one-argument mathematical functions (abs, sqrt, exp, cumsum and friends), the programming utilities (missing, on.exit, as.integer, is.na, is.null, is.object, unclass, invisible, seq_along) and the basic accessors and replacement functions (length, class, oldClass, attr, names, dim, levels<-, storage.mode<-). Replacement functions are primitive where possible to avoid copying. A few primitives exist for efficiency only: c, list, rep, seq.int, round, signif, UseMethod and standardGeneric among them.

Writing R Extensions adds a rule for package authors. It says it is not portable to call compiled code in R or in other packages through .Internal, .C, .Fortran, .Call or .External, because such interfaces are subject to change without notice and will probably result in code that terminates the R process (Writing R Extensions: Writing portable packages). .Internal is for the R sources; user code and package code call the closures that wrap it, and use .Call() with their own registered routines for their own compiled code (see Interfacing C, C++ and Fortran).

Prototypes for primitives

Because a primitive has no formals, R keeps prototypes for printing, for args() and for package checking (tools::checkS3methods() and the codetools package use them). They live in two environments in the base package, .GenericArgsEnv for the primitives that are internal S3 generics and .ArgsEnv for the rest, and hold closures with the primitives' names, formals derived by hand from the help pages, and a body that is either a suitable call to UseMethod() or NULL (R Internals: Prototypes for primitives).

# Printing and args() use prototypes kept in two environments of base.
exists("sum", envir = .GenericArgsEnv, inherits = FALSE)
#> [1] TRUE
exists("sum", envir = .ArgsEnv, inherits = FALSE)
#> [1] FALSE
exists("quote", envir = .ArgsEnv, inherits = FALSE)
#> [1] TRUE
typeof(.GenericArgsEnv$sum)
#> [1] "closure"
body(.GenericArgsEnv$sum)
#> UseMethod("sum")

sum has a prototype in .GenericArgsEnv, whose body is UseMethod("sum"): it is an internal generic (a member of the Summary group), and the prototype records that. quote is in .ArgsEnv, the environment for primitives that are not generic. The prototypes are what make the function (..., na.rm = FALSE) part of the printed line; the primitive itself is the C function behind it.

Internal generics: dispatch on a primitive

Primitives are not generic functions in the sense of UseMethod("mean"), because there is no R code to hold the call. Yet length, [, c, dim, as.character and many others take methods. The help page InternalMethods lists the functions that are generic in this way: the subsetting operators ([, [[, $ and their replacement forms), length, lengths, dim, dimnames, names and their replacement forms, levels<-, @, c, unlist, cbind, rbind, the as. family, is.na, anyNA, is.numeric, nchar, rep and seq.int, xtfrm and others, plus all the members of the group generics. The vector .S3PrimitiveGenerics lists the primitives that are internal generic and not group generic, and .internalGenerics those reached through .Internal.

How the dispatch works is described in R Internals under "Argument evaluation". A special primitive that is an internal generic does not evaluate its arguments on call; the C code begins with a call to DispatchOrEval, which evaluates the first argument, looks for a method based on its class (if S4 dispatch is on, S4 methods are looked for first, even for S3 classes) and, if it finds one, dispatches to it with the remaining arguments as promises. If no method is found, the remaining arguments are evaluated and control returns to the internal generic. Group generics work another way: most are builtins and their C code calls DispatchGeneric. The efficiency rule is in the help page: for efficiency, internal dispatch only occurs on objects, that is, those for which is.object() returns TRUE.

source("scenario.R")

dispossessed <- book(
  "The Dispossessed", "Ursula K. Le Guin", "978-0-306-40615-7",
  1974, "Science fiction", 387
)

# length() is a primitive and an internal generic: with no method it counts
# the six fields of the underlying list.
length(dispossessed)
#> [1] 6

length.book <- function(x) 1L
length(dispossessed)
#> [1] 1
length(unclass(dispossessed))
#> [1] 6

length(dispossessed) counts the six fields of the list. After length.book is defined, the same call returns 1: the primitive found the class attribute and dispatched. length(unclass(dispossessed)) is 6 again, since the object is no longer an object and the primitive does not even look for a method. The next fragment of the same script makes the point with a plain list that happens to have a method defined for a class name it does not carry.

# The same dispatch works for the other internal generics, and only for
# objects: a plain list with the same fields never reaches the method.
fields <- list(title = "x", author = "y")
length.fields <- function(x) 99L
length(fields)
#> [1] 2
length(structure(fields, class = "fields"))
#> [1] 99

# Which names are internal generics (and not group generics)?
c("length", "c", "dim", "mean") %in% .S3PrimitiveGenerics
#> [1]  TRUE  TRUE  TRUE FALSE

The plain list fields is not an object, so length.fields is never consulted and the result is 2; the classed copy dispatches and returns 99. The membership test shows that length, c and dim are in .S3PrimitiveGenerics while mean is not, since mean is an ordinary closure and a generic by the UseMethod() route. In practice this means two things when you write a class. A method for an internal generic such as length or [ takes effect only when the object has a class attribute, and every call on a classed object pays the (small) cost of looking for a method. And a package that defines such a method must register it for dispatch, which the S3 Classes and Generics page shows with S3method() directives in NAMESPACE.

A short history of R’s object systems

The five systems did not arrive together, and their dates explain several of their quirks. The R FAQ dates the first two: Chambers and Hastie’s "Statistical Models in S" (1992) introduced S version 3, which added structures to facilitate statistical modelling, and Chambers’s "Programming with Data" (1998) described version 4 of S, a major revision (R FAQ 3.1). R’s S3 classes follow the first, and the methods package, which was added in R 1.4.0, implements the second. The NEWS of the later releases supply the remaining dates; the two CRAN packages are dated from the CRAN archive.

timeline title R's object systems section Inherited from S 1992 : S3 : S version 3 and the White Book : class attribute and UseMethod 1998 : S4 : S version 4 and the Green Book : formal classes and generic functions section Base R 2001 : methods package : R 1.4.0 adds S4 classes and methods 2010 : Reference Classes : R 2.12.0 adds setRefClass section CRAN packages 2014 : R6 : first release on CRAN 2023 : S7 : 0.1.0 on CRAN in August 2026 : S7 0.2.2 : latest release in April section Back in base R 2024 : OBJSXP : R 4.4.0 renames S4SXP

The dates come from these places:

  • S3 (1992) and S4 (1998) are the years of the S books that R FAQ 3.1 describes.

  • R 1.4.0 (released 2001-12-19) is the release whose NEWS says that a package methods has been added, containing formal classes and methods ("S4" methods) that implement the description in "Programming with Data", and that the @ operator has been added to the grammar (R NEWS: Changes in R 1.4.0, NEWS.1; the release date is from the source archive).

  • R 2.12.0 (released 2010-10-15) is the release whose NEWS says that a facility for defining reference-based S4 classes, in the object-oriented style of Java and C++, has been added experimentally to package methods (R NEWS: Changes in R 2.12.0, NEWS.2; release date from the source archive).

  • R6’s first release in the CRAN archive is 1.0.1, dated 2014-07-17; the current release is 2.6.1. S7 0.1.0 was published on 2023-08-24, and the current release, 0.2.2, on 2026-04-22 (CRAN: S7).

  • R 4.4.0 (released 2024-04-24) is the release whose NEWS records the renaming of the internal type S4SXP to OBJSXP; see the section on OBJSXP.

The same object in five systems

The five systems are easiest to compare on one tiny object, a book with a single field, title. The script builds it in each system and asks the questions of the first half of this page about the result: what type is it, what does class() say, is it an "object" and is it an S4 object?

source("scenario.R")
library(R6)
library(S7)

# The same one-field object in each of the five systems.
s3_book <- book("Ten Winters")

book_s4 <- setClass("BookS4", slots = c(title = "character"))
s4_book <- book_s4(title = "Ten Winters")

book_rc <- setRefClass("BookRC", fields = list(title = "character"))
rc_book <- book_rc$new(title = "Ten Winters")

book_r6 <- R6Class("BookR6", public = list(title = NULL))
r6_book <- book_r6$new()
r6_book$title <- "Ten Winters"

book_s7 <- new_class("BookS7", properties = list(title = class_character))
s7_book <- book_s7(title = "Ten Winters")

objects <- list(
  S3 = s3_book, S4 = s4_book, RC = rc_book, R6 = r6_book, S7 = s7_book
)
data.frame(
  system = names(objects),
  typeof = vapply(objects, typeof, character(1L)),
  class = vapply(objects, \(x) class(x)[[1L]], character(1L)),
  is_object = vapply(objects, is.object, logical(1L)),
  is_s4 = vapply(objects, isS4, logical(1L)),
  row.names = NULL
)
#>   system      typeof  class is_object is_s4
#> 1     S3        list   book      TRUE FALSE
#> 2     S4          S4 BookS4      TRUE  TRUE
#> 3     RC          S4 BookRC      TRUE  TRUE
#> 4     R6 environment BookR6      TRUE FALSE
#> 5     S7      object BookS7      TRUE FALSE

The book() from the scenario is an S3 object, a list with a class attribute. setClass() returns a generator function, and its objects have the type "S4"; setRefClass() objects have the same type, because a Reference Class object is an S4 object that holds an environment. An R6 object is an environment with a class vector, so typeof() says "environment". An S7 object has the type "object": it is an OBJSXP object whose S4 bit is not set. All five are objects for is.object(), and only the S4 and Reference Class objects answer TRUE to isS4().

# R6 and S7 objects carry S3 class vectors.
class(r6_book)
#> [1] "BookR6" "R6"
class(s7_book)
#> [1] "BookS7"    "S7_object"
inherits(s7_book, "S7_object")
#> [1] TRUE
inherits(r6_book, "R6")
#> [1] TRUE

# unclass() cannot strip the class of an environment.
tryCatch(unclass(r6_book), error = conditionMessage)
#> [1] "cannot unclass an environment"

R6 and S7 objects have ordinary S3 class vectors, which is how S3 generics such as print() and format() can see them, and which is why inherits(s7_book, "S7_object") works. The last call is a consequence of the R6 type: unclass() is not allowed for environments, so there is no plain-structure view of an R6 object as there is for a book.

Copy-on-modify or reference semantics

R’s usual behaviour is that a function that modifies its argument modifies a copy. S3, S4 and S7 objects behave this way. Reference Classes and R6 objects are environments: a function that receives one and changes a field changes the object everywhere. The script gives each object to a function that sets the title to "Changed" and then looks at the caller’s copy.

source("scenario.R")
library(R6)
library(S7)

s3_book <- book("Ten Winters")
book_s4 <- setClass("BookS4", slots = c(title = "character"))
s4_book <- book_s4(title = "Ten Winters")
book_rc <- setRefClass("BookRC", fields = list(title = "character"))
rc_book <- book_rc$new(title = "Ten Winters")
book_r6 <- R6Class("BookR6", public = list(title = NULL))
r6_book <- book_r6$new()
r6_book$title <- "Ten Winters"
book_s7 <- new_class("BookS7", properties = list(title = class_character))
s7_book <- book_s7(title = "Ten Winters")

# Each function changes the title of the object it receives.
retitle_dollar <- function(x) {
  x$title <- "Changed"
  invisible(x)
}
retitle_at <- function(x) {
  x@title <- "Changed"
  invisible(x)
}

retitle_dollar(s3_book)
retitle_at(s4_book)
retitle_at(s7_book)
retitle_dollar(rc_book)
retitle_dollar(r6_book)

# Copy-on-modify (S3, S4, S7): the caller still sees the old title.
s3_book$title
#> [1] "Ten Winters"
s4_book@title
#> [1] "Ten Winters"
s7_book@title
#> [1] "Ten Winters"

# Reference semantics (RC, R6): the caller sees the change.
rc_book$title
#> [1] "Changed"
r6_book$title
#> [1] "Changed"

# An RC object is copied only on request.
rc_copy <- rc_book$copy()
rc_copy$title <- "Copy"
rc_book$title
#> [1] "Changed"

The @ form is the S4 and S7 accessor; the $ form is the list element of the S3 book and the field of the Reference Class and R6 objects. After the calls, the S3, S4 and S7 titles are unchanged (each function modified its own copy), while the Reference Class and R6 titles are "Changed". A Reference Class object is copied only on request with its $copy() method; R6 has the analogous $clone() method. Reference semantics are the reason to pick these two systems and also the reason to be careful with them: a change made through one name is visible through every other name, and the usual rules of Assignment, Names and Values about copies no longer describe such an object. Memory and Copy Semantics covers when R copies.

Generic functions or methods that belong to the class

S3, S4 and S7 are generic-function systems: a method is a function that is registered with a generic, and a call such as shout(x) looks at the class of x to decide which one runs. Reference Classes and R6 are message-passing systems, in the style of Java and Python: the methods are stored in the class, and a call such as x$shout() finds the method through the object. The same operation in each system:

source("scenario.R")
library(R6)
library(S7)

# S3: a generic function that dispatches on the class attribute.
shout <- function(x) UseMethod("shout")
shout.book <- function(x) toupper(x$title)
shout(book("Ten Winters"))
#> [1] "TEN WINTERS"

# S4: a generic declared with setGeneric(), methods registered by class.
book_s4 <- setClass("BookS4", slots = c(title = "character"))
setGeneric("shout4", function(x) standardGeneric("shout4"))
#> [1] "shout4"
setMethod("shout4", "BookS4", function(x) toupper(x@title))
shout4(book_s4(title = "Ten Winters"))
#> [1] "TEN WINTERS"

# Reference classes: methods live inside the class and use fields directly.
book_rc <- setRefClass(
  "BookRC",
  fields = list(title = "character"),
  methods = list(shout = function() toupper(title))
)
book_rc$new(title = "Ten Winters")$shout()
#> [1] "TEN WINTERS"

# R6: methods live inside the class and reach the object through self.
book_r6 <- R6Class(
  "BookR6",
  public = list(
    title = NULL,
    initialize = function(title) self$title <- title,
    shout = function() toupper(self$title)
  )
)
book_r6$new("Ten Winters")$shout()
#> [1] "TEN WINTERS"

# S7: new_generic() and method() bind a function to a class.
book_s7 <- new_class("BookS7", properties = list(title = class_character))
shout7 <- new_generic("shout7", "x")
method(shout7, book_s7) <- function(x) toupper(x@title)
shout7(book_s7(title = "Ten Winters"))
#> [1] "TEN WINTERS"

In the S3 version the generic is a plain function that calls UseMethod() and the method is a plain function named generic.class. In S4 the generic is declared with setGeneric() (which prints its name, the #> [1] "shout4" line) and each method is registered with setMethod(). A Reference Class method refers to the fields by name, as in toupper(title), and R6 methods reach the object through self. S7 binds a function to a generic and a class with method(generic, class) <- function. In every case the user-visible effect is the same, and the differences show up in how each system handles inheritance, validation and mutation.

Single or multiple dispatch

S3 dispatches on the class of the first argument only: the second argument is ignored when selecting the method. S4 and S7 can dispatch on several arguments, so a method can be written for a particular combination of classes.

library(S7)

# S3 looks only at the class of the first argument: the second is ignored.
pair_kind <- function(x, y) UseMethod("pair_kind")
pair_kind.numeric <- function(x, y) "first argument is a number"
pair_kind.character <- function(x, y) "first argument is text"
pair_kind(1, "a")
#> [1] "first argument is a number"
pair_kind(1, TRUE)
#> [1] "first argument is a number"

# S4 can look at the classes of several arguments.
setGeneric("pair_kind4", function(x, y) standardGeneric("pair_kind4"))
#> [1] "pair_kind4"
setMethod(
  "pair_kind4", signature("numeric", "character"),
  function(x, y) "number then text"
)
setMethod(
  "pair_kind4", signature("character", "numeric"),
  function(x, y) "text then number"
)
pair_kind4(1, "a")
#> [1] "number then text"
pair_kind4("a", 1)
#> [1] "text then number"

# S7 chooses the dispatch arguments when it creates the generic.
pair_kind7 <- new_generic("pair_kind7", c("x", "y"))
method(pair_kind7, list(class_double, class_character)) <-
  function(x, y) "number then text"
method(pair_kind7, list(class_character, class_double)) <-
  function(x, y) "text then number"
pair_kind7(1, "a")
#> [1] "number then text"
pair_kind7("a", 1)
#> [1] "text then number"

pair_kind(1, "a") and pair_kind(1, TRUE) give the same answer, because only the first argument decides. The S4 and S7 generics select different methods for the same two values in a different order, because both arguments take part. The S4 signature() lists the classes in argument order; S7 declares the dispatch arguments (c("x", "y")) when it creates the generic and takes a list of classes in the method<- call. S3’s UseMethod() can be extended by manual checks inside the method, and Ops group generics have their own double-dispatch rule (see S3 Group Generics and Operator Overloading), but the general rule for S3 is first argument only.

Validation

Nothing in S3 stops you from putting a number where a title belongs: a class is a label, and checks are the constructor’s and the validator’s job. R6 fields are likewise unchecked unless you write the check. S4 slots, Reference Class fields and S7 properties declare a type, and the type is enforced on every assignment.

source("scenario.R")
library(R6)
library(S7)

s3_book <- book("Ten Winters")
book_s4 <- setClass("BookS4", slots = c(title = "character"))
s4_book <- book_s4(title = "Ten Winters")
book_rc <- setRefClass("BookRC", fields = list(title = "character"))
rc_book <- book_rc$new(title = "Ten Winters")
book_r6 <- R6Class("BookR6", public = list(title = NULL))
r6_book <- book_r6$new()
book_s7 <- new_class("BookS7", properties = list(title = class_character))
s7_book <- book_s7(title = "Ten Winters")

# S3 and R6 accept a number where a title belongs.
s3_book$title <- 42
s3_book$title
#> [1] 42
r6_book$title <- 42
r6_book$title
#> [1] 42

# S4, Reference Classes and S7 check the type on assignment.
tryCatch(s4_book@title <- 42, error = conditionMessage)
#> [1] "assignment of an object of class “numeric” is not valid for @‘title’ in an object of class “BookS4”; is(value, \"character\") is not TRUE"
tryCatch(rc_book$title <- 42, error = conditionMessage)
#> [1] "invalid assignment for reference class field ‘title’, should be from class “character” or a subclass (was class “numeric”)"
tryCatch(s7_book@title <- 42, error = conditionMessage)
#> [1] "<BookS7>@title must be <character>, not <double>"

The messages are the real ones from R 4.6.1, S7 0.2.2 and the methods package; the S4 and Reference Class messages use typographic quotation marks. S4 goes further than type checks: validity functions and validObject() enforce rules that involve several slots (see S4 Classes and Methods), and S7 has validators for the same purpose (see The S7 Object System). The book() constructor in the scenario does its checking by hand, in validate_book(), which is the S3 convention.

Comparing the five systems

The table summarises what the scripts showed, plus the facts that the system pages establish. "Where" says what a user has to install: S3, S4 and Reference Classes come with every R; R6 and S7 are CRAN packages with a version of their own.

S3 S4 Reference Classes R6 S7

Where it lives

base R: the class attribute, UseMethod(), NextMethod()

the methods package, attached by default

the methods package, setRefClass()

CRAN package R6 2.6.1

CRAN package S7 0.2.2

Style

generic functions; informal

generic functions; formal classes

methods belong to the class; formal fields

methods belong to the class; public, private and active members

generic functions; formal classes

Dispatch

single, on the first argument; implicit classes

multiple, on any arguments in the signature

single: obj$method()

single: obj$method()

multiple, on the arguments chosen for the generic

Class definition

none: set the class attribute; constructor and validator by convention

setClass() with slots, prototype, validity, contains

setRefClass() with fields, methods, contains

R6Class() with public, private, active, inherit

new_class() with properties, validator, parent

Mutability

copy-on-modify

copy-on-modify

reference semantics (an environment); $copy()

reference semantics (an environment); $clone()

copy-on-modify

Field checks

none unless you write them

slot types checked on assignment; validity

field classes checked on assignment

none unless you write them

property types checked on assignment; validator

Inheritance

class vector; NextMethod()

contains (several parents allowed); callNextMethod(); virtual classes, class unions

contains; callSuper()

inherit (one parent); super$

parent (one parent); super()

typeof() of an instance

what the object is built from, usually "list"

"S4" (or the basic type it extends)

"S4"

"environment"

"object"

Typical use

base R and most CRAN packages: lm, data.frame, Date, factor

Bioconductor and Matrix

older code that needs mutable objects

stateful objects: Shiny’s session objects (R6), caches, handles

new packages; ggplot2 4.0 builds its plot objects with it

The "typical use" row is based on what the example below prints, not on popularity figures. Two of the cells deserve a comment. In the S4 cell (Bioconductor and Matrix), the classes of Matrix, which ships with R, are S4 classes, as the output below shows, and Bioconductor’s core packages are written with S4 too; this page checked Matrix and not Bioconductor. In the S7 cell (ggplot2 4.0), the ggplot2 NEWS records that the S3 parts of ggplot2 have been replaced with S7 pieces, and a plot object is now an S7_object.

# Which object systems do packages in the wild use?
library(Matrix)
sparse <- Matrix(c(0, 1, 0, 2), 2L, 2L, sparse = TRUE)
class(sparse)
#> [1] "dtCMatrix"
#> attr(,"package")
#> [1] "Matrix"
isS4(sparse)
#> [1] TRUE

library(ggplot2)
p <- ggplot()
class(p)
#> [1] "ggplot2::ggplot" "ggplot"          "ggplot2::gg"     "S7_object"
#> [5] "gg"
isS4(p)
#> [1] FALSE
typeof(p)
#> [1] "object"
S7::S7_inherits(p)
#> [1] TRUE
packageVersion("ggplot2")
#> [1] ‘4.0.3’

A sparse Matrix object has an S4 class with the attribute "package", and isS4() says so. A ggplot2 4.0.3 plot is an S7 object (type "object", S7_inherits() true), and its class vector still has S3 names so that old S3 code keeps working. In the next block, a Shiny mock session is an R6 object, an environment, while a fitted lm is the classical S3 case, a list with a class, and a connection is an S3-classed integer that refers to a C-level structure.

library(shiny)
session <- MockShinySession$new()
class(session)
#> [1] "MockShinySession" "R6"
typeof(session)
#> [1] "environment"

# Base R itself: lm fits are S3 lists, connections are S3 integers.
fit <- lm(dist ~ speed, data = cars)
c(class(fit), typeof(fit))
#> [1] "lm"   "list"
con <- textConnection("a")
c(class(con), typeof(con))
#> [1] "textConnection" "connection"     "integer"
close(con)

Choosing a system

There is no single best system; there is a best system for each kind of problem. The questions below are the ones that settle most cases.

  1. Do you only need a print, format or summary method for a result? Use S3. It is what the language itself uses and what the R Language Definition singles out as the greatest use of object orientation in R: print, summary and plot methods. A class vector, a constructor and one UseMethod() generic are enough, and the S3 Classes and Generics page shows the constructor, validator and helper pattern with the book class.

  2. Are you extending a base-R or CRAN class, or writing a package that other packages extend? Use S3 unless the other side is S4. S3 methods interoperate with everything, inherits() and UseMethod() work across packages, and the mechanism needs no dependency.

  3. Do you need formal, checked structure, or dispatch on more than one argument? Use S4 if the methods package is acceptable, or S7 if a CRAN dependency is. S4 is the established choice in Bioconductor and Matrix; S7 is newer, simpler to read, and designed to interoperate with S3 and S4.

  4. Do you need an object that changes in place, with identity? Use R6 (or a Reference Class). A connection-like handle, a cache, a queue and an application component all have an identity that outlives a function call. Reference semantics also cost something: the object is shared by every name that points to it, and R functions that expect value semantics (modify-in-copy) do not apply.

  5. Are you starting a new package and want a modern formal system? S7 is the system the R Consortium Object-Oriented Programming working group designed for that, with R Core members taking part; its documentation describes it as somewhat experimental, and its stated long-term goal is to merge it into base R. As of this page it is a CRAN package at version 0.2.2 and is not part of base R 4.6.1. The The S7 Object System page covers its status and API.

Mixing is normal. A package may use S3 for results, S4 for a few core classes and R6 for a stateful component; setOldClass() and S7’s compatibility helpers exist because of that. What does not work well is mixing semantics in one class: an S3 list with reference semantics bolted on, for example, or an R6 object that is expected to be copied by <-.

OBJSXP and the road to S7

The R 4.4.0 NEWS contains an entry for C developers that matters for everyone who follows R’s object systems: the SEXP type S4SXP was renamed OBJSXP, "to support experimenting with alternative object systems". The old name can still be used in C code but is deprecated, and the change is based on contributions from the R Consortium’s Object-Oriented Programming Working Group (R NEWS: Changes in R 4.4.0). The reason is in R Internals: an S4 object is either a simple type with S4 class information or an object of type OBJSXP, and OBJSXP is for S4 and other objects which do not consist solely of a simple type (R Internals: SEXPTYPEs). The old name tied the type to S4; the new name says what the type is, a general type for objects that are not a simple vector or function, so that a package may create such objects without the S4 bit.

library(S7)

book_s4 <- setClass("BookS4", slots = c(title = "character"))
s4_book <- book_s4(title = "Ten Winters")
book_s7 <- new_class("BookS7", properties = list(title = class_character))
s7_book <- book_s7(title = "Ten Winters")

# Both are objects that are not a simple vector or function: type OBJSXP.
# typeof() reports "S4" when the S4 bit is set and "object" when it is not.
typeof(s4_book)
#> [1] "S4"
isS4(s4_book)
#> [1] TRUE
typeof(s7_book)
#> [1] "object"
isS4(s7_book)
#> [1] FALSE

On R 4.6.1, typeof() returns "S4" for an OBJSXP object that has the S4 bit set, and "object" for one that does not. The S7 object is the second case: it is a plain OBJSXP object with a class attribute, not an S4 object (isS4() is FALSE). The Language Definition’s table of typeof() values still lists "S4" and not "object", while the typeof() help page lists both.

# An S4 class that extends a basic type keeps that type.
measure <- setClass("Measure", contains = "numeric")
lengths_cm <- measure(c(1.5, 2.5))
typeof(lengths_cm)
#> [1] "double"
isS4(lengths_cm)
#> [1] TRUE
is.object(lengths_cm)
#> [1] TRUE
lengths_cm@.Data
#> [1] 1.5 2.5

# What an S7 object stores: a class attribute, the class object and the
# property values.
names(attributes(s7_book))
#> [1] "class"    "S7_class" "title"
class(s7_book)
#> [1] "BookS7"    "S7_object"
packageVersion("S7")
#> [1] ‘0.2.2’

An S4 class that extends a basic type keeps that type: Measure extends numeric, so its instances have the type "double", with the S4 bit set (isS4() is TRUE), and the numbers are available as @.Data. An S7 object stores its class vector, a reference to the class object (S7_class) and one attribute per property. The OBJSXP type itself is the groundwork; the rest of the support for alternative object systems came in 4.3.0, in the hooks that R Core added at the request of the same working group: inherits() accepts a class object through nameOfClass(), the @ operator became an S3 generic, and chooseOpsMethod() resolves a conflict between two Ops methods (R NEWS: Changes in R 4.3.0). The R-devel NEWS, which describes the upcoming R 4.7.0, lists a further low-level function, .OBJSXP(), that enables deparsing of non-S4 objects of type "object" (R-devel NEWS); it does not exist in R 4.6.1 and is not used here.

"R5", the methods package and Rscript

"R5" is not a separate system

Reference Classes are sometimes called "R5". The name is a community nickname, a joke on the sequence S3, S4, R5, and it appears in no R documentation. The help page for setRefClass() calls them reference classes; the facility was added in R 2.12.0 as an experimental part of the methods package, and as implemented they are S4 classes whose instances hold an environment, which is why isS4() is TRUE for them above. If a tutorial says "R5", read "Reference Classes" and look for setRefClass(); the Reference Classes and R6 page uses the documented name.

The methods package is attached by Rscript

The S4 and Reference Class machinery lives in the methods package. In an interactive session or with R -f it is attached by default, but before R 3.5.0 Rscript was different: it started with a shorter list of default packages that did not include methods, so scripts that called setClass() or new() needed library(methods). R 3.5.0 changed that. The NEWS entry says that, if --default-packages is not used, Rscript now checks the environment variable R_SCRIPT_DEFAULT_PACKAGES, which takes precedence over R_DEFAULT_PACKAGES, and that if default packages are not specified by either, Rscript now uses the same default packages as R. The previous behaviour, without methods, can be restored by setting the environment variable R_SCRIPT_LEGACY to yes (R NEWS: Changes in R 3.5.0, NEWS.3).

"package:methods" %in% search()
#> [1] TRUE
getOption("defaultPackages")
#> [1] "datasets"  "utils"     "grDevices" "graphics"  "stats"     "methods"
exists("setClass")
#> [1] TRUE
environmentName(environment(setClass))
#> [1] "methods"
Sys.getenv("R_SCRIPT_LEGACY", unset = "unset")
#> [1] "unset"

# A child Rscript started with a smaller list of default packages.
rscript <- file.path(R.home("bin"), "Rscript")
check <- shQuote("\"package:methods\" %in% search()")
system2(rscript, c("--default-packages=utils", "-e", check), stdout = TRUE)
#> [1] "[1] FALSE"

On R 4.6.1 methods is on the search path, defaultPackages lists it, and setClass lives in its namespace. The last call starts a child Rscript with --default-packages=utils, and that process reports FALSE: the methods package is not attached unless the list asks for it. A script that is run with a restricted package list, for example under a container entry point that sets R_DEFAULT_PACKAGES (the Startup help page gives utils,grDevices,graphics,stats as the setting used by R CMD check), can therefore fail with "could not find function "setClass"", and the fix is library(methods) at the top, or methods::setClass(). In a package, importing from methods in NAMESPACE is the correct fix. Examples on the S4 and Reference Class pages do not need library(methods) under Rscript 3.5.0 or later, but it does no harm.

Bridging S3 to S4: setOldClass()

S4 slots, S4 method signatures and S4 generics know the classes that setClass() defined and the basic classes; they do not know an S3 class such as "book". setOldClass("book") registers the S3 class as a virtual S4 class, which lets it appear in a slot type or a method signature. The argument is the character vector that would be the class attribute of the S3 object, so a vector of several names registers the whole inheritance chain; the help page also describes how to attach the known attributes of an S3 class through S4Class.

source("scenario.R")

# An S3 class that S4 has not heard of cannot be used as a slot type.
result <- tryCatch(
  setClass("LoanTicket", slots = c(item = "book")),
  warning = conditionMessage
)
result
#> [1] "undefined slot classes in definition of \"LoanTicket\": item(class \"book\")"

# setOldClass() registers the S3 class as a virtual S4 class.
setOldClass("book")
isVirtualClass("book")
#> [1] TRUE
loan_ticket <- setClass("LoanTicket", slots = c(item = "book"))

ticket <- loan_ticket(item = book("Ten Winters"))
class(ticket@item)
#> [1] "book"
is(ticket@item, "book")
#> [1] TRUE

# S4 generics can now dispatch on the S3 class.
setGeneric("title_of", function(x) standardGeneric("title_of"))
#> [1] "title_of"
setMethod("title_of", "book", function(x) x$title)
title_of(ticket@item)
#> [1] "Ten Winters"

Without the registration, setClass() signals the warning "undefined slot classes in definition of …​" and the slot is unusable for checking. After setOldClass("book") the class is virtual, an S4 slot can hold a book, and an S4 generic can dispatch on it. S7 has its own helper for the same purpose, new_S3_class(), covered on its page. The class help page adds the converse: S3 method selection treats objects of an S4 class as if they had the appropriate S3 class attribute, so S3 methods can be defined for S4 classes, and inherits() behaves consistently with is() apart from conditional superclasses.

Pitfalls collected

  • class(x) == "name" instead of inherits(x, "name"). Since R 4.0.0 a matrix has two classes, so the comparison has length 2, and in an if condition of length greater than one R 4.2.0 and later signal an error. The NEWS entry for R 4.0.0 says explicitly that the change invalidates code that assumes class(matrix_obj) has length one, and the R 4.2.0 entry for ks.test(), whose result now has class c("ks.test", "htest"), warns packages to use inherits() and not == on a class.

  • mode() as the type. mode(1L) is "numeric", mode(sum) is "function", mode(quote(x)) is "name". Use typeof() for the storage type and inherits() or is() for class questions.

  • inherits(x, "numeric") for an integer vector. It is FALSE for a plain integer, since class(1L) is "integer"; is.numeric(1L) is TRUE. The implicit class used for dispatch (.class2()) contains "numeric", but inherits() does not use it.

  • is.object() on atomic vectors. A plain vector, a matrix and a function are not objects; the test is the class attribute, not the object-orientation of the type.

  • Assigning a class to a value of the wrong shape. class(y) <- "matrix" needs a dimension attribute of length 2, and class()<- on NULL is an error.

  • Missing S3 methods. Many S3 methods are hidden in namespaces. R FAQ 7.26 ("Where have all the methods gone?") says this has the advantage that they cannot be called inadvertently with arguments of the wrong class, and shows getS3method("[", "terms") to see one and pkg:::foo for an unexported function. Do not call unexported functions in your own code.

  • Looking for the source of a primitive. sum, + and if have no R source (R FAQ 7.40 and the section on primitives above).

m <- matrix(1:4, 2)

# class(x) is a character vector, so == compares element by element.
class(m)
#> [1] "matrix" "array"
class(m) == "matrix"
#> [1]  TRUE FALSE
tryCatch(
  if (class(m) == "matrix") "a matrix" else "something else",
  error = conditionMessage
)
#> [1] "the condition has length > 1"

# inherits() asks the question that was meant.
inherits(m, "matrix")
#> [1] TRUE
is.matrix(m)
#> [1] TRUE

# mode() is the S-compatible grouping of types, not the type itself.
x <- c(1L, 2L)
c(typeof = typeof(x), mode = mode(x), storage.mode = storage.mode(x))
#>       typeof         mode storage.mode
#>    "integer"    "numeric"    "integer"
c(typeof = typeof(sum), mode = mode(sum), class = class(sum))
#>     typeof       mode      class
#>  "builtin" "function" "function"
mode(x) <- "character"
x
#> [1] "1" "2"
storage.mode(m) <- "double"
typeof(m)
#> [1] "double"

The script shows the first two pitfalls with their real outputs. class(m) == "matrix" is TRUE FALSE, and using it as a condition fails with the message the condition has length  1. inherits(m, "matrix") and is.matrix(m) ask the intended question. The third block shows the two replacement forms from earlier: mode(x) <- "character" converted the values, while storage.mode(m) <- "double" changed the matrix from integer to double and kept its dimensions.

Version notes

Version Change

R 1.4.0 (2001)

The methods package is added: formal (S4) classes and methods; the @ operator joins the grammar.

R 2.12.0 (2010)

Reference-based classes are added experimentally to the methods package (setRefClass()).

R 3.5.0

Rscript uses the same default packages as R, so methods is attached; R_SCRIPT_DEFAULT_PACKAGES is checked first and R_SCRIPT_LEGACY=yes restores the old behaviour.

R 4.0.0

Matrices also inherit from class "array", so class(matrix(1:4, 2)) is c("matrix", "array"); S3 methods for "array" are dispatched for matrices; .class2() returns the full vector of classes used for S3 dispatch.

R 4.3.0

inherits(x, what) accepts a non-character what through the new generic nameOfClass(); chooseOpsMethod() arbitrates between two Ops methods; the @ operator is an S3 generic.

R 4.4.0

The SEXP type S4SXP is renamed OBJSXP to support experiments with alternative object systems. On R 4.6.1 typeof() returns "object" for an OBJSXP object without the S4 bit and "S4" for one with it.

R-devel (4.7.0, upcoming)

.OBJSXP() is a new low-level function that enables deparsing of non-S4 objects of type "object". Not available in R 4.6.1.

R6 2.6.1, S7 0.2.2

Package versions this page checked. S7 0.1.0 was published on 2023-08-24; S7 is a CRAN package, not part of base R 4.6.1.

The R 3.5.0 entry is in the 3.x NEWS (NEWS.3) and the 1.4.0 and 2.12.0 entries are in the archived NEWS files cited in the history section.

Where older tutorials differ

Older material says Current practice Why

“Rscript does not load methods, so call library(methods)”

Not needed since R 3.5.0; still harmless

Rscript uses the same default packages as R (NEWS R 3.5.0).

"Reference Classes are R5", "S4 and R5 are the future"

Call them Reference Classes; choose the system by the problem

"R5" is a community nickname, and no R documentation uses it. S4 and Reference Classes are established and maintained, and S7 is the new design for new packages, but none of the systems is deprecated.

"S4 is the modern OOP system"

S4 for established ecosystems such as Bioconductor and Matrix; consider S7 for new code

S7 is designed for the same job with a simpler syntax and interoperability with S3 and S4.

mode(x) to find the type of an object

typeof(x) for the storage type, class(x) or inherits() for class

mode() is a coarse S-compatible grouping: integers and doubles are both "numeric", all functions are "function".

class(x) == "foo" or class(x)[1] == "foo"

inherits(x, "foo")

class(x) can have several elements, and since R 4.0.0 a matrix has two.

if (class(x) == "matrix")

is.matrix(x) or inherits(x, "matrix")

The first form fails with the condition has length  1.

“A matrix has class "matrix"”

class(matrix(1:4, 2)) is c("matrix", "array") since R 4.0.0

NEWS R 4.0.0.

typeof() is "S4" for every S4-style object

"S4" when the S4 bit is set, "object" otherwise (S7)

S4SXP became OBJSXP in R 4.4.0.

S4SXP in C tutorials

OBJSXP

The type was renamed in R 4.4.0; the old name still compiles but is deprecated.

Treating .Internal() as a way to call fast C code from packages

Call the R-level closure; use .Call() with registered routines for your own C

.Internal is for the R sources and may change without notice (Writing R Extensions).

For the full list of changes by release see What’s New and Migrating Older Code.

Where to go next

References