Skip to Content
Course content

31: Object-Oriented Programming in R (S3, S4, 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 R, you've already been using Object-Oriented Programming (OOP), even if you didn't realize it. Every time you call print() or plot() on a data frame versus a linear model, R is doing some magic behind the scenes to figure out which version of that function to run. But when you start building your own complex systems, you'll hit a wall where standard functions aren't enough. You'll be tempted to start writing massive functions with endless if/else blocks to handle different "types" of data, and that's exactly where things start to fall apart.

The 'Giant Switch Statement' Trap

I've seen this a dozen times in production code: a developer creates a list to represent a "Project" and adds a type field to it. Then, they write a function called calculate_budget() that looks like this:

calculate_budget <- function(project) {
  if (project$type == "fixed") {
    return(project$total)
  } else if (project$type == "hourly") {
    return(project$rate * project$hours)
  } else if (project$type == "retainer") {
    return(project$monthly_fee * project$months)
  } else {
    stop("Unknown project type!")
  }
}

This works fine when you have two types. It's a nightmare when you have twenty. Every time you add a new project type, you have to hunt down every single function that checks project$type and add another else if. It's brittle, it's hard to test, and it's not how R is designed to work. We want polymorphism—the ability for the object itself to determine how a function should behave.

S3: The 'Good Enough' Approach

The first tool in your kit is S3. It's the most common system in R because it's essentially "informal." You don't define a class in a rigid way; you just assign a class attribute to a list. To make a function polymorphic, you create a "generic" using UseMethod().

Instead of that giant if block, we do this:

# 1. Define the generic
calculate_budget <- function(x) {
  UseMethod("calculate_budget")
}

# 2. Define the methods for specific classes
calculate_budget.fixed <- function(x) x$total
calculate_budget.hourly <- function(x) x$rate * x$hours
calculate_budget.retainer <- function(x) x$monthly_fee * x$months

# 3. Create the objects
proj1 <- list(total = 1000)
class(proj1) <- "fixed"

proj2 <- list(rate = 50, hours = 10)
class(proj2) <- "hourly"

calculate_budget(proj1) # Returns 1000
calculate_budget(proj2) # Returns 500

Now, if you add a "bonus-based" project type, you just write calculate_budget.bonus. You don't touch the original generic function at all. The trade-off? S3 is loose. If you typo a class name as "houly" instead of "hourly", R won't complain until the function fails at runtime. It's fast to write, but it doesn't protect you from yourself.

S4: When the Stakes are Higher

If you're working in bioinformatics (Bioconductor) or building a package that other people will depend on for mission-critical math, S3 might be too flimsy. That's where S4 comes in. S4 is formal. You have to explicitly define the "slots" (the data types) that a class must have. It’s more verbose, but it catches errors early.

In S4, you define a setClass and use setGeneric and setMethod. It feels a bit more like Java or C#. You lose the "quick and dirty" speed of S3, but you gain a guarantee that every "Project" object actually has a total or a rate field before the function even tries to run. I generally suggest sticking to S3 unless you specifically need the strict validation S4 provides.

R6: Breaking the 'Copy-on-Modify' Rule

Here is the biggest conceptual jump: S3 and S4 are functional. When you modify an object, R creates a copy of that object. This is great for data integrity, but it's terrible for things like database connections or complex state machines where you want to update a single object in place.

R6 gives you "reference semantics." It behaves like a class in Python or JavaScript. You define a class with fields and methods, and when you call a method, it modifies the object itself without copying it. This is incredibly powerful for building APIs or managing a session.

library(R6)

ProjectManager <- R6Class("ProjectManager",
  public = list(
    projects = list(),
    add_project = function(name, budget) {
      self$projects[[name]] <- budget
      cat("Project", name, "added!\n")
    },
    get_total_portfolio = function() {
      sum(unlist(self$projects))
    }
  )
)

my_manager <- ProjectManager$new()
my_manager$add_project("Website", 5000)
my_manager$add_project("App", 12000)
my_manager$get_total_portfolio() # 17000

Notice the self$ syntax. We aren't passing the object into a function; we're calling a method on the object. Just be careful: because R6 objects are mutable, if you pass my_manager to another function and that function changes a value, it changes for everyone. That's a side-effect that can lead to some very confusing bugs if you're used to the standard R way of doing things.




📋 Practical Task

Build a State-Tracking API Client

You need to simulate a simple API client that tracks how many requests have been made to prevent hitting a rate limit. Using R6, create a class called ApiClient with the following specifications:

  • A public field called request_count initialized to 0.
  • A public method called make_request() that:
    • Increments the request_count by 1.
    • Prints a message: "Request sent. Total requests: [count]".
  • A public method called reset_count() that sets request_count back to 0.

After defining the class, instantiate it as my_client, call make_request() three times, and then call reset_count() to verify the state changes correctly.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.