OOP in R Exercises: 8 S3, S4 & R6 Practice Problems, Solved Step-by-Step)
These 8 practice problems build real fluency in R's three major OOP systems, S3 (informal), S4 (formal, validated), and R6 (mutable reference), plus operator overloading and method dispatch. Every exercise ships with starter code and a worked solution you can run in the browser.
Work through them in order. Each one uses a real pattern you'd ship in production code, and the exercises get progressively harder, simple S3 first, then S4 validation, then R6 mutation, then a synthesis problem that asks which system fits a given scenario. If you're fuzzy on any system, the parent tutorial OOP in R is your fallback.
How Should You Use These Exercises?
Every code block on this page shares one R session, so variables and class definitions you create in one exercise are still available in the next. The warm-up below confirms that R6 and the methods package (which powers S4) are both loaded, and creates one tiny demo object in each system so you can see them side by side.
All three systems work. Each exercise below gives you a starter block with a scaffold and expected output, followed by a collapsible worked solution with a short explanation. Type your own answer before opening the reveal, that's where the learning happens.
class(), UseMethod(), and setClass() do. If those feel unfamiliar, read OOP in R first and come back.Exercise 1: Can You Build an S3 Temperature Class?
S3 is the lightest OOP system in R. A class is just a character vector attached to an object via class(), and a method is a function named <generic>.<class>. Build an S3 class Temperature that stores a numeric value plus a unit ("C" or "F"), prints itself nicely, and exposes a to_celsius() generic that converts Fahrenheit to Celsius.
Click to reveal solution
Explanation: The constructor wraps the inputs in a list and tags it with class = "Temperature". structure() is the idiomatic one-liner for this. print.Temperature is picked up automatically when you call print(temp_f) because R sees class Temperature and looks for print.Temperature. The generic to_celsius() calls UseMethod(), which is S3's dispatch mechanism, it finds to_celsius.Temperature by matching the class of the first argument.
<generic>.<class>, never the other way around. Writing Temperature.print would just create a function with a dot in its name, it would not be dispatched to when you call print(temp_f).Exercise 2: Can You Write a Generic That Dispatches to Three Methods?
S3 dispatch is pure string matching. When you call a generic, R looks up the class of the first argument and searches for <generic>.<class>. If it doesn't find a match it falls back to <generic>.default. Write a describe_ex2() generic with methods for numeric, character, and factor, plus a default that says "unknown type".
Click to reveal solution
Explanation: Each method takes the same argument name as the generic (x) and returns a string. UseMethod("describe_ex2") inspects class(x) and searches for describe_ex2.<class>. For 1:10, R sees class "integer" and then "numeric", if it finds a method for either, it dispatches there. The default handler catches anything R doesn't have a specific method for, which is how TRUE (class "logical") ends up at describe_ex2.default.
<generic>.<class>, and calls whatever function exists at that name. That simplicity is why S3 powers 90% of base R and the tidyverse.Exercise 3: Can You Create an S4 Account Class With Validation?
S4 is the stricter, more formal sibling of S3. You declare slots with types up front using setClass(), optionally attach a validity function, and dispatch with setGeneric() + setMethod(). Build an Account class with a numeric balance slot and a character owner slot. Add a validity rule that the balance cannot be negative, and a deposit() generic that adds to the balance.
Click to reveal solution
Explanation: representation() lists the slots and their required types, assigning a character to balance would error at construction time. setValidity() attaches a function that runs on new() and on every slot update, returning TRUE for valid or an error string. setGeneric() registers deposit as an S4 generic, and setMethod() binds an implementation for objects of class Account. Slots are accessed with @, not $.
new() and every slot write via @. That means acct1@balance <- -5 also fails the check, S4 will not silently let you corrupt the object. This is a feature, not a bug, and is a big reason bioconductor and pharma pipelines rely on S4.Exercise 4: Can You Use S4 Multiple Dispatch for Geometric Intersections?
S4's most unique feature is multiple dispatch: a method can dispatch on the classes of several arguments, not just the first. Define S4 classes Circle (with radius) and Rectangle (with width, height), then write an intersects() generic that takes two shapes and returns a string describing which pair it was called with. You need three methods: (Circle, Circle), (Circle, Rectangle), and (Rectangle, Rectangle).
Click to reveal solution
Explanation: setGeneric("intersects", function(a, b) ...) declares that the generic takes two arguments, and setMethod() uses signature() to declare which class combination each implementation handles. When you call intersects(cc1, rr1), S4 looks at the classes of both arguments, Circle and Rectangle, and finds the exact method registered for that pair. No other OOP system in R does this natively.
signature("Circle", "Rectangle") will not fire when you call intersects(rectangle, circle), you'd need to register a separate signature("Rectangle", "Circle") method, or make the generic commutative by sorting the inputs before dispatch.Exercise 5: Can You Build a Counter Class With R6?
R6 is R's mutable, reference-based OOP system. Unlike S3 and S4 (which return new objects on modification), an R6 object changes in place. Build a Counter class with a private count field starting at zero, and three public methods: increment(), decrement(), and get_count().
Click to reveal solution
Explanation: R6Class() returns a class generator, calling $new() on it creates an instance. Inside methods, self$ refers to public fields and private$ to private ones. Returning invisible(self) from increment() and decrement() is the standard R6 pattern for chainable methods: you can write my_counter$increment()$increment()$decrement() in a single line. Each call mutates the same object, there's no copy.
my_counter$increment() changes the original object. If you do other <- my_counter and then other$increment(), my_counter also changes, because they point to the same underlying environment. This is the opposite of base R's copy-on-modify, and it's exactly what you want for stateful things like game loops, Shiny reactive sessions, or database connections.Exercise 6: Can You Extend Counter Into a BoundedCounter?
R6 supports single inheritance via the inherit = argument. A subclass can add new fields, add new methods, or override existing ones, and when it overrides, it can still call the parent via super$. Extend Counter into BoundedCounter that accepts a max value in initialize() and refuses to go above max or below 0. Reuse the parent's increment() and decrement() logic wherever possible.
Click to reveal solution
Explanation: inherit = Counter pulls in every public and private member from the parent. We override initialize() to capture the max argument, and we override increment() and decrement() to guard against hitting the bounds. When the guard passes, we delegate to the parent's implementation with super$increment(), no need to reimplement private$count <- private$count + 1 ourselves. get_count() wasn't overridden, so it just inherits unchanged.
super$ is how you reuse parent logic even after overriding it. Without super$, you'd have to re-declare private$count <- private$count + 1 inside the subclass, a classic inheritance footgun where the child silently drifts out of sync with the parent.Exercise 7: Can You Overload + and - for an S3 Money Class?
R lets you define methods for operators (+, -, *, ==, <, etc.) by writing Ops.<class>, a single function that dispatches on the special variable .Generic, which tells you which operator was called. Build an S3 Money class that holds an amount and a currency, supports + and - between two Money values of the same currency, and prints as "$100.00 USD".
Click to reveal solution
Explanation: Inside Ops.Money, the magic variable .Generic contains the string name of the operator that was invoked ("+", "-", etc.). We switch() on it to decide how to combine the two operands. Anything we don't handle explicitly falls through to the stop() branch, so wallet * paycheck errors instead of silently doing the wrong thing. The result is wrapped back into a new Money object via the constructor.
Ops.<class> function gets you 20 operators for free. The Ops group generic covers arithmetic (+ - * / ^ %% %/%), comparison (== != < > <= >=), and logic (& | !). Use .Generic to branch on which one was called, and only implement the ones that make sense for your class.Exercise 8: Which OOP System Should You Pick for Each Scenario?
There's no universally "best" OOP system in R, each solves a different problem. For the three scenarios below, pick S3, S4, or R6 and justify why in one sentence. Then write a minimal sketch showing the class definition for each one.
Scenarios:
- You're writing
summary()for a new model-fitting function. The object is created once, printed once, and then discarded. - You're building a pharma data pipeline where every patient record must pass strict type and range validation before processing.
- You're writing a turn-based game where a
Playerobject's HP, mana, and position change every tick.
Click to reveal solution
Explanation: Scenario 1 is ephemeral output with no constraints, S3 is the simplest tool that answers the need. Scenario 2 is exactly the case S4 was designed for: formal types, validated slots, and a predictable contract you can audit. Scenario 3 needs mutation, a fresh copy of a player every time their HP changes would break the game loop, so R6's reference semantics are the right fit.
Summary
A quick cheat sheet tying the three systems together. Keep this handy while you work, most of the "which system?" questions resolve in under a minute once you know the trade-offs.
| System | Best for | Constructor | Dispatches on | Mutable? |
|---|---|---|---|---|
| S3 | Lightweight classes, print/summary methods, most tidyverse code | structure(list, class = ...) |
Class of first argument | No (copy-on-modify) |
| S4 | Strict validation, bioconductor, pharma, multiple dispatch | new("ClassName", ...) |
Any number of arguments | No (copy-on-modify) |
| R6 | Mutable state, Shiny sessions, game loops, database handles | Generator$new(...) |
Method lookup on object | Yes (reference) |
You now have working templates for eight real OOP patterns in R. The biggest payoff from this exercise set is not memorising syntax, it's developing the instinct to ask "should this be S3, S4, or R6?" before you write any class at all.
References
- Wickham, H., Advanced R, 2nd Edition. Chapters 12 (S3), 15 (S4), 14 (R6). Link
- R6 package vignette, Introduction to R6. Link
methodspackage documentation,setClass,setGeneric,setMethod,setValidity. Link- sloop package, inspect and debug OOP systems at runtime with
otype(),s3_dispatch(),s4_methods_class(). Link - Chambers, J., Object-Oriented Programming, Functional Programming and R. Statistical Science, 2014. Link
- r-statistics.co, OOP in R: Systems Compared (parent tutorial)
Continue Learning
- OOP in R, the parent tutorial that introduces all four systems side by side and gives you the decision framework
- S3 Classes in R, deep dive on S3 with full method dispatch internals
- R6 Classes in R, when and why to reach for mutable reference objects in Shiny and beyond