OOP in R: S3 vs S4 vs R5 vs R6 — When to Use Each System
R has four object-oriented programming systems: S3 (simple, informal), S4 (formal, strict), R5/Reference Classes (mutable), and R6 (modern, fast). This guide compares all four and tells you when to pick each one.
Most languages have one OOP system. R has four, because they evolved over decades to serve different needs. You don't need to master all four — but you need to recognize them in the wild and know which one fits your project.
The Big Picture
System
Style
Mutability
Validation
Speed
Use case
S3
Informal
Copy-on-modify
None built-in
Fast
Quick classes, most packages
S4
Formal
Copy-on-modify
Strict slots
Medium
Bioconductor, complex hierarchies
R5
Reference
Mutable
Optional
Slow
Legacy mutable objects
R6
Reference
Mutable
Optional
Fast
Modern mutable objects, APIs
S3: The Informal System
S3 is R's most common OOP system. It's what lm(), data.frame, and factor use. There's no formal class definition — you just set an attribute.
# Create an S3 object: it's just a list with a "class" attribute
dog <- list(name = "Rex", breed = "Labrador", age = 5)
class(dog) <- "Dog"
# Define a method: create a function named generic.class
print.Dog <- function(x, ...) {
cat(sprintf("Dog: %s (%s, %d years old)\n", x$name, x$breed, x$age))
}
# R dispatches print() to print.Dog automatically
print(dog)
# S3 is informal — no validation, no enforcement
dog$color <- "brown" # Add any field anytime
dog$age <- "old" # No type checking
S4 enforces structure with setClass(), typed slots, and setGeneric()/setMethod(). It's used heavily in Bioconductor.
# Define class with typed slots
setClass("Person", representation(
name = "character",
age = "numeric",
email = "character"
))
# Create an instance
alice <- new("Person", name = "Alice", age = 30, email = "alice@example.com")
# Access slots with @
cat("Name:", alice@name, "\n")
cat("Age:", alice@age, "\n")
# Slots are type-checked
# new("Person", name = "Bob", age = "thirty") # Error!
# S4 methods
setClass("Person", representation(
name = "character",
age = "numeric"
))
setGeneric("greet", function(x, ...) standardGeneric("greet"))
setMethod("greet", "Person", function(x, ...) {
cat("Hi, I'm", x@name, "and I'm", x@age, "\n")
})
bob <- new("Person", name = "Bob", age = 25)
greet(bob)
R6: The Modern Reference System
R6 classes use reference semantics (objects are mutable) and have a clean, familiar syntax. Install with install.packages("R6").
# S4 version
setClass("PointS4", representation(x = "numeric", y = "numeric"))
setMethod("show", "PointS4", function(object) {
cat(sprintf("(%g, %g)\n", object@x, object@y))
})
p2 <- new("PointS4", x = 3, y = 4)
p2
# R6 version
library(R6)
PointR6 <- R6Class("PointR6",
public = list(
x = NULL, y = NULL,
initialize = function(x, y) { self$x <- x; self$y <- y },
print = function(...) cat(sprintf("(%g, %g)\n", self$x, self$y))
)
)
p3 <- PointR6$new(3, 4)
p3
Decision Guide
graph TD
A[Need OOP?] -->|Simple dispatch| B[S3]
A -->|Strict types, Bioconductor| C[S4]
A -->|Mutable state, APIs| D[R6]
A -->|Legacy code| E[R5]
B -->|Most packages| F[Default choice]
C -->|Formal validation needed| G[Bioconductor, complex hierarchies]
D -->|Reference semantics| H[Connections, caches, GUIs]
Choose...
When...
S3
You need simple method dispatch, compatibility with base R, or you're building a CRAN package
S4
You need strict type checking, multiple dispatch, or you're in the Bioconductor ecosystem
R6
You need mutable objects (connections, caches, stateful APIs) or you prefer obj$method() syntax
R5
Only for maintaining legacy code — use R6 for new projects
Practice Exercises
Exercise 1: Create an S3 Class
Build a Temperature S3 class that stores a value and unit, with a print method.
# Create new_Temperature(value, unit = "C")
# print.Temperature should display "25°C" or "77°F"
# Create a convert() generic that converts between C and F
**Explanation:** S3 classes are just lists with a class attribute. Methods follow the `generic.class` naming convention. `UseMethod()` enables dispatch.
Exercise 2: R6 Stack
Implement a stack (LIFO data structure) using R6.
library(R6)
# Create a Stack R6 class with:
# - push(item): add to top
# - pop(): remove and return top
# - peek(): view top without removing
# - size(): return number of elements
# - is_empty(): TRUE/FALSE
**Explanation:** R6 classes use `self$` to access their own fields and methods. `invisible(self)` enables method chaining. Private fields keep internal state hidden.
Summary
Feature
S3
S4
R5
R6
Define class
class(x) <- "Foo"
setClass()
setRefClass()
R6Class()
Create object
new_Foo()
new("Foo")
Foo$new()
Foo$new()
Access fields
x$field
x@slot
x$field
x$field
Define methods
method.Class
setMethod()
Inside class
Inside class
Inheritance
Implicit
contains=
contains=
inherit=
Copy semantics
Copy-on-modify
Copy-on-modify
Reference
Reference
Package needed
No
No
No
R6
FAQ
Which OOP system should I learn first?
S3. It's used by most R packages and base R itself. Learn R6 next if you need mutable objects. Only learn S4 when you need it (Bioconductor work, formal validation).
Can I mix OOP systems?
Yes. S3 and S4 interoperate (S4 can inherit from S3). R6 objects can have S3 methods added to them. In practice, pick one system per class and stick with it.
Why does R have so many OOP systems?
Historical evolution. S3 came from S (1990s). S4 was added for stricter needs. R5 added reference semantics. R6 was created as a faster, cleaner alternative to R5. Each solved a real problem at the time.