R Promise Objects: Lazy Evaluation & Force() Explained
An R promise object is a recipe R creates for every function argument: an unevaluated expression, an environment to evaluate it in, and an empty value slot that fills on first use. This mechanism, lazy evaluation, lets R skip work it doesn't need, but it also hides a few traps that cost R programmers real hours.
What is a promise object in R?
The quickest way to see a promise in action is to pass a side-effecting expression as an argument and watch when R decides to run it. Below, show_when() takes two arguments but only uses the first. Notice how the second argument, a cat() call that would normally print immediately, stays silent because R never needed to look at it.
The function prints a marker, uses argument a, then exits without ever touching b. If R evaluated arguments eagerly (like Python or JavaScript), the cat() inside b would fire as soon as we called the function. It doesn't.
The ">> b was evaluated!" line never appears. When R called show_when(), it packaged the expression { cat(...); 999 } and the calling environment into a promise and bound that promise to the formal argument b. The promise sat there, unused, until show_when() returned. No evaluation, no side effect, no cost.
Every promise has three slots:
- Expression, the unevaluated code you wrote (
{ cat(...); 999 }) - Environment, where that expression should be evaluated (here, the global environment)
- Value, empty at first, filled when the promise is forced (first access)
Try it: Write a function ex_lazy(x, y) that returns x * 2 and never uses y. Pass a noisy cat() expression as y and confirm it stays silent.
Click to reveal solution
Explanation: Because y is never referenced in the body, its promise is never forced. The cat() call lives inside the promise's expression slot and is discarded when the function returns.
How does lazy evaluation actually work step by step?
Now that you've seen a promise stay unforced, let's trace the full lifecycle. When R encounters f(x + 1), it does four things in order:
- Wrap, build a promise holding the expression
x + 1plus a pointer to the caller's environment. - Bind, attach that promise to
f's formal parameter name. - Force, the first time
f's body reads the parameter, evaluate the expression in its captured environment. - Cache, store the result in the value slot. Every subsequent read returns the cached value directly.
That caching step matters. A promise is forced at most once, so referencing an argument five times does not recompute it five times. Here's the proof:
Despite x being read twice, noisy() executed exactly once. On the first read, R saw an unforced promise, ran noisy() in the global environment, got 42, and stored it. On the second read, the promise's value slot was already populated, so R skipped straight to the cached number.
Try it: Add a third read of x inside use_twice() and predict what counter will be after one call. Run it to check.
Click to reveal solution
Explanation: No matter how many times x appears, only the first access forces the promise. The value slot is filled with 42 and every subsequent read is a plain lookup.
Why do default arguments see other function arguments?
You've probably written (or seen) a function like function(x, n = length(x)), a default that refers to another argument. It works because of one rule: default arguments are evaluated in the function's own environment, not the caller's. By the time R needs n, x is already bound in the function frame, so length(x) has something to look up.
Here's the friendly version:
When you omit n, R wraps the default expression length(x) in a promise whose environment is the call frame of smart_default. The moment the body reads n, that promise forces, finds x in the same frame, and returns 10. When you pass n = 3, the default is never consulted, your supplied promise takes its place.
Now the unfriendly version. The same rule means a default can reference anything in the function's local scope, including variables created after the formal parameters. Hadley's classic h05() example shows how strange that gets:
Same expression, ls(), two completely different results. The default version runs in h05's frame, where the locals are a and x, so ls() returns c("a", "x"). The supplied version runs in the caller's frame (the global environment), where ls() returns whatever globals happen to exist.
function(x, n = length(x)) patterns, dangerous when a default references a local whose meaning the caller can't see.Try it: Write ex_append(x, suffix = paste0("-", length(x))) that returns paste0(x, suffix). Call it with and without suffix.
Click to reveal solution
Explanation: The default paste0("-", length(x)) is wrapped in a promise whose environment is the call frame. When forced, it sees x already bound and computes the suffix on the fly.
What is the closure-loop trap and how does force() fix it?
This is the bug that lazy evaluation is infamous for. Build closures inside a for loop that captures the loop variable, and every closure will end up pointing at the same final value. Watch:
All three adders return 13. Why? Each call make_adder(i) created a promise for n whose expression was the symbol i and whose environment was the global frame. That promise stayed unforced because the inner function function(x) x + n doesn't touch n until you call it. By the time you call the first adder, the loop has already finished and i equals 3. Every promise forces to the same number.
The fix is to force the promise eagerly inside the factory, before the inner function is returned. force(n) is the idiomatic way to say "evaluate this promise now, capture its value":
Correct. On iteration 1, force(n) ran inside make_adder_safe's frame, evaluated i (which was 1), and cached 1 in the value slot. The inner closure's reference to n now resolves to that frozen 1, regardless of what i does later. Iterations 2 and 3 froze their own promises the same way.
force() their captured argument. It's a silent, frustrating bug, the code looks right, it doesn't error, it just returns the wrong numbers. If you're building closures that capture anything from an enclosing environment, add force() as a reflex.Technically, force(x) is nothing more than identity(x), it does not exist to do work, only to make the intent unmistakable. Writing n on its own line would also force the promise; force(n) just tells the next reader why.
Try it: The factory below builds personalised greeters but has the same bug. Add force() to fix it.
Click to reveal solution
Explanation: Without force(name), every closure captures the same unforced promise for name, which later resolves to "Cai", the loop's final value. Forcing inside the factory freezes each closure's copy.
How does substitute() let you inspect a promise?
So far we've talked about forcing a promise, getting its value. substitute() does the opposite: it reads the expression slot without triggering evaluation. It gives you the raw code the caller typed.
substitute(x) peered inside the promise and handed back the expression slot, the exact code the caller wrote. Then x on the next line forced the promise normally to get the value. Both slots, one promise.
This is the engine behind a lot of "magic" R functions. plot(x, y) labels its axes "x" and "y" because it calls substitute() on its arguments. curve(sin(1/x^2), 0, 1) captures sin(1/x^2) as an expression, never evaluates it in the caller, and re-evaluates it across a grid of x values. dplyr's filter(cyl == 4) works because filter() uses substitute-like tools to capture cyl == 4 and evaluate it inside the data frame instead of the caller.
substitute() only captures the promise at its own argument position. If function outer(x) passes x down to inner(x), and inner() calls substitute(x), it sees the symbol x, not the original expression from the outermost caller. Metaprogramming across function boundaries needs rlang::enquo() or manual expression passing.Try it: Write ex_describe(x) that returns a named list with expression (a character string via deparse) and value.
Click to reveal solution
Explanation: substitute(x) returns an R language object; deparse() converts it to a string. Reading x on the next line forces the promise and gives the value.
Can I create promises manually with delayedAssign()?
Function arguments are the usual source of promises, but you can create one at the top level too. delayedAssign(name, expr) binds name to a promise that holds expr unevaluated, the expression runs the first time you reference name, and never again.
Two things to notice. First, the cat() message printed exactly once, exactly on the first read of big_calc. That's the familiar "force and cache" rule, now applied to a top-level binding instead of a function argument. Second, after the first access, big_calc looks like an ordinary variable holding 500000500000, the promise has done its job.
This is how many R packages expose large reference datasets: delayedAssign on package load, and the dataset stays compressed on disk until a user actually reads it.
makeActiveBinding() instead.** delayedAssign() fires once and caches; makeActiveBinding() fires every time. Different tools for lazy-once vs lazy-always.Try it: Use delayedAssign() to create ex_config whose expression reads the current time via Sys.time(). Confirm that the same time shows up on repeated reads.
Click to reveal solution
Explanation: Sys.time() runs once, during the first access to ex_config. The returned time is cached in the promise's value slot, so the second read returns the same cached timestamp, not a fresh one.
Practice Exercises
These pull together several ideas from the tutorial. Use distinct variable names (prefixed my_) so exercise code doesn't clobber the teaching examples above.
Exercise 1: A safe multiplier factory
Write make_multiplier(n) that returns a function of x computing x * n. Build five multipliers in a for loop (for n = 2 through 6) and store them in a list called my_mults. Verify that each multiplier returns the correct result, my_mults[[1]](10) should give 20, my_mults[[5]](10) should give 60.
Click to reveal solution
Explanation: Without force(n), all five closures would capture the same unforced promise for k and return 60 (the loop's final value times 10). force(n) evaluates and caches on each call, freezing n at 2, 3, 4, 5, 6 respectively.
Exercise 2: Build your own once()
Write once(fn) that takes a zero-argument function fn and returns a new function that runs fn() the first time it's called and returns the cached result on every subsequent call. Do this using a local environment (not delayedAssign). Test on a random-number generator and confirm two calls give the same number.
Click to reveal solution
Explanation: A small environment (cache) persists across calls because the returned closure holds a reference to it. The first call evaluates fn() and stores the result; later calls short-circuit to the cached value. Notice the force(fn) at the top, if you built multiple once() wrappers in a loop, you'd hit the closure-loop trap without it.
Exercise 3: A default that survives the h05 trap
Write safe_default(x, n = length(x)) that returns head(x, n), but guards against the trap shown earlier: if the caller passes their own n, use it; if they omit n, fall back to length(x) computed inside the function. Use missing() to tell the two cases apart.
Click to reveal solution
Explanation: missing(n) returns TRUE if the caller didn't supply n, it checks whether n's promise still holds the default expression. Branching on missing() makes defaults explicit and prevents surprises when a default references other locals in the function body.
Putting It All Together: a lazy config loader
Here's a small real-world scenario that ties promises, force(), and delayedAssign() together. Imagine a configuration object where each value is computed on demand, reading a file, contacting a database, running an expensive calculation, and you only pay for the keys you actually touch.
lazy_config() accepts a list of name → expression pairs (passed via quote() so the expressions don't evaluate at call time). It stores each expression as a promise inside a dedicated environment and returns that environment. Reading a key forces the corresponding promise exactly once.
Three things to observe. First, building cfg printed nothing, no expression was evaluated yet; each became a promise. Second, the first read of cfg$db_host printed the "resolving" message and returned the value; the second read printed nothing, because the promise was already forced and cached. Third, cfg$heavy_calc never ran at all, its expression still sits in an unforced promise inside cfg and will never fire unless someone reads it.
The force(k) and force(expr) inside local() are the same closure-loop defence from Exercise 1, without them, every delayedAssign() would capture the final loop values of key and entries[[key]], and all three keys would resolve to the same expression.
Summary
| Concept | What it is | When you notice it |
|---|---|---|
| Promise | Expression + environment + value slot | Every function call |
| Lazy evaluation | Delay compute until first access | Unused arguments never run |
force(x) |
Evaluate a promise now, cache the result | Function factories inside loops |
substitute(x) |
Read the expression slot without forcing | Metaprogramming, NSE, plot() axis labels |
| Default arg scoping | Defaults evaluate in the function's environment | function(x, n = length(x)) works |
h05() trap |
Same expression, different meaning as default vs supplied | Defaults that reference locals |
| Closure-loop trap | Closures share one unforced promise | for + function factory |
delayedAssign() |
Create a top-level promise by hand | Package datasets, lazy config |
References
- Wickham, H. Advanced R (2nd ed.), §6.5 Lazy evaluation. CRC Press, 2019. Link
- R Core Team,
force()documentation. Link - R Core Team,
delayedAssign()documentation. Link - Gągolewski, M. Deep R Programming, Chapter 17: Lazy evaluation. Link
- Fay, C., About lazy evaluation (2018). Link
- Mailund, T., Promises, their environments, and how we evaluate them. Link
- R Core Team, R Internals, §1.3 Promise objects. Link
Continue Learning
- R Lexical Scoping, the rule that tells R where to look up a promise's expression. Lazy evaluation decides when; scoping decides where.
- R Closures, how functions remember the environment they were born in, which is exactly what makes the closure-loop trap bite (and what makes it fixable).
- R Environments, the containers that promises live inside, and the thing
eval.envindelayedAssign()actually points to.