R Programming / Evaluation, ownership, and reproducible results / Lesson 14
S3 methods, environments, and evaluation
Understand dispatch and reference state without relying on fragile class labels or hidden evaluation.
On this page
Lesson goals
- Separate representation validation from class assignment.
- Trace S3 generic dispatch for a small object.
- Distinguish environment identity from ordinary value semantics.
Class is a behavior contract rather than a proof
An S3 class attribute influences how generic functions select methods. Assigning a class string does not automatically validate the object’s fields, units, or invariants. A small constructor can check the required representation before assigning the class, while methods should preserve that contract. The first fixture creates a scalar score object and defines its print method. This is intentionally a narrow teaching class, not a complete numeric abstraction. Supporting arithmetic, subsetting, missingness, or inheritance requires additional explicit design, so avoid promising all vector behavior merely because one print call succeeds.
A generic selects behavior from the class
print is a generic with methods for different object classes. For the supplied lab_score object, the method prints one stable line and returns the object invisibly so display does not become a destructive transformation. S3 dispatch generally follows a class vector and documented method lookup rules; some internal and group generics have additional details. Use methods and class inspection when behavior surprises you. Calling a specific method directly can bypass the intended generic contract, so test through the public generic as well as any internal helper. A method name alone does not establish package registration or complete interoperability.
new_lab_score <- function(value) {
stopifnot(is.numeric(value), length(value) == 1L, is.finite(value))
structure(list(value = value), class = "lab_score")
}
print.lab_score <- function(x, ...) {
cat("lab_score:", x$value, "\n", sep = "")
invisible(x)
}
x <- new_lab_score(7)
print(x)
cat("inherits=", inherits(x, "lab_score"), "\n", sep = "")
lab_score:7
inherits=TRUE
The generic print selects the class method, while the constructor checks the representation invariant before assigning the class.
Environments provide shared mutable bindings
An environment contains named bindings and a link to an enclosing environment. Assigning an environment to another name preserves reference identity: modifying a binding through either name affects the same environment. The second fixture contrasts that with ordinary list-value modification. Such shared state can be useful for a cache or controlled counter, but it introduces dependencies that tests and callers must know about. Distinguish the enclosing environment used for lexical lookup from the caller’s evaluation frame. Changing enclosure links to force name lookup is a fragile substitute for an explicit function interface.
state <- new.env(parent = emptyenv())
state$value <- 1
alias <- state
alias$value <- 2
x <- list(value = 1)
y <- x
y$value <- 2
cat("environment original=", state$value, "\n", sep = "")
cat("list original=", x$value, "; modified=", y$value, "\n", sep = "")
environment original=2
list original=1; modified=2
Environment aliases refer to the same mutable bindings, unlike the ordinary value semantics illustrated by these list modifications.
Lazy evaluation changes when an expression runs
An ordinary function argument is supplied as a promise that combines an expression and evaluation context, and is evaluated when needed. This permits useful interfaces, but can make captured expressions sensitive to when they are forced. A default argument and a supplied argument can have different evaluation contexts under R’s rules. When storing configuration in a closure, force the intended value at the intended boundary and test changes to referenced state. Do not evaluate arbitrary text to obtain a numeric parameter when a normal argument or explicit parser would express the contract more clearly.
Keep metaprogramming proportional to the interface
R can manipulate calls, symbols, and expressions, which supports formulas and domain-specific interfaces. That power increases the need to state which environment resolves names and whether side effects are allowed. Prefer explicit data and character column names for ordinary reusable transformations unless expression capture offers a meaningful user benefit. Test free-name collisions, absent columns, and repeated calls with changed state. The examples use only trusted synthetic objects, with derived expected results and no R execution. They explain dispatch, identity, and evaluation concepts without claiming that a package’s method registration or evaluator was tested.
Common errors and fixes
An object has the right class but a method fails on its fields
- Why it happens
- Class assignment bypassed representation validation.
- How to fix it
- Construct and validate the required fields before assigning the class.
- Prevent it next time
- Test malformed objects at the public boundary.
Two apparently separate caches change together
- Why it happens
- Both names reference the same environment.
- How to fix it
- Make sharing deliberate or construct independent environments.
- Prevent it next time
- Test aliasing and reset behavior explicitly.
A captured expression uses an unexpected value later
- Why it happens
- Lazy evaluation deferred reading mutable referenced state.
- How to fix it
- Force a deliberate configuration value or pass explicit data.
- Prevent it next time
- Test capture timing and free-name changes.
Check your understanding
1. Why does adding class lab_score to list(value="seven") not make it a valid score?
Show worked solution
The class label changes dispatch but does not turn text into the required finite numeric scalar. A constructor must reject the representation or perform a separately justified conversion. Methods should rely on a documented invariant rather than interpret every malformed object ad hoc.
2. Why does changing alias$value affect state$value while changing y$value leaves x$value at one?
Show worked solution
state and alias name the same environment and therefore share bindings. The list example follows ordinary value semantics for that modification, so y receives its own changed value without changing x. Do not generalize this list example to nested reference objects stored inside a list.
Key takeaways
- Class labels and representation validity are separate.
- Generic dispatch should be tested through its public entry point.
- Environment sharing and promise timing are observable state behavior.