R Promise Objects: Lazy Evaluation & Force() Explained
When you call a function in R, arguments are not evaluated immediately. Instead, R creates promise objects — recipes that say "evaluate this expression when (and if) it's actually needed." This is lazy evaluation, and it's both a feature and a source of subtle bugs.
How Lazy Evaluation Works
When you call f(x + 1), R does not compute x + 1 first. It packages the expression x + 1 and the environment to evaluate it in as a promise, and passes that to f. The promise is only evaluated when f actually uses the argument:
# Lazy evaluation: arguments aren't evaluated until used
lazy_demo <- function(a, b) {
cat("About to use 'a'\n")
cat("a =", a, "\n")
cat("'b' is never used — its expression is never evaluated\n")
}
# Even though 1/0 would cause issues, b is never evaluated
lazy_demo(42, {cat("b is being evaluated!\n"); 999})
# This means side effects in arguments happen at use time, not call time
log_and_return <- function(label, value) {
cat(sprintf("[%s evaluated]\n", label))
value
}
process <- function(x, y) {
cat("Function started\n")
result <- x # x is evaluated here
cat("After using x\n")
# y is never used, so it's never evaluated
result
}
process(log_and_return("x", 10), log_and_return("y", 20))
The Lazy Evaluation Trap in Loops
The most common bug from lazy evaluation occurs with closures in loops:
# The classic trap
fns <- list()
for (i in 1:3) {
fns[[i]] <- function() cat("i =", i, "\n")
}
# All print 3! Because i is evaluated lazily when called, not when defined
fns[[1]]()
fns[[2]]()
fns[[3]]()
# Fix with force()
fns_fixed <- list()
for (i in 1:3) {
local({
forced_i <- i # force evaluation by assigning to local variable
fns_fixed[[length(fns_fixed) + 1]] <<- function() cat("i =", forced_i, "\n")
})
}
fns_fixed[[1]]()
fns_fixed[[2]]()
fns_fixed[[3]]()
force(): Evaluate Now
force(x) simply evaluates x immediately. It's equivalent to just writing x, but makes the intent clear:
# force() in function factories
make_adder <- function(n) {
force(n) # Evaluate n NOW, not later
function(x) x + n
}
# Without force(), this pattern can have subtle bugs
# when the argument comes from a changing variable
adders <- lapply(1:5, make_adder)
cat("adders[[1]](10) =", adders[[1]](10), "\n") # 11
cat("adders[[3]](10) =", adders[[3]](10), "\n") # 13
cat("adders[[5]](10) =", adders[[5]](10), "\n") # 15
# Default arguments are evaluated lazily IN the function's environment
smart_default <- function(x, n = length(x)) {
# n defaults to length(x), evaluated when n is first used
# At that point, x is already available in this environment
head(x, n)
}
cat("All:", smart_default(1:10), "\n")
cat("First 3:", smart_default(1:10, 3), "\n")
substitute(): Capture the Promise Expression
substitute() captures the unevaluated expression from a promise:
# substitute captures what the user typed, not the value
show_expression <- function(x) {
expr <- substitute(x)
cat("Expression:", deparse(expr), "\n")
cat("Value:", x, "\n")
}
a <- 5
show_expression(a + 10)
show_expression(sqrt(144))
show_expression(1:5)
This is how functions like plot() automatically label axes with the expression you passed in.
Practice Exercise
# Exercise: Create a function make_validators() that takes a list
# of conditions like list(positive = x > 0, small = x < 100)
# and returns a list of validator functions.
# Make sure each validator captures its own condition correctly
# (don't fall into the lazy evaluation trap).
# Test: validators$positive(-5) should return FALSE
# validators$small(50) should return TRUE
# Write your code below:
**Explanation:** Using `quote()` to create unevaluated expressions and `eval()` with a custom environment prevents the lazy evaluation trap. `lapply()` creates a new scope for each iteration, which also helps.
Summary
Concept
Description
Solution
Lazy evaluation
Arguments evaluated on demand
Default R behavior
Promise
Unevaluated expression + environment
Created automatically for args
force(x)
Evaluate argument immediately
Use in function factories
substitute(x)
Capture unevaluated expression
For metaprogramming
Loop trap
Closures capture variable, not value
Use force() or local()
Default args
Evaluated in function's environment
Can reference other args
FAQ
Is lazy evaluation the same as memoization?
No. Lazy evaluation means "don't compute until needed." Memoization means "compute once, cache the result." A promise is evaluated once and then cached, so there's a similarity — but the key feature of lazy evaluation is deferral, not caching.
Why doesn't R evaluate arguments eagerly like most languages?
Lazy evaluation enables powerful patterns: default arguments that reference other arguments, short-circuit evaluation, and functions like substitute() that inspect the caller's expression. It also avoids computing unused arguments.
What's Next?
R Active Bindings — computed variables with makeActiveBinding()
R Closures — how functions capture environments (closely related to promises)
R Environments — the environments where promises are evaluated