Skip to Content
Course content

125: Comparing S3, S4, and R6

Click on the "Edit" button in the top corner of the screen to edit your slide content.

If you've spent any time in the R ecosystem, you've probably noticed that "Object-Oriented Programming" in R isn't one thing—it's three or four different things fighting for dominance. It can be incredibly frustrating when you just want to organize your code. I've spent years jumping between these systems, and the secret is that none of them are "the right one." They just serve different purposes.

To see this in action, let's build a simple ProjectTracker. We want something that stores a project name and a list of completed tasks. Depending on which system we use, the "feel" of the code changes completely.

The "Wild West" approach with S3

S3 is what you're using 90% of the time without realizing it. It's basically just a list with a label slapped on it. I usually start here because it's fast. No boilerplate, no ceremony.

# Creating a simple S3 object
new_project <- function(name) {
  structure(list(name = name, tasks = c()), class = "ProjectTracker")
}

# A generic function to print our tracker
print.ProjectTracker <- function(x) {
  cat("Project:", x$name, "\nTasks completed:", length(x$tasks), "\n")
}

my_proj <- new_project("Website Redesign")
print(my_proj)

It works, but here's the problem: there's nothing stopping me from accidentally breaking this object. I could set my_proj$name <- 42 or my_proj$tasks <- "not a vector", and R wouldn't blink. In a large production pipeline, that lack of discipline is a liability.

Tightening the screws with S4

When I need a "contract" that guarantees the data is in the right format, I move to S4. It's much more formal. You define the slots and their types upfront. It's a bit wordy, but it's safer.

setClass("ProjectTrackerS4",
         slots = list(name = "character", 
                      tasks = "character"))

# Let's try to create one
proj_s4 <- new("ProjectTrackerS4", name = "API Integration", tasks = c("Auth", "Endpoints"))

Now, here is where I usually trip up when switching back to S4. I'll try to update a value using the S3 list syntax because I'm in a hurry:

# This is my mistake:
proj_s4$name <- "New Name" 
# Error: object of type 'S4' is not subscriptable

Right. I forgot that S4 objects aren't lists. You can't use the $ operator. I have to use the @ symbol to access slots, or better yet, write a proper setter method. Correcting that looks like this:

proj_s4@name <- "New Name" # The "quick" way
# Or the professional way:
setGeneric("addTask", function(object, task) standardGeneric("addTask"))
setMethod("addTask", "ProjectTrackerS4", function(object, task) {
  object@tasks <- c(object@tasks, task)
  return(object)
})

Handling state with R6

S3 and S4 share a fundamental R trait: they are immutable. When you "add a task" in S4, you're actually creating a whole new copy of the object and returning it. For a small project, that's fine. But if your object is massive, copying it every time you change one value is a performance nightmare.

That's why R6 exists. It's "encapsulated," meaning the methods live inside the object, and it modifies the object in place. It feels much more like Python or Java.

library(R6)

ProjectTrackerR6 <- R6Class("ProjectTrackerR6",
  public = list(
    name = NULL,
    tasks = NULL,
    initialize = function(name) {
      self$name <- name
      self$tasks <- c()
    },
    add_task = function(task) {
      self$tasks <- c(self$tasks, task)
      invisible(self) # Return self so we can chain methods
    }
  )
)

my_r6_proj <- ProjectTrackerR6$new("Cloud Migration")
my_r6_proj$add_task("Setup S3")
my_r6_proj$add_task("Configure IAM")
# Notice we didn't have to do: my_r6_proj <- add_task(my_r6_proj, "Task")

The tradeoff? R6 objects don't play as nicely with R's functional paradigms. You lose that "pure function" predictability. I generally only reach for R6 when I'm building a complex system, like a database connector or a shiny app state manager, where I need a single source of truth that updates in real-time.




📋 Practical Task

Refactoring the ProjectTracker to R6 with Validation

In the lesson, the R6 implementation was very basic. Your task is to enhance the ProjectTrackerR6 class to ensure data integrity.

Modify the add_task method so that it performs the following checks before adding a task to the list:

  • Ensure the task argument is a single character string. If it isn't, use stop() to throw an error.
  • Ensure the task isn't a duplicate of one already in the tasks vector. If it is, print a warning using warning() but do not add the task.

Test your class by creating a new project, adding two identical tasks, and attempting to add a numeric value as a task to verify that your validation logic works.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.