R OOP Exercises: 20 S3, S4 & R6 Practice Problems
Twenty scenario-based exercises that build fluency across R's three object systems: S3 (informal dispatch), S4 (formal slots and validity), and R6 (mutable reference semantics). Each problem ships with expected output so you can verify, and worked solutions are hidden behind reveal toggles so you actually try first.
Work through the sections in order. Section 1 builds S3 from the ground up, Section 2 introduces S4's formal contracts, Section 3 covers R6's mutable model, Section 4 stress-tests operator and subset dispatch, and Section 5 asks you to pick the right system for a given problem. Several exercises reuse classes you defined earlier, so keep the page open and the session live.
Section 1. S3: informal dispatch (5 problems)
Exercise 1.1: Build an S3 class for survey responses
Task: A research team running a small NPS survey wants tidy storage with a custom print. Build a constructor survey() that takes a numeric vector of scores between 0 and 10 and returns a list with class "survey" holding the scores and a creation date. Save the constructed object to ex_1_1.
Expected result:
class(ex_1_1)
#> [1] "survey"
ex_1_1$scores
#> [1] 9 10 8 7 6 9 4 5 10 8
Difficulty: Beginner
An S3 object is just an ordinary R value with a class label attached; validate the score range before you label it.
Build the list and set the class in one step with structure(..., class = "survey"), use stopifnot() for the 0-to-10 check, and Sys.Date() for the creation date.
Click to reveal solution
Explanation: S3 is the "informal" class system because there is no registry; you just attach a class string to an existing R object. The structure() helper builds the list and stamps the class attribute in one call. stopifnot() provides cheap argument checking. For stronger guarantees you would jump to S4 with setValidity(). Always prefer constructor functions to raw list(..., class = "x") so refactoring stays safe.
Exercise 1.2: Add a print method that dispatches by class
Task: Continue with the survey class from the previous exercise. Define print.survey() so calling print() on a survey object shows a one-line summary like <survey: n=10, mean=7.6> instead of dumping the raw list. Wrap the same scores into a fresh object and save it as ex_1_2, then print it.
Expected result:
ex_1_2
#> <survey: n=10, mean=7.6>
Difficulty: Intermediate
A method named after its generic and class is found automatically; yours only has to emit a single summary line.
Format the line with sprintf() fed by length() and mean(), write it out with cat(), and end by returning invisible(x).
Click to reveal solution
Explanation: print.survey is found by UseMethod("print") because the object's class attribute is "survey". The naming pattern generic.class is the entire S3 contract: R looks for <generic>.<class> first, then falls back to <generic>.default. Always return invisible(x) so the value still threads through pipelines without printing twice at the REPL.
Exercise 1.3: Chain inheritance with NextMethod for audit logs
Task: Build an audit-log class hierarchy. The base audit_log carries an events vector and a date; secure_audit_log inherits from it via the class vector c("secure_audit_log", "audit_log"). Implement print() so the secure subclass prints [SECURE] then calls NextMethod() to inherit the base format. Save a secure_audit_log instance to ex_1_3.
Expected result:
ex_1_3
#> [SECURE]
#> <audit_log: 3 events at 2026-05-13>
Difficulty: Advanced
A subclass print method should add only its own piece, then hand control to the parent so the base format is reused rather than rewritten.
Give the instance the class vector c("secure_audit_log", "audit_log"), cat() the [SECURE] line, then call NextMethod() to reach the base method.
Click to reveal solution
Explanation: The class vector encodes lookup order: UseMethod walks left to right, finds print.secure_audit_log, runs it, and NextMethod() re-dispatches to the next class in the vector. This is the S3 equivalent of super.print() in Java or C++, and it lets subclasses extend rather than replace parent behaviour. Forget NextMethod() and the base format disappears entirely.
Exercise 1.4: Operator overloading with the Ops group generic
Task: Build a money S3 class wrapping a numeric amount and a currency string. Implement Ops.money() so that + and - work between two money objects of the same currency and raise an informative error otherwise. Construct two USD objects, add them, and save the resulting money object to ex_1_4.
Expected result:
ex_1_4
#> <money: 175 USD>
Difficulty: Advanced
One method can serve a whole family of operators because R tells the method which symbol triggered the call.
In Ops.money(), reject the operands unless inherits(e2, "money") holds and the currencies match, then apply get(.Generic) to the two amounts.
Click to reveal solution
Explanation: Ops is a group generic that covers the entire family of arithmetic and comparison operators including +, -, *, /, ==, and <. R sets .Generic to the operator name as a string before calling the method, so get(.Generic) resolves the matching base function. Wrapping it in one method lets you handle a whole operator family without writing a separate dispatcher for every symbol.
Exercise 1.5: Format and compare a temperature class
Task: Build a temperature S3 class holding a numeric value in Celsius. Implement format.temperature() to render "21.0 C", a print.temperature() that delegates to format() and cat(), plus an Ops.temperature() method enabling <, >, and == comparisons. Compare two temperatures (21.0 and 19.5) and save the logical result to ex_1_5.
Expected result:
print(a); print(b)
#> 21.0 C
#> 19.5 C
ex_1_5
#> [1] FALSE
Difficulty: Intermediate
Keep the rendering logic apart from the printing logic so other callers can reuse the renderer, and let the comparison method accept only the operators you want.
Have format.temperature() return sprintf("%.1f C", ...), make print call cat(format(x)), and gate Ops.temperature() on .Generic %in% c("<", ">", "==", ...).
Click to reveal solution
Explanation: The Ops group covers arithmetic AND comparison operators; checking .Generic %in% c(...) lets you opt in to a subset and reject the rest with a single dispatcher. Splitting the renderer into a separate format() method is the canonical pattern: print() calls format(), and other consumers like paste() or as.character() can reuse the same renderer for free.
Section 2. S4: formal classes with validation (4 problems)
Exercise 2.1: Define an Employee class with typed slots
Task: Use setClass() to create an Employee S4 class with three slots: name (character), salary (numeric), and start_date (Date). Construct an instance for "Alice" earning 75000 starting 2024-03-15 and save it as ex_2_1. The slot type system will reject anything mistyped at construction time.
Expected result:
ex_2_1
#> An object of class "Employee"
#> Slot "name":
#> [1] "Alice"
#>
#> Slot "salary":
#> [1] 75000
#>
#> Slot "start_date":
#> [1] "2024-03-15"
Difficulty: Beginner
An S4 class needs an explicit definition that names every slot and its type before any instance can exist.
Pass representation(name = "character", salary = "numeric", start_date = "Date") to setClass(), then build the instance with new(), wrapping the date in as.Date().
Click to reveal solution
Explanation: S4 differs from S3 by requiring an explicit class definition through setClass() with typed slots. new() is the canonical constructor and enforces slot types at construction: passing a string to salary would error immediately. representation() is legacy syntax with the same effect as the modern slots = argument, but you will see it everywhere in published code so it is worth recognising.
Exercise 2.2: Reject invalid state with setValidity
Task: Define an S4 Patient class with slots age (numeric) and systolic_bp (numeric). Attach a validity function via setValidity() that rejects ages below zero or systolic_bp outside the 60 to 250 range. Try constructing a patient with systolic_bp = 400 inside tryCatch() and save the captured error message string to ex_2_2.
Expected result:
ex_2_2
#> [1] "invalid class \"Patient\" object: systolic_bp must be 60 to 250"
Difficulty: Intermediate
A validity check runs on every construction and must either describe a problem in words or signal that everything is fine.
Inside setValidity("Patient", ...), return() a message string when age is negative or systolic_bp falls outside 60 to 250, and return TRUE otherwise.
Click to reveal solution
Explanation: setValidity() registers a function that runs on every new() call and whenever you invoke validObject() explicitly. It must return TRUE for valid state or a character string describing the failure. S4 prepends a standard invalid class X object: prefix to your message, which is why the captured error reads as it does. Validity checks beat manual stopifnot() in constructors because they also fire on slot mutation.
Exercise 2.3: Compute billing with setGeneric and setMethod
Task: Continuing with the Employee class from 2.1, define a generic annual_cost() using setGeneric() and a method via setMethod() that returns salary * 1.30 to model a 30 percent overhead loading. Compute Alice's annual cost from the existing ex_2_1 object and save the numeric to ex_2_3.
Expected result:
ex_2_3
#> [1] 97500
Difficulty: Intermediate
S4 dispatch needs the function name registered first and a concrete implementation attached to the class second.
In the setMethod("annual_cost", "Employee", ...) body, read the salary through the @ accessor and multiply it by 1.30.
Click to reveal solution
Explanation: setGeneric() registers a function name that S4 will dispatch on, with standardGeneric() acting as the dispatch stub that selects a method based on argument classes. setMethod() attaches a concrete implementation to a class signature. The slot accessor @ is the S4 equivalent of $ for lists, but it is type-checked: writing x@nonexistent errors at runtime instead of silently returning NULL.
Exercise 2.4: Inherit with contains and callNextMethod
Task: Define an S4 Manager class that extends Employee using contains = "Employee" and adds a reports slot (integer count of direct reports). Override annual_cost() so a manager's overhead loading is 50 percent on base salary plus reports * 5000 headcount load. Construct a Manager named "Bob" with salary 75000 and 4 reports, then save the cost to ex_2_4.
Expected result:
ex_2_4
#> [1] 132500
Difficulty: Advanced
A subclass method can call the parent's version of the same generic and then reshape the result it gets back.
Use callNextMethod() to get Employee's loaded cost, divide the 1.30 back out, reapply 1.50, and add x@reports * 5000.
Click to reveal solution
Explanation: contains = "Employee" makes Manager inherit every slot and every method defined on Employee. callNextMethod() dispatches the same generic to the parent class signature, similar to NextMethod() in S3 but aware of S4's multiple dispatch. Dividing out the parent loading is a common idiom when you want a different multiplier on top of an inherited calculation rather than a plain additive delta.
Section 3. R6: mutable reference classes (4 problems)
Exercise 3.1: Build a Counter with public state
Task: Use R6Class() to define a Counter with a public field n initialised to zero and a method increment() that adds 1 to self$n. Instantiate, increment three times, then save the counter object (not its value) to ex_3_1. Read ex_3_1$n afterwards to confirm the count is 3.
Expected result:
ex_3_1$n
#> [1] 3
Difficulty: Beginner
An R6 object changes itself in place, so a method updates a field rather than returning a new object.
Declare n = 0 in the public list and write increment to do self$n <- self$n + 1, returning invisible(self).
Click to reveal solution
Explanation: R6 objects have reference semantics, so ex_3_1$increment() mutates the same instance every call without reassignment. Inside a method, self$ refers to the current object. Returning invisible(self) is the R6 idiom that enables method chaining, allowing Counter$new()$increment()$increment() to flow naturally without polluting the REPL with intermediate prints.
Exercise 3.2: Encapsulate state with private fields
Task: Define an R6 Account with a private numeric balance initialised in initialize() and public methods deposit(amount), withdraw(amount), and current(). withdraw must error if amount exceeds balance. Open an account, deposit 500, withdraw 120, and save the account object to ex_3_2. Call ex_3_2$current() to verify.
Expected result:
ex_3_2$current()
#> [1] 380
Difficulty: Intermediate
State that callers must not touch directly belongs behind a boundary that only the object's own methods can reach.
Set private$balance in initialize, adjust it inside deposit and withdraw, and make withdraw stop() when the amount exceeds the balance.
Click to reveal solution
Explanation: private fields are accessible only inside methods of the same object via private$. Calling ex_3_2$balance from outside returns NULL because the field is invisible to the public interface. This is the strongest encapsulation R offers: S3 and S4 have no private mechanism. Reach for private state when you want invariants that callers cannot accidentally clobber by direct assignment.
Exercise 3.3: Expose derived state with active bindings
Task: Build an R6 Body class with public fields height_m and weight_kg, plus an active binding bmi that computes weight divided by height squared every time it is read. Construct a Body with height 1.75 and weight 70, then save the value of body$bmi (a numeric scalar, not the object) to ex_3_3.
Expected result:
ex_3_3
#> [1] 22.85714
Difficulty: Intermediate
A derived value can look like a plain field to callers while actually running code on every read.
Define bmi in the active list, reject writes by testing missing(value), and return self$weight_kg / (self$height_m^2).
Click to reveal solution
Explanation: Active bindings look like fields to callers but execute a function on every access. The function receives value only when the binding is being assigned to; checking missing(value) is the idiom for declaring a read-only computed property. This is R6's answer to Python's @property decorator, useful for cached or derived quantities that should always stay in sync with their inputs.
Exercise 3.4: Copy-on-write via clone with deep semantics
Task: R6 objects copy by reference, so a <- b creates an alias, not a copy. Reuse the Counter from 3.1: create object a, increment it once, then make b <- a$clone(), increment a again, and save the count of b$n to ex_3_4 (which should still be 1, not 2). This verifies that clones really decouple from their source.
Expected result:
ex_3_4
#> [1] 1
Difficulty: Advanced
Plain assignment of an R6 object shares a single instance, so decoupling two names needs an explicit copy.
Call a$clone() to make b an independent object, then read b$n into ex_3_4.
Click to reveal solution
Explanation: $clone() produces a shallow independent copy, so subsequent mutations of a no longer touch b. Without clone, b <- a makes both names point to the same R6 environment and incrementing either mutates both. For nested R6 fields you also need clone(deep = TRUE), otherwise the inner R6 object remains shared between the copies and the bug surfaces only when you mutate something nested.
Section 4. Operator overloading and dispatch (3 problems)
Exercise 4.1: Render fractions with format and print
Task: Build a fraction S3 class with integer slots num and den. Write format.fraction() returning strings like "3/4" and print.fraction() that delegates to format() and cat() to render output. Construct a fraction representing three-quarters and save the printable object (not its character form) to ex_4_1. Print it to confirm the output is 3/4.
Expected result:
print(ex_4_1)
#> 3/4
Difficulty: Intermediate
Give the string-building and the screen-printing separate methods so each one has a single job.
Make format.fraction() return sprintf("%d/%d", x$num, x$den) and have print.fraction() send that through cat() with a trailing newline.
Click to reveal solution
Explanation: Splitting format from print lets other generics like as.character(), paste(), or sprintf() reuse the same renderer without duplication. The print() method simply adds a newline via cat(). A common mistake is to call print() recursively from inside the method body, which would re-enter dispatch on the same class and produce infinite recursion until the C stack overflows.
Exercise 4.2: Vector addition via Ops on a Vec2 class
Task: Define an S3 vec2 class wrapping a length-2 numeric (x, y). Implement print.vec2() that renders vec2(x, y) and Ops.vec2() so that + between two vec2 objects returns a new vec2 with elementwise sums, while any other operator errors with "unsupported". Add vec2(1, 2) and vec2(3, 4) and save the resulting vec2 to ex_4_2.
Expected result:
ex_4_2
#> vec2(4, 6)
Difficulty: Intermediate
A single operator method can let one symbol through and turn every other one away with a uniform error.
In Ops.vec2(), call stop("unsupported") unless .Generic equals "+", then return a vec2() of the elementwise sums.
Click to reveal solution
Explanation: The Ops group dispatches arithmetic and comparison operators based on .Generic. Filtering on .Generic != "+" lets you allow a single operator while rejecting the rest with a uniform error message. Adding a print.vec2() method is essential because the default list printer would dump messy $x, $y, and attr(,"class") lines, hiding the actual structure under noise.
Exercise 4.3: Implement subsetting with custom bracket methods
Task: Build a ts_simple S3 class storing two parallel numeric vectors time and value. Implement [.ts_simple so x[i] returns a sub-ts_simple with both vectors indexed by i. Construct a 10-point series with time = 1:10 and ten increasing values, take the first three points, and save the subsetted ts_simple to ex_4_3.
Expected result:
ex_4_3
#> ts_simple:
#> time value
#> 1 1 10
#> 2 2 12
#> 3 3 15
Difficulty: Advanced
The subsetting bracket is itself a generic, so a class can define what indexing into it should mean.
Define [.ts_simple to index both the time and value vectors by i and rebuild a ts_simple() so the result keeps the same class.
Click to reveal solution
Explanation: The functions [, [[, and $ are themselves S3 generics, which is why custom subset methods integrate seamlessly with the rest of base R. Backticks are required around ` [.ts_simple because square brackets are not valid in a bare identifier. Returning a same-class object from the subset method preserves chained subsetting, so full[1:5][2]` continues to work as expected.
Section 5. Choosing the right system (4 problems)
Exercise 5.1: Refactor an S3 stack into R6 for true mutation
Task: An S3 stack written as push_s3(stack, x) returns a fresh list every call, forcing callers to reassign stack <- push_s3(stack, x). Rewrite it as an R6 Stack class with push(), pop(), and size() methods that mutate in place. Push 10, 20, and 30 onto a fresh stack and save the resulting size to ex_5_1.
Expected result:
ex_5_1
#> [1] 3
Difficulty: Intermediate
When an operation should alter a structure rather than hand back a fresh copy, reference semantics fit better than value semantics.
Give the R6 Stack an items list, append in push via self$items[[length(self$items) + 1]] <- x, and have size return length(self$items).
Click to reveal solution
Explanation: S3's value semantics turn every "mutating" operation into a copy-and-replace, which is fine for pure transforms but awkward for stateful structures like stacks, queues, and caches. R6 trades that simplicity for in-place mutation, eliminating the "I forgot to reassign" bug class. Pick R6 when state actively evolves over time, and pick S3 or S4 when your transforms are pure functions of their inputs.
Exercise 5.2: Wrap an R6 object in S4 for typed slots
Task: Imagine you need an R6 cache for performance but also want S4 slot type checking at the API boundary. Define an S4 CacheHandle with one slot engine of class "R6" (registered via setOldClass("R6")). Wrap an R6 Cache instance inside the handle and save the handle (the S4 object) to ex_5_2. Verify both class layers.
Expected result:
class(ex_5_2)[1]
#> [1] "CacheHandle"
class(ex_5_2@engine)[1]
#> [1] "Cache"
Difficulty: Intermediate
An S4 slot can hold an object from another class system once that system's class is made known to S4.
Call setOldClass("R6"), declare the engine slot with type "R6" in setClass(), and pass Cache$new() to new().
Click to reveal solution
Explanation: setOldClass("R6") registers the R6 class string with the S4 system so it can be used as a slot type. The outer S4 layer enforces type-checking at slot assignment, while the inner R6 layer carries mutable state. This hybrid pattern appears when interfacing with packages that demand formal S4 class signatures (Bioconductor, for example) while you still need stateful internals.
Exercise 5.3: Pick the right system for an immutable value type
Task: You need a class for immutable currency conversion rules: a base currency plus a named numeric vector of conversion rates. The rates never change after construction, and conversions are pure functions of input. Pick S3 (the lowest-friction choice for pure value types) and implement a convert() generic with a method. Save the rules object to ex_5_3 and run a conversion.
Expected result:
convert(ex_5_3, 100, "EUR")
#> [1] 85
Difficulty: Beginner
For a pure, immutable value type, reach for the lightest object system, the one with no class registry or constructor ceremony.
Build the object with structure(..., class = "rates"), declare the generic with UseMethod("convert"), and write convert.rates() to multiply the amount by the looked-up rate.
Click to reveal solution
Explanation: For immutable value types with no shared state and only light validation, S3 is the lowest-friction option. No class registry, no new() call, no slot ceremony, just a structure with a class string. Reserve S4 for cases where you genuinely need formal type contracts (Bioconductor, validated domain models), and R6 for cases where mutation is the central point of the abstraction.
Exercise 5.4: Benchmark dispatch overhead across three systems
Task: Define a trivial noop() operation in all three systems (S3 via UseMethod, S4 via setGeneric, R6 via a method) and time 100000 dispatch calls of each with system.time(). Capture elapsed seconds, name them s3, s4, r6, and save the named numeric vector to ex_5_4. Compare which system has the lowest dispatch overhead.
Expected result:
ex_5_4
#> # approximate elapsed seconds for 100k dispatches
#> s3 s4 r6
#> 0.10 0.45 0.20
Difficulty: Advanced
Run the same trivial call many times under each system and record only the wall-clock time it takes.
Wrap each loop in system.time(), pull out the "elapsed" element, and combine the three timings with setNames(c(...), c("s3", "s4", "r6")).
Click to reveal solution
Explanation: S3 dispatch is the cheapest because UseMethod() is a thin C call that walks the class vector. S4 dispatch is the slowest in classic benchmarks because it walks a class hierarchy and selects the best-matching signature, sometimes with multiple arguments. R6 dispatch is close to S3 because methods are stored as plain environment lookups. The exact ratios depend on R version and JIT, but the ordering is stable.
What to do next
If any system felt fuzzy, the parent tutorial walks through it in full:
- OOP in R covers S3, S4, and R6 with worked examples and a system-selection cheat sheet.
- Functional Programming in R explores the closures-and-environments toolkit that R6 builds on top of.
- R Closures explains the environment mechanics that make R6 private fields and active bindings actually work.
Practice more exercise hubs at the Practice Exercises section of the sidebar.
r-statistics.co · Verifiable credential · Public URL
This document certifies mastery of
R OOP Mastery
Every certificate has a public verification URL that proves the holder passed the assessment. Anyone with the link can confirm the recipient and date.
124 learners have earned this certificate