S3 Classes in R — Object-Oriented Programming
S3 classes are R’s oldest and simplest object-oriented system. They underlie much of R’s base functionality, including how print(), summary(), and predict() work for different data types. Understanding S3 classes gives you control over how your own functions behave with different object types.
What makes something an S3 object
An S3 object is simply a base R object with a class attribute. The class attribute is a character vector. You can attach a class to any object using the class() function.
# Create a regular list
person <- list(name = "Alice", age = 30)
class(person)
# [1] "list"
# Add an S3 class
class(person) <- "person"
class(person)
# [1] "person"
The object itself does not change. It is still a list with two elements. The class attribute is just metadata that R uses to decide which function to call.
Creating an S3 class
A constructor function makes creating class instances consistent. It wraps object creation and lets you validate input.
# Constructor for a person class
person <- function(name, age) {
if (!is.character(name)) {
stop("name must be a character vector")
}
if (!is.numeric(age) || age < 0) {
stop("age must be a non-negative number")
}
structure(
list(name = name, age = age),
class = "person"
)
}
# Create an instance
p <- person("Alice", 30)
p
# $name
# [1] "Alice"
# $age
# [1] 30
# attr(,"class")
# [1] "person"
The structure() function attaches the class while preserving the list contents. The structure() approach is idiomatic because it creates the object and sets the class in a single expression, avoiding the two-step pattern of creating a list and then calling class()<- separately. Every S3 constructor you write should use either structure() or set the class attribute before returning the object.
Generic functions and method dispatch
Generic functions are the heart of S3 classes. A generic is a function that delegates to a specific implementation based on the object’s class. You create a generic using UseMethod, which tells R to search for and call the appropriate method at runtime:
# Define a generic for describing people
describe <- function(x) {
UseMethod("describe")
}
# Default method for any class without specific implementation
describe.default <- function(x) {
paste("Something about", class(x)[1])
}
# Person-specific method
describe.person <- function(x) {
paste(x$name, "is", x$age, "years old")
}
# Test it
describe(p)
# [1] "Alice is 30 years old"
# Works with other types too
describe(1:5)
# [1] "Something about integer"
When you call describe(p), R looks for describe.person. If it does not exist, R falls back to describe.default. The naming convention is critical: generic dot class, where the function name must follow generic.class exactly for dispatch to work. This simple convention is both S3’s strength — you can add methods for any generic without registering them — and its weakness — a typo in a function name silently creates a new unrelated function instead of a method.
Method dispatch in detail
R dispatches methods based on the first argument’s class. The search order matters:
- Look for generic.class - specific method
- Look for generic.default - fallback
- Throw an error if neither exists
# Create a subclass: student inherits from person
student <- function(name, age, major) {
structure(
list(name = name, age = age, major = major),
class = c("student", "person")
)
}
s <- student("Bob", 20, "Physics")
# R looks for describe.student first
# Not found, so falls back to describe.person
describe(s)
# [1] "Bob is 20 years old"
The class vector defines inheritance. “student” comes first, so R checks for a student method before looking for person.
NextMethod for inheritance
When you have class inheritance, NextMethod lets you call the parent class’s implementation. This avoids duplicating code.
# Extend describe for students
describe.student <- function(x) {
# Call the person method first
base_description <- NextMethod()
# Add student-specific info
paste(base_description, "studying", x$major)
}
describe(s)
# [1] "Bob is 20 years old studying Physics"
The base description comes from describe.person, then we append the major.
Common generics and extending them
Many base R functions are S3 generics. You can add methods for your classes to customize behavior.
# Customize print behavior
print.person <- function(x, ...) {
cat("Person: \n")
cat(" Name:", x$name, "\n")
cat(" Age:", x$age, "\n")
invisible(x)
}
p
# Person:
# Name: Alice
# Age: 30
Key generics you will commonly extend:
- print - how objects display
- summary - compact representation
- plot - visualization
- predict - model predictions
- coef - extract coefficients
Checking class
Use inherits to check if an object inherits from a class.
inherits(p, "person")
# [1] TRUE
inherits(p, "student")
# [1] FALSE
inherits(s, "person")
# [1] TRUE
This is useful inside methods when you need to handle different cases.
Why use S3
S3 is lightweight and flexible. You do not need to define formal classes or methods. The system is duck-typed: if an object has the right components, it works.
Base R uses S3 extensively. The data.frame class, lm model objects, and time series all rely on S3 methods. Understanding S3 lets you work with these objects more effectively and extend them for your own needs.
Common pitfalls
Forgetting to call UseMethod is the most common mistake. A generic must call UseMethod or the dispatch never happens.
# Wrong - this will not dispatch
describe <- function(x) {
describe.person(x) # Always calls person method
}
# Right - this dispatches correctly
describe <- function(x) {
UseMethod("describe")
}
Another pitfall: forgetting that class returns a vector. Use class(x)[1] to get the first or primary class.
S3 dispatch and the method resolution order
S3 dispatch is simple and fast: when print(x) is called and x has class(x) = c("my_class", "data.frame"), R tries print.my_class() first, then print.data.frame(), then print.default(). Methods are looked up in the search path for a function named generic.class.
This implicit lookup is S3’s strength and weakness: it requires no formal class definition, making it easy to add methods to existing classes, but it also means you can accidentally create a method by naming a function generic.class without intending to.
Constructor patterns
The canonical S3 constructor pattern: a low-level new_myclass() function that creates the object cheaply (minimal validation), and a user-facing myclass() function that validates inputs and calls new_myclass(). This pattern, from Hadley Wickham’s “Advanced R”, separates the performance-critical construction (called internally) from the user-facing validation (called once at the boundary).
new_myclass <- function(x, y) {
structure(list(x = x, y = y), class = "myclass")
}
myclass <- function(x, y) {
stopifnot(is.numeric(x), length(x) == 1)
new_myclass(x, y)
}
Essential S3 methods
Every S3 class should implement print() for readable output and format() for string representation. Implement [ and [[ for subsetting if the class is indexable. c() and rbind() for combining instances. as.data.frame() and as_tibble() for converting to tabular format. These methods make the class work correctly with all the standard R infrastructure.
methods("print") lists all S3 methods for print. getS3method("print", "lm") retrieves a specific method even if it is not exported from its package.
Defining S3 methods
An S3 method is a function named generic.class. When you call print(obj) and obj has class "myclass", R calls print.myclass(obj). Create methods by defining a function with this naming convention, no registration is required.
Inheritance
S3 supports inheritance by assigning a vector of class names: class(obj) <- c("child", "parent"). Method dispatch tries each class in order, so method.child is called first, falling back to method.parent if no child method exists. Use NextMethod() inside a method to explicitly delegate to the next class in the hierarchy, similar to calling super() in object-oriented languages.
When to use S3
S3 is appropriate for most R packages that need custom print(), summary(), plot(), or predict() behavior. It is the most common OOP system in CRAN packages because it requires no boilerplate — no class definition file, no field declarations, no constructor registration. The tradeoff is that S3 provides no encapsulation or formal interface contract. For applications where interface enforcement and mutable state matter, R6 is a better fit.
How S3 works
S3 is R’s original and simplest object system. It works by attaching a class attribute to any R object and using generic functions that dispatch to different method implementations based on that class. There is no formal class definition — you create an S3 object by setting the class attribute of a list or vector. There is no encapsulation — all fields are directly accessible. The simplicity that makes S3 easy to learn also makes it easy to misuse, but for most package development purposes it is sufficient and idiomatic.
Generic functions in S3 dispatch by checking the class attribute of their first argument and looking for a method named generic.class in the current environment and search path. If no specific method is found, the generic falls back to generic.default. This mechanism is simple enough to understand completely in a few minutes, which makes debugging method dispatch straightforward compared to S4 or R5.
Constructors and validators
S3 has no formal constructor mechanism, but the convention is to write a constructor function with the same name as the class. The constructor creates a list with the required fields, sets the class attribute, and returns the object. Separating construction from validation is the recommended pattern: the constructor assumes valid inputs and a separate validator function checks that invariants hold. This separation makes testing easier because you can test construction and validation independently.
Constructor functions should use structure() to set the class attribute in one step rather than creating the list and then setting the attribute separately. Using structure is idiomatic and slightly more efficient. Validate required fields at the top of the constructor so errors appear at creation time with a clear message rather than at use time with an obscure error.
Writing methods
Methods for S3 generics follow the naming convention generic.class. To add a print method for a class called “my_model”, define a function named print.my_model. The function signature should accept at least the same arguments as the generic, including the dots argument for passing additional arguments down the call chain.
When writing methods, call NextMethod() to invoke the next applicable method in the class hierarchy. For single-inheritance S3 objects this is usually the default method, but for objects with a class vector representing an inheritance hierarchy it calls the parent class method. Using NextMethod correctly makes methods composable and avoids duplicating behavior from parent class methods.
Documenting S3 classes
Document S3 classes and their methods with roxygen2. The class itself is typically documented in the constructor’s man page. Each method that a user might call directly needs its own documentation, but methods that exist only to customize generic behavior — like print or format — can be documented with @rdname to group them with the class documentation. Export generics and constructors but not internal methods unless users have a reason to call them directly.
Conclusion
S3 provides a simple way to add object-oriented behavior to R code. Attach a class attribute to create objects, use UseMethod to create generics, and define generic.class methods for specific behaviors. The system is pragmatic: it favors convention over complexity and gets out of your way.
This foundation makes it easier to understand S4 and R6 when you need more formal OOP structures.
See also
- S4 Classes in R — formal class system with strict type checking and method signatures
- Building R Packages — package your S3 classes and generics for distribution
- R Basics: Vectors and Types — understanding the data structures that S3 classes build upon