Lazy evaluation and metaprogramming

R · Expert · 18 min

Non-standard evaluation powers formulas, dplyr, ggplot2 and data.table; writing wrappers around them requires it.

R evaluates function arguments lazily. Each argument is a promise: an unevaluated expression plus the environment to evaluate it in, computed the first time it's used and then cached. That's why defaults can refer to other arguments and why an argument that is never touched can contain an error without consequence. force(x) evaluates a promise deliberately, important in function factories.

Because the expression is available, a function can capture the code instead of the value:

  • quote(a + b) returns an unevaluated call (a language object).
  • substitute(arg) inside a function returns the expression the caller wrote for arg. This is how plot(x, y) labels axes "x" and "y".
  • eval(expr, envir) evaluates an expression in a chosen environment, which can be a data frame or list: eval(quote(mpg * 2), mtcars).
  • bquote() builds expressions with .() placeholders; deparse() turns code back into text.

This is non-standard evaluation (NSE): subset(df, age > 30) evaluates age > 30 inside df. The cost is referential transparency: you can't pass a column name in a variable the obvious way.

The tidyverse formalises NSE as tidy evaluation (rlang): data masking with quosures, which carry their environment, {{ arg }} to forward an argument into a dplyr verb, and .data[[name]] for column names stored as strings.

Example

f <- function(x, y = stop("never needed")) x * 2
print(f(21))                              #> [1] 42 (y never evaluated)

show_expr <- function(arg) deparse(substitute(arg))
print(show_expr(height / weight^2))       #> [1] "height/weight^2"

e <- quote(mpg[1] * 2)
print(class(e))                           #> [1] "call"
print(eval(e, mtcars))                    #> [1] 42

my_subset <- function(df, cond) {
  rows <- eval(substitute(cond), df, parent.frame())
  df[rows, , drop = FALSE]
}
print(nrow(my_subset(mtcars, cyl == 6 & mpg > 20)))   #> [1] 3

library(dplyr, warn.conflicts = FALSE)
mean_by <- function(df, group, var) {
  df |> group_by({{ group }}) |> summarise(m = mean({{ var }}))
}
print(mean_by(mtcars, am, mpg)$m)        #> [1] 17.14737 24.39231

Remember

  • Arguments are promises, evaluated on first use
  • quote/substitute capture code, eval runs it in an environment or data frame
  • NSE lets functions read bare column names
  • Forward arguments into dplyr with {{ }}

Watch out for

  • Passing a column name stored in a string to an NSE function; use .data[[name]] instead.

Check yourself

  1. Why doesn't f <- function(x, y = stop("bad")) x; f(1) raise an error?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: cran.r-project.org/doc/manuals/r-release/R-lang.html#Promise-objects

Builds on

Unlocks

Nothing builds on this yet.