Evaluation and Non-Standard Evaluation
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. Everything on this page is base R (base, utils and stats) and codetools 0.2-20, the recommended package that ships with R and is behind the R CMD check notes about undefined global variables, except the section on tidy evaluation, which is run against rlang 1.3.0 and dplyr 1.2.1 and is labelled as package material wherever it appears; the lintr 3.4.0 default linters were used only to check the scripts. An R function normally receives values, but R can also hand a function the code that was typed as an argument, and let it decide where and when to run that code. That is non-standard evaluation, and it is how subset(books, pages > 300), with(), lm(pages ~ year, data = books) and the whole tidyverse let you write column names as if they were variables. This page takes the mechanism apart: eval() and the two environments it can be given, data masks, writing a subset()-like function and the mistakes it invites, the call stack, match.call(), do.call(), local(), and then tidy evaluation, explained as a layer built on substitute(), eval() and bquote() and not as a different language.
Every example is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines that start with #> are what it printed, in the order it printed them. The scripts also pass the default lintr linters; a # nolint: comment marks a line where the idiom on show is what a default linter dislikes (a column name that is not defined as a variable, for instance), so the linter does not hide the lesson. A script that is cut into several blocks with text between them is one script: run its blocks in order. The names books, loans, shelf_today, loan_days and the book() constructor come from the running library-shelf scenario described on the section index: a script that needs them starts with source("scenario.R"). Where a message contains quotation marks, they are the typographic quotes R prints in a UTF-8 locale. The pieces this page builds on are explained elsewhere: promises and lazy evaluation in Lazy Evaluation and Promises, environments and frames in Scoping, Environments and Closures, argument matching in Functions, and quoting and the structure of calls in Language Objects and Quoting. The formula side of the same idea is in Formulas.
What non-standard evaluation is
Standard and non-standard evaluation
In standard evaluation a function uses the value of each argument: R wraps the argument in a promise, which holds the expression and the caller’s environment, and evaluates it in that environment the first time the function needs it (The R Language Definition: Argument evaluation), so the function sees a number, a string or a data frame. In non-standard evaluation the function looks at the expression the caller typed, through substitute() (see Lazy Evaluation and Promises, which shows that the expression lives in the promise), and then does something else with it: it prints it as a label, evaluates it in a place of its own choosing, or never evaluates it. The Language Definition introduces the building blocks in its chapter on computing on the language (The R Language Definition: Computing on the language), and eval() is described there as the means of evaluating call objects, with the environment as the part that often has to be set to something else (The R Language Definition: More on evaluation).
source("scenario.R")
# Standard evaluation: the argument is evaluated first and the function
# receives its value.
sum(c(310, 288, 96))
#> [1] 694
# Non-standard evaluation: the function receives the expression that was
# typed, and decides what to do with it.
label <- function(x) deparse1(substitute(x))
label(books$pages * 2)
#> [1] "books$pages * 2"
label(shelf_today + loan_days)
#> [1] "shelf_today + loan_days"
# Base R functions that treat an argument as an expression, not a value.
library(stats)
library("stats")
quote(pages > 300)
#> pages > 300
pages <- c(310, 288)
years <- c(2019, 2021)
names(data.frame(pages, years))
#> [1] "pages" "years"
names(data.frame(pages, years * 2))
#> [1] "pages" "years...2"
class(pages ~ years)
#> [1] "formula"
with(books, max(pages, na.rm = TRUE))
#> [1] 432
nrow(subset(books, genre == "Poetry"))
#> [1] 2
Source: R documentation: substitute
The calls in the last block do five different things with their arguments. label() turns the expression into text and never evaluates it. library(stats) and library("stats") do the same because library() looks at its argument with substitute() and accepts either a name or a string (the argument character.only decides). data.frame(pages, years) takes the column names from the expressions of its arguments, which is why years * 2 becomes the awkward name years...2. A formula quotes both sides. And with() and subset() evaluate the expression inside the data frame, which is the subject of most of this page.
Why R has it
The motivation is that data analysis is mostly about columns, and that writing books$pages three times in an expression is noise. The rlang documentation traces data masking back to the S language, which introduced attach() for data scopes in 1988 and data-masked formulas for modelling functions in 1993; it credits Peter Dalgaard’s frametools package of 1997 as the origin of base::transform() and base::subset(), and Luke Tierney’s change that made formulas remember their environment (published in R 1.1.0) as the step that made hygienic data masking possible. with() followed in 2001 (rlang package documentation: data masking). Thomas Lumley’s note "Standard Nonstandard Evaluation Rules", cited from the ?with help page, is the closest thing to an official statement of the conventions (R Development: Standard Nonstandard Evaluation Rules). The price of the convenience is described in every one of those sources in the same terms: the function has to be told where to look, and the user can no longer tell from the call alone which names are columns and which are variables.
The base functions that use the pattern, and what each one does with the expression, are collected here; the ones that are not obvious are taken apart below.
| Function | What it captures | Where it evaluates |
|---|---|---|
|
the expression, unevaluated |
nowhere; it returns the call |
|
the expression of an argument (the promise) |
nowhere; it returns the call |
|
nothing: it receives a call object |
in |
|
the expression, with |
in |
|
the expression |
in a mask built from |
|
both expressions |
|
|
the tagged expressions |
in the data, behind the caller |
|
the expression |
in a new environment |
|
the package name |
nowhere; it takes the name as text |
|
the argument expressions, for the names |
normally, as arguments; the text is only a label |
|
the formula, |
in |
|
the expressions, for the text of the failure message |
as ordinary promises, one at a time |
The three questions
Every non-standard evaluation function answers three questions, and a reader who asks them of an unfamiliar function can predict its behaviour. What does it capture, and when? Where does it evaluate what it captured: in which environment, and with which data in front of it? And what does it return, given that assignments made while evaluating usually vanish with the temporary environment? The sections that follow keep coming back to the second question, because every pitfall on this page is a case of the function and its user giving different answers to it.
eval() and its environments
The signature
eval(expr, envir = parent.frame(), enclos = if (is.list(envir) || is.pairlist(envir)) parent.frame() else baseenv()) evaluates the object expr in the environment described by envir. The help page lists what expr may be: a call, an expression vector, a name (looked up, and its binding evaluated), a promise, or any basic type such as a vector, a function or an environment, which are returned unchanged. evalq(expr, envir, enclos) is eval(quote(expr), ...); the help page adds that eval evaluates its first argument in the current scope before passing it to the evaluator, and that evalq avoids this. eval.parent(expr, n) is a shorthand for eval(expr, parent.frame(n)), and local() is evalq() with a new environment as the default (R documentation: eval, evalq, local). The Language Definition says that by default the evaluation frame is the one from which eval is called, and that very often the relevant frame is that of the parent of the current one, because an object that came out of substitute() contains variables that make sense to the caller only (The R Language Definition: More on evaluation).
# The signature: expr, envir and enclos.
args(eval)
#> function (expr, envir = parent.frame(), enclos = if (is.list(envir) ||
#> is.pairlist(envir)) parent.frame() else baseenv())
#> NULL
# eval() evaluates what it is given in an environment.
x <- 10
eval(quote(x * 2))
#> [1] 20
eval(expression(x, x + 1, x + 2))
#> [1] 12
eval(as.name("x"))
#> [1] 10
eval(42)
#> [1] 42
eval(sum)
#> function (..., na.rm = FALSE) .Primitive("sum")
# A call is evaluated; a string is a constant and comes back unchanged.
eval("x * 2")
#> [1] "x * 2"
eval(parse(text = "x * 2"))
#> [1] 20
eval(str2lang("x * 2"))
#> [1] 20
The last two evaluations in the block show a mistake that is easy to make. A string is a constant: eval("x * 2") returns the string, and only parse(text = ) or str2lang() turn text into something eval() can run (str2lang() was added in R 3.6.0 as a streamlined parse(text = , keep.source = FALSE) for a single expression, see the version notes). And eval() is an ordinary function, so its argument is evaluated before eval() sees it.
# eval() is a function, so its own argument is evaluated first. What eval()
# receives is the value of that argument, and it evaluates the value.
expr <- quote(x * 2)
eval(expr)
#> [1] 20
eval(quote(expr))
#> x * 2
eval(eval(quote(expr)))
#> [1] 20
eval(x * 2)
#> [1] 20
eval(expr) evaluates the call stored in expr. eval(quote(expr)) evaluates the symbol expr, whose value is that call, so it returns the call itself, unevaluated; one more eval() runs it. And eval(x * 2) is not a quotation at all: R computes 20 first and eval() evaluates the constant 20. When the argument is already the value you wanted, the extra eval() is harmless; when the argument has side effects, they happen once for the argument and the result is evaluated again, which is why a function that receives its code unevaluated uses substitute() and not the value.
Calls, expression vectors and quotedness
An expression vector (see Language Objects and Quoting) is evaluated element by element and the value is the value of the last one. The Language Definition points out the subtle difference from a block: a call nested in another call is evaluated as a subexpression, while an expression object stays quoted unless it is passed directly to eval() (The R Language Definition: Evaluation of expression objects).
# An expression vector is evaluated element by element; the value is the
# value of the last one.
ex <- expression(2 + 2, 3 + 4)
length(ex)
#> [1] 2
ex[[1L]]
#> 2 + 2
eval(ex)
#> [1] 7
# A block is a single call with the same effect.
block <- quote({
2 + 2
3 + 4
})
eval(block)
#> [1] 7
# The difference: a call inside another call is evaluated when the outer
# call runs; an expression vector keeps its quotedness.
eval(substitute(mode(x), list(x = quote(2 + 2))))
#> [1] "numeric"
eval(substitute(mode(x), list(x = expression(2 + 2))))
#> [1] "expression"
# Printed alike, but one is a call that builds an expression vector.
e <- quote(expression(2 + 2))
ee <- expression(2 + 2)
e
#> expression(2 + 2)
ee
#> expression(2 + 2)
c(mode(e), mode(ee))
#> [1] "call" "expression"
c(class(e), class(ee))
#> [1] "call" "expression"
eval(ex) ran both elements and returned 7. Substituting a call for x gave "numeric", because the call 2 + 2 was evaluated before mode() saw it; substituting an expression vector gave "expression", because the evaluator does not evaluate an expression object recursively. The two objects e and ee print identically, which the manual calls out as a source of confusion, and class() is the way to tell them apart.
The forms of envir
envir can be an environment, which is used as it is; a list or a data frame, which is copied into a temporary environment; a pairlist, which is treated like a list; NULL, which is read as an empty list; or an integer, read as in sys.call(). The help page spells out the consequence of the copy: if the expression changes a component named in the list, the change is lost. Anything else is an error with a message of its own.
source("scenario.R")
x <- "global"
shelf_env <- new.env()
assign("x", "environment", envir = shelf_env)
# envir can be an environment, a list, a data frame, a pairlist or NULL.
eval(quote(x), shelf_env)
#> [1] "environment"
eval(quote(x), list(x = "list"))
#> [1] "list"
eval(quote(x), pairlist(x = "pairlist"))
#> [1] "pairlist"
eval(quote(x), NULL)
#> [1] "global"
eval(quote(paste(title, "-", pages)), books[2L, ])
#> [1] "Pride and Prejudice - 432"
# NULL means an empty list: look-up goes straight to enclos.
eval(quote(x), list(), shelf_env)
#> [1] "environment"
# An integer is a frame number, as in sys.call(): non-negative values count
# from the global environment, negative values count back from the current
# frame.
outer_fn <- function() {
x <- "outer_fn" # nolint: object_usage_linter.
inner_fn()
}
inner_fn <- function() {
x <- "inner_fn" # nolint: object_usage_linter.
c(
default = eval(quote(x)),
frame_1 = eval(quote(x), 1L),
frame_2 = eval(quote(x), 2L),
minus_1 = eval(quote(x), -1L),
parent = eval(quote(x), parent.frame())
)
}
outer_fn()
#> default frame_1 frame_2 minus_1 parent
#> "inner_fn" "outer_fn" "inner_fn" "inner_fn" "outer_fn"
# Anything else is an error, with these messages.
tryCatch(eval(quote(x), "shelf_env"), error = conditionMessage)
#> [1] "invalid 'envir' argument of type 'character'"
tryCatch(eval(quote(x), 1:2), error = conditionMessage)
#> [1] "numeric 'envir' arg not of length one"
tryCatch(eval(quote(x), TRUE), error = conditionMessage)
#> [1] "invalid 'envir' argument of type 'logical'"
The integer form reads a frame number. Non-negative numbers count from the global environment, which is frame 0, so 1L was the outermost function (outer_fn()) and 2L the next one. A negative number counts back from the frame of eval() itself, which is why -1L gave the function that called eval(), here inner_fn(), and not its caller; sys.frame(-1L) called from the same function would count back from inner_fn() and give outer_fn() (see the call stack below). The two errors at the end are the messages of a character string and of a vector of length two used as envir.
enclos: where the search continues
When envir is a list or a data frame, R builds a temporary environment from it, and enclos is that environment’s parent: the place where a name that is not a column is looked up next. The default is parent.frame(). NULL is read as the base environment (baseenv(), which holds the base package but not stats or utils), and an environment can be given. When envir is itself an environment, enclos is ignored, because an environment already has its own parent.
source("scenario.R")
# When envir is a list or a data frame, R copies it into a temporary
# environment whose parent is enclos. Names are looked up in the data first,
# then along the enclosure.
cutoff <- 300
eval(quote(sum(pages > cutoff, na.rm = TRUE)), books)
#> [1] 6
# The parent of that temporary environment is enclos.
shelf_env <- new.env()
mask <- eval(quote(environment()), books, shelf_env)
identical(parent.env(mask), shelf_env)
#> [1] TRUE
sort(ls(mask))
#> [1] "author" "genre" "isbn" "pages" "title" "year"
# Without a data frame the default enclosure is the caller.
mask <- eval(quote(environment()), books)
identical(parent.env(mask), globalenv())
#> [1] TRUE
# enclos = NULL is the base environment: stats is not there.
tryCatch(
eval(quote(sd(pages, na.rm = TRUE)), books, NULL),
error = conditionMessage
)
#> [1] "could not find function \"sd\""
eval(quote(max(pages, na.rm = TRUE)), books, NULL)
#> [1] 432
# enclos is only used when envir is a list; with an environment it is ignored.
local_env <- new.env()
assign("kind", "environment", local_env)
eval(quote(exists("kind")), local_env, enclos = NULL)
#> [1] TRUE
The temporary environment is a real environment: ls() lists the columns, and parent.env() returns the enclos that was passed. That is all there is to a data mask in base R. The Language Definition describes the call form for the case it was designed for, the evaluation of the terms of a model formula in a data argument with the caller as the enclosure: eval(expr, data, sys.frame(sys.parent())) (The R Language Definition: More on evaluation).
Why parent.frame() is the usual enclosure
The expression handed to a function was written by the caller, so the variables it mentions are the caller’s. The default of enclos is parent.frame(), and a default argument is evaluated inside the function it belongs to; the sys.parent help page notes that these functions look at the call stack at the time they are evaluated, and that when they provide default values they are evaluated in the called function and count frames accordingly, naming the envir argument of eval as the example (R documentation: sys.call, sys.frame, sys.function and parent.frame). Inside eval(), then, the default parent.frame() is whoever called eval(). When a function calls eval(substitute(expr), data) without a third argument, that is the function itself, and the caller’s variables are out of reach. The help page of eval says as much in its note: when evaluating in a data frame that was passed to a function, the relevant enclosure is often the caller’s environment, so one needs eval(x, data, parent.frame()). Written as an argument of eval() in the body of the function, parent.frame() is a promise that is evaluated in the function’s frame, and so it gives the function’s caller: the right place.
source("scenario.R")
# The default of enclos is parent.frame(), evaluated inside eval(): it is the
# environment that called eval(), which in a function is the function's own
# frame, not the frame of whoever called the function.
bad_with <- function(data, expr) {
eval(substitute(expr), data)
}
good_with <- function(data, expr) {
eval(substitute(expr), data, parent.frame())
}
count_long <- function(with_fn, data, n = 300) {
with_fn(data, sum(pages > n, na.rm = TRUE)) # nolint: object_usage_linter.
}
count_long(good_with, books)
#> [1] 6
tryCatch(count_long(bad_with, books), error = conditionMessage)
#> [1] "object 'n' not found"
# The wrong enclosure also leaks the function's own variables into the
# expression: `data` is the formal argument of bad_with(), not a variable the
# caller defined.
data <- "the caller's data"
good_with(books, data)
#> [1] "the caller's data"
bad_with(books, class(data))
#> [1] "data.frame"
# Where the default is evaluated: parent.frame() inside the argument list of
# eval() is evaluated in the function, so it is the function's caller.
who_called <- function() parent.frame()
caller <- function() identical(who_called(), environment())
caller()
#> [1] TRUE
count_long(bad_with, books) fails with object 'n' not found even though n is a formal argument of count_long(): the expression pages > n was evaluated in the temporary environment of bad_with(), whose parent is the frame of bad_with(), then the global environment, and the frame of count_long() is not on that path. The second symptom is subtler and does not raise an error: the arguments and local variables of the wrong function become visible to the expression. bad_with(books, class(data)) found data, the formal argument of bad_with() (the data frame itself), and not the string the caller had defined, and a typo that happens to match a local variable of the function is silently accepted. This is the most common bug in hand-written non-standard evaluation functions, and the cure is always the same third argument.
Assignments, evalq() and eval.parent()
Evaluating in a list evaluates in a copy, so assignments are lost. Evaluating in an environment changes that environment. And eval(N <- 4, env) is a classic trap: the argument is evaluated first, in the caller, so the assignment happens in the caller and eval() receives the value 4. evalq() quotes its argument, and the assignment goes where it was meant to. The Language Definition remarks that evaluating in a given environment may change it, most obviously with an assignment, and that the same is true of lists except that the original does not change because one is working on a copy.
source("scenario.R")
# Assignments inside a data mask happen in a temporary copy and are lost.
cutoff <- 300
eval(quote(cutoff <- 1), list(cutoff = 2))
cutoff
#> [1] 300
shelf <- data.frame(pages = c(310, 288))
eval(quote(double <- pages * 2), shelf)
names(shelf)
#> [1] "pages"
# With a real environment the assignment sticks, in that environment.
shelf_env <- new.env()
eval(quote(double <- 20), shelf_env)
get("double", envir = shelf_env)
#> [1] 20
# eval(N <- 4, env) assigns in the caller: the argument is evaluated first.
n_copies <- 3
shelf_env <- new.env()
assign("n_copies", 27, envir = shelf_env)
eval(n_copies <- 4, shelf_env)
#> [1] 4
n_copies
#> [1] 4
get("n_copies", envir = shelf_env)
#> [1] 27
# evalq() quotes its argument, so the assignment is made in shelf_env.
evalq(n_copies <- 5, shelf_env)
n_copies
#> [1] 4
get("n_copies", envir = shelf_env)
#> [1] 5
The first eval() shows the copy: cutoff is still 300 after quote(cutoff <- 1) ran in a list, and the data frame shelf got no double column. The third block is the one to remember. eval(n_copies <- 4, shelf_env) printed 4 (the value of the assignment, visible because it was the argument) and changed the global n_copies, leaving the variable in shelf_env at 27; evalq(n_copies <- 5, shelf_env) changed the one in shelf_env.
eval.parent() is the tool for the opposite need, a function that is supposed to change a variable of its caller. It evaluates in the frame of the caller of the function that calls it, because inside it parent.frame(n + 1) counts the extra frame of eval.parent() itself, as the printed body shows.
# eval.parent(expr, n) is eval(expr, parent.frame(n)) for the function that
# calls it, which makes it the tool for a function that changes its caller.
bump <- function(name) {
eval.parent(substitute(name <- name + 1))
}
visits <- 1
bump(visits)
visits
#> [1] 2
set_visits <- function() {
visits <- 100
bump(visits)
visits
}
set_visits()
#> [1] 101
body(eval.parent)
#> {
#> p <- parent.frame(n + 1)
#> eval(expr, p)
#> }
bump(visits) captured the symbol visits with substitute(), built the call visits <- visits + 1 and ran it in the caller. Inside set_visits() the same function changed the local variable and left the global one alone. A function that rewrites its caller’s variables is unusual in R, where functions work on copies and return the new value, and it is worth a comment when it is used (the replacement functions of Functions are the idiomatic route).
Data masks
A data mask by hand
The word "data mask" comes from the tidyverse documentation, but the thing is the temporary environment of the previous section: an environment made from a data frame, in front of the caller’s environment, so that columns hide variables of the same name. The rlang documentation describes data masking as resting on three language features, defusing code with substitute() (or enquo()), first-class environments into which lists and data frames can be turned, and explicit evaluation with eval() (rlang package documentation: data masking). In base R the whole of with() fits in one line, which the first block prints before writing the same function by hand.
source("scenario.R")
# A data mask is an environment built from a data frame (or list) and chained
# to the caller's environment. base::with() is this and nothing more.
body(with.default)
#> eval(substitute(expr), data, enclos = parent.frame())
my_with <- function(data, expr) {
eval(substitute(expr), data, parent.frame())
}
my_with(books, round(mean(pages, na.rm = TRUE), 1))
#> [1] 268.2
with(books, round(mean(pages, na.rm = TRUE), 1))
#> [1] 268.2
# The mask comes first: a column hides a variable of the same name.
pages <- 1000
my_with(books, round(mean(pages, na.rm = TRUE), 1))
#> [1] 268.2
# The variable is still reachable when the expression names it differently.
min_pages <- 300
my_with(books, sum(pages > min_pages, na.rm = TRUE))
#> [1] 6
# The mask is a copy: it can be changed without touching the data frame.
my_with(books, {
pages <- pages * 2
max(pages, na.rm = TRUE)
})
#> [1] 864
max(books$pages, na.rm = TRUE)
#> [1] 432
# ls() inside the mask lists the columns; the enclosure is the caller.
my_with(books, sort(ls()))
#> [1] "author" "genre" "isbn" "pages" "title" "year"
my_with(books, identical(parent.env(environment()), globalenv()))
#> [1] TRUE
# A list or an environment works as well.
my_with(list(a = 2, b = 5), a^b)
#> [1] 32
my_env <- list2env(list(a = 3, b = 2))
my_with(my_env, a^b)
#> [1] 9
Source: R documentation: with, within
Four things are visible in that block. The column pages hides the variable pages <- 1000, so my_with(books, round(mean(pages, na.rm = TRUE), 1)) is not affected by it. The mask is a copy, so reassigning pages inside the block changed the result (864) and not the data frame (still 432 as its maximum). The enclosure of the mask is the global environment, because that is where my_with() was called from, which is exactly what parent.frame() in the third argument buys. And a list or an environment works in place of a data frame.
with() and within()
with(data, expr) evaluates expr in a mask made from data; the help page says the mask has the caller’s environment as its parent, and that when data is already an environment it is used with its existing parent. Assignments made inside take place in the mask, not in the workspace. within(data, expr) examines the mask after the evaluation and returns a modified copy of the data. The help page warns that this can fail for a data frame if the block creates objects that cannot be stored in a data frame, and presents within() as an alternative to transform(). Two details trip people up. New variables are appended in the reverse of the order in which they were created, because within() reads them back from the environment, and a variable removed with rm() inside the block is removed from the result.
# within() evaluates in the mask, then copies the variables back. New
# columns are added in reverse order of creation.
shelf <- within(head(books[, c("title", "pages")], 4L), {
half <- pages / 2
long <- pages > 300
})
names(shelf)
#> [1] "title" "pages" "long" "half"
shelf
#> title pages long half
#> 1 The Dispossessed 387 TRUE 193.5
#> 2 Pride and Prejudice 432 TRUE 216.0
#> 3 the quiet streets 214 FALSE 107.0
#> 4 Loud Roofs 198 FALSE 99.0
# A variable removed in the block is removed from the result.
names(within(head(books, 2L), rm(isbn, author)))
#> [1] "title" "year" "genre" "pages"
# Assignments to a column hide nothing: within() changes a copy.
shelf <- within(books, pages <- pages * 2)
c(books$pages[1L], shelf$pages[1L])
#> [1] 387 774
# with() on a list and on an environment.
with(list(a = 2, b = 5), a^b)
#> [1] 32
with(list2env(list(a = 3, b = 2)), a^b)
#> [1] 9
# with() does not copy back, so an assignment is lost.
with(books, extra <- pages * 2)
exists("extra")
#> [1] FALSE
# The help page warns about variables in the data hiding local variables.
year <- 2000
with(books, sum(year > year, na.rm = TRUE))
#> [1] 0
Source: R documentation: with, within
The help page carries a warning that belongs next to the first rule of this page: for interactive use with() is effective and nice to read, but in a function variables in data "may accidentally override local variables", and with modelling or graphics functions that have a data argument that argument should be preferred to with(data, ...). The last two lines are the accident: the local year is hidden by the column and the comparison is of the column with itself. The list method of within() gained a keepAttrs argument in R 3.5.0 (see the version notes).
subset()
subset(x, subset, select) is the textbook non-standard evaluation function. Its two expressions are evaluated differently, and the source printed below shows both. subset is evaluated in the data frame, with parent.frame() as the enclosure, and must produce a logical vector; rows where it is NA are dropped, which is the difference from indexing with [, where an NA index gives an all-NA row. select is evaluated in a list that maps each column name to its position, so title:year is the range of positions, -c(isbn, author) drops columns, and c(title, year) picks them (the help page describes this as first replacing the column names with the column numbers and then indexing).
source("scenario.R")
# subset() evaluates `subset` as an expression in the data frame and
# `select` with column names mapped to column numbers.
subset(books, year > 2018, select = c(title, year))
#> title year
#> 3 the quiet streets 2019
#> 4 Loud Roofs 2021
#> 10 THE LONG TABLE 2023
#> 13 Orchard Logic 2020
#> 15 Ten Winters 2022
subset(books, pages > 300, select = title:year)
#> title author isbn year
#> 1 The Dispossessed Ursula K. Le Guin 978-0-306-40615-7 1974
#> 2 Pride and Prejudice Jane Austen 978-0-14-143951-8 1813
#> 5 Midnight Ledger Iris Fenwick 978-0-00-300031-3 2016
#> 9 Rivers of Glass Tomas Arden 2012
#> 12 Notes on Gravity Elias Moreau 978-0-01-000104-4 2017
#> 14 The Slow Comet Priya Raman 978-0-01-200126-4 2014
subset(books, year < 1900, select = -c(isbn, author, genre))
#> title year pages
#> 2 Pride and Prejudice 1813 432
# The source shows both evaluations.
body(subset.data.frame)
#> {
#> chkDots(...)
#> r <- if (missing(subset))
#> rep_len(TRUE, nrow(x))
#> else {
#> e <- substitute(subset)
#> r <- eval(e, x, parent.frame())
#> if (!is.logical(r))
#> stop("'subset' must be logical")
#> r & !is.na(r)
#> }
#> vars <- if (missing(select))
#> rep_len(TRUE, ncol(x))
#> else {
#> nl <- as.list(seq_along(x))
#> names(nl) <- names(x)
#> eval(substitute(select), nl, parent.frame())
#> }
#> x[r, vars, drop = drop]
#> }
# Rows where the condition is NA are dropped; `[` keeps them as NA rows.
nrow(subset(books, year > 2015))
#> [1] 8
nrow(books[books$year > 2015, ])
#> [1] 9
sum(is.na(books[books$year > 2015, "title"]))
#> [1] 1
# The condition must be logical.
tryCatch(subset(books, year), error = conditionMessage)
#> [1] "'subset' must be logical"
Source: R documentation: subset
The call subset(books, year > 2015) returned eight rows and books[books$year > 2015, ] returned nine, one of them a row of NA values for the book whose year is missing. The condition must be logical: subset(books, year) fails with 'subset' must be logical, and since R 4.3.0 the data frame method warns about extra arguments, which catches the common slip of writing = for ==.
# Since R 4.3.0 extra arguments are reported, which catches `=` for `==`.
tryCatch(
subset(books, genre = "Poetry"),
warning = conditionMessage
)
#> [1] "In subset.data.frame(books, genre = \"Poetry\") :\n extra argument ‘genre’ will be disregarded"
Source: R documentation: subset
The text of that warning starts with In subset.data.frame(...) : because chkDots(), the helper that raises it, builds the message from sys.call() (the class of the condition is chkDotsWarning), which is one more use of the call stack described below. The help page of subset() ends with a warning about its own design, in the same words as the pages of transform(): it is a convenience function intended for interactive use, and for programming it is better to use the standard subsetting functions like [, because the non-standard evaluation of the subset argument can have unanticipated consequences. The rest of the page shows what those consequences are.
transform()
transform(_data, ...) takes tagged expressions (tag = value) and evaluates all of them in the data frame at once: tags that match an existing column replace it and the others are appended. Because the list(...) is evaluated as one call, an argument cannot use a column created by an earlier argument, which is a real difference from within(), from a for loop over [[<- and from dplyr::mutate().
source("scenario.R")
# transform() evaluates every argument in the data frame, tag = value.
shelf <- transform(
head(books[, c("title", "year", "pages")], 4L),
age = 2026 - year,
pages_k = pages / 1000
)
shelf
#> title year pages age pages_k
#> 1 The Dispossessed 1974 387 52 0.387
#> 2 Pride and Prejudice 1813 432 213 0.432
#> 3 the quiet streets 2019 214 7 0.214
#> 4 Loud Roofs 2021 198 5 0.198
# All arguments see the original data: a new column cannot be used by the
# next argument.
tryCatch(
transform(books, age = 2026 - year, age_2 = age * 2),
error = conditionMessage
)
#> [1] "object 'age' not found"
# within() evaluates a block in order, so it can.
head(within(books, {
age <- 2026 - year
age_2 <- age * 2
})[, c("title", "age", "age_2")], 3L)
#> title age age_2
#> 1 The Dispossessed 52 104
#> 2 Pride and Prejudice 213 426
#> 3 the quiet streets 7 14
# The source: one eval() of a list(...) call.
body(transform.data.frame)
#> {
#> e <- eval(substitute(list(...)), `_data`, parent.frame())
#> tags <- names(e)
#> inx <- match(tags, names(`_data`))
#> matched <- !is.na(inx)
#> if (any(matched)) {
#> `_data`[inx[matched]] <- e[matched]
#> `_data` <- data.frame(`_data`, check.names = FALSE)
#> }
#> if (!all(matched)) {
#> args <- e[!matched]
#> args[["check.names"]] <- FALSE
#> do.call("data.frame", c(list(`_data`), args))
#> }
#> else `_data`
#> }
Source: R documentation: transform
The error object 'age' not found is the whole lesson: both arguments saw the original data, in which there is no age. The printed source shows why: a single call to eval() evaluates substitute(list(...)) in the data frame _data, in one step, before any column is replaced. within(), which evaluates a block in order, accepts the second assignment. The help page of transform() repeats the warning of subset() about interactive use, and adds a remark that is worth believing: if some of the values are not vectors of the appropriate length, you get whatever you deserve.
Models, formulas and the same pattern
The functions that fit models use the same pattern with the formula as the carrier of the environment. A formula remembers where it was created, and model.frame() evaluates the variables of the formula, and the subset and weights arguments, in data first and then in the environment of the formula, not in the frame of the function that called lm().
source("scenario.R")
# A formula is a quoted expression plus an environment, which is why model
# functions can use it as a data mask: variables come from `data` first and
# then from the environment of the formula.
f <- pages ~ year
class(f)
#> [1] "formula"
f[[2L]]
#> pages
f[[3L]]
#> year
environmentName(environment(f))
#> [1] "R_GlobalEnv"
fit_pages <- function(data, cutoff) {
lm(pages ~ year, data, subset = year > cutoff) # nolint: object_usage_linter.
}
coef(fit_pages(books, 1990))
#> (Intercept) year
#> -1057.6628272 0.6530541
# The formula was created inside fit_pages(), so its environment is the
# function's frame and `cutoff` is found there. A formula built elsewhere
# does not see it.
f <- pages ~ year
fit_with <- function(formula, data, cutoff) {
lm(formula, data, subset = year > cutoff) # nolint: object_usage_linter.
}
tryCatch(
fit_with(f, books, 1990),
error = conditionMessage
)
#> [1] "object 'cutoff' not found"
cutoff <- 1990
coef(fit_with(f, books, 1990))
#> (Intercept) year
#> -1057.6628272 0.6530541
Source: R documentation: model.frame
The formula pages ~ year written inside fit_pages() has the frame of that function as its environment, and cutoff is found there. The formula f written in the global environment does not see the argument cutoff of fit_with(): the error is the same object 'cutoff' not found that bad_with() produced, for the same reason, and it disappears when a global cutoff exists. R 1.1.0 made formulas keep their environment, as the history above says, and the Language Definition lists the sys.frame(sys.parent()) evaluation of the matched call as the way lm() evaluates its model.frame() call in the caller (The R Language Definition: Manipulation of function calls). The environment of a formula, terms() and the rest of the machinery are the subject of Formulas.
Writing a non-standard evaluation function
The pattern
A subset()-like function has three steps: capture the expression with substitute(), evaluate it in the data with the caller as the enclosure, and use the result. The capture must come first, before the argument is touched, because substitute() only returns the expression of a promise that has not been replaced by an assignment (Lazy Evaluation and Promises shows the cases).
source("scenario.R")
# An NSE function in the style of subset(): capture, evaluate in the data,
# in the caller's environment, and use the result.
shelf_subset <- function(data, cond) {
cond_call <- substitute(cond)
rows <- eval(cond_call, data, parent.frame())
data[rows & !is.na(rows), , drop = FALSE]
}
shelf_subset(books, pages > 400)[, c("title", "pages")]
#> title pages
#> 2 Pride and Prejudice 432
#> 12 Notes on Gravity 410
# Variables of the caller are found through parent.frame().
longer_than <- function(data, n) {
shelf_subset(data, pages > n) # nolint: object_usage_linter.
}
nrow(longer_than(books, 300))
#> [1] 6
# The expression is not evaluated until eval() is called, and then once.
trace_cond <- function(data, cond) {
cond_call <- substitute(cond)
cat("evaluating:", deparse1(cond_call), "\n")
eval(cond_call, data, parent.frame())
}
invisible(trace_cond(books, year < 1900))
#> evaluating: year < 1900
Source: R documentation: substitute
shelf_subset(books, pages > 400) found pages in the data. longer_than() calls it with an expression that mentions n, a variable of longer_than(), and it was found because shelf_subset() evaluated in parent.frame(), the frame of longer_than(). And trace_cond() shows that nothing is evaluated until eval() is called, and then once. The line data[rows & !is.na(rows), , drop = FALSE] is the same treatment of NA that subset() applies. These are the three habits that every function of this family needs. The pitfalls start when one of them is missing, or when two such functions meet.
Pitfalls
A variable named like a column. The mask comes first, so a parameter called year is invisible inside year > year, which compares the column with itself. The result is not an error but an empty data frame. The cure is a parameter name that cannot be a column name, or an explicit pronoun (see tidy evaluation below).
A column that does not exist, and a variable that does. If the name is not in the data, R continues to the enclosure. When no variable has that name the error is the clear object 'price' not found, but when the caller’s environment happens to contain a variable with the same name, the expression is silently evaluated with it: the result of publisher == "Nobody" is an empty data frame, not an error, because the global string publisher was used. The longer the workspace and the more generic the names, the more likely this is, and it is the reason scripts that rely on non-standard evaluation break when a variable is added far away.
Forwarding an argument. A function that receives a condition and passes it on to another non-standard evaluation function gives the second one the symbol cond, not the expression that the user typed. The second function evaluates cond in the data, does not find a column of that name, looks in the frame of the first function, finds a promise there, and forces it in the global environment, where pages does not exist. The user sees an error about a name they never wrote, in a function they never called.
source("scenario.R")
shelf_subset <- function(data, cond) {
rows <- eval(substitute(cond), data, parent.frame())
data[rows & !is.na(rows), , drop = FALSE]
}
# 1. A function argument named like a column: the column wins.
books_after <- function(data, year) {
shelf_subset(data, year > year)
}
nrow(books_after(books, 2015))
#> [1] 0
# The column and the variable are told apart by building the call.
books_after <- function(data, min_year) {
shelf_subset(data, year > min_year) # nolint: object_usage_linter.
}
nrow(books_after(books, 2015))
#> [1] 8
# 2. A column that does not exist is silently replaced by a variable of the
# same name from the caller (here a global).
publisher <- "Quill & Co"
nrow(shelf_subset(books, publisher == "Nobody"))
#> [1] 0
tryCatch(
shelf_subset(books, price > 3),
error = conditionMessage
)
#> [1] "object 'price' not found"
# 3. Forwarding an argument to an NSE function does not work: the inner
# function sees the symbol `cond`, not what the user typed.
long_books <- function(data, cond) {
shelf_subset(data, cond)
}
tryCatch(
long_books(books, pages > 300),
error = conditionMessage
)
#> [1] "object 'pages' not found"
Source: R documentation: subset
The first call returned no rows at all; the second, with the parameter renamed, returned eight. The global string publisher made an impossible condition evaluate silently to nothing, while the misspelled price was reported. And the forwarding call failed with object 'pages' not found, although pages is a column of books.
The standard-evaluation twin
The reliable way out of the forwarding problem is to separate the two jobs. A function that takes the expression and the environment as ordinary arguments does the work and can be called from anywhere; the non-standard function is a two-line wrapper that captures and passes on. The convention has a history: dplyr 0.3 (2014) gave every function that does non-standard evaluation a standard-evaluation version ending in an underscore, powered by the lazyeval package, and dplyr 0.7.0 (2017) replaced that approach with tidy evaluation (dplyr package documentation: NEWS); the trailing underscore is still the clearest name for a twin in plain base R. A third argument env = parent.frame() carries the environment that the caller means.
source("scenario.R")
# The standard-evaluation twin takes the expression and the environment as
# ordinary arguments, so it can be called from any other function.
shelf_subset_ <- function(data, cond_call, env = parent.frame()) {
rows <- eval(cond_call, data, env)
data[rows & !is.na(rows), , drop = FALSE]
}
# The non-standard function is a thin wrapper that captures once.
shelf_subset <- function(data, cond) {
shelf_subset_(data, substitute(cond), parent.frame())
}
# Another function can forward its own argument with the same two lines.
long_books <- function(data, cond) {
shelf_subset_(data, substitute(cond), parent.frame())
}
nrow(long_books(books, pages > 300))
#> [1] 6
# It also works with a variable that lives in the caller's caller.
count_longer <- function(min_pages) {
nrow(long_books(books, pages > min_pages)) # nolint: object_usage_linter.
}
count_longer(350)
#> [1] 4
# And with an expression built from data: a list of conditions.
conditions <- list(quote(pages > 300), quote(year < 2000))
vapply(
conditions,
\(cond) nrow(shelf_subset_(books, cond)),
integer(1L)
)
#> [1] 6 3
# do.call() passes a built expression to the NSE function itself.
min_pages <- 400
cond <- bquote(pages > .(min_pages))
cond
#> pages > 400
nrow(do.call(shelf_subset, list(books, cond)))
#> [1] 2
nrow(eval(bquote(shelf_subset(books, pages > .(min_pages)))))
#> [1] 2
Source: R documentation: do.call
long_books() forwards its own argument with the same two lines the original used, and finds min_pages in count_longer() even though the function that does the evaluation is two levels away. The twin also makes it possible to run a list of conditions, which a purely non-standard function cannot do, and do.call() or bquote() can build the call for the non-standard function when only that is available. do.call(shelf_subset, list(books, cond)) works because the call that do.call() builds contains the call object pages > 400 as an argument, and substitute() inside shelf_subset() returns it; the help page of do.call() warns that the behaviour of substitute() under do.call() is undefined and subject to change, so the pattern is fine in a script and not something a library should rest on.
Undefined globals and R CMD check
A column used as a bare name looks, to a static checker, like a reference to a variable that nobody defined. R CMD check runs codetools over the code of a package and reports no visible binding for global variable as a NOTE; lintr reports the same with its object_usage_linter, and an IDE shows a squiggle. The checker is right in general and cannot know that this function is going to be evaluated in a data mask. The declaration that tells the check which names are defined elsewhere is documented on the help page of globalVariables() (R documentation: globalVariables); the check is the work of codetools (codetools package documentation: checkUsage).
# R CMD check and lintr find the names a function uses without defining them.
long_books <- function(data, n) {
subset(data, pages > n) # nolint: object_usage_linter.
}
codetools::checkUsage(long_books, name = "long_books", all = TRUE)
#> long_books: no visible binding for global variable ‘pages’
codetools::findGlobals(long_books)
#> [1] "{" ">" "pages" "subset"
# Reading the column through `[[` or `$` is ordinary evaluation: the name is
# a string or a member, so there is nothing to report.
long_books_std <- function(data, n) {
data[!is.na(data$pages) & data$pages > n, , drop = FALSE]
}
codetools::checkUsage(long_books_std, name = "long_books_std", all = TRUE)
# Declaring the name as a local variable silences the report, but it hides
# the problem: the value is NULL until the data mask is in place.
long_books_null <- function(data, n) {
pages <- NULL
subset(data, pages > n)
}
codetools::checkUsage(long_books_null, name = "long_books_null", all = TRUE)
codetools::checkUsage() printed the report that R CMD check turns into a NOTE for long_books(): the name of the function, then no visible binding for global variable ‘pages’. There are four ways to deal with it, in order of preference. Use ordinary evaluation inside package code (data$pages or data[["pages"]], as in long_books_std()), where nothing needs declaring. In functions that must use the data mask, tell the checker the names: utils::globalVariables(c("pages", "year")) at the top level of the package, once, which the help page describes as naming objects that should be regarded as defined globally when the check is applied (a package-wide declaration, so a typo in one of those names is no longer reported anywhere). With tidy evaluation, use the .data pronoun, .data$pages, imported from rlang, which makes the reference explicit (see below). And do not assign pages <- NULL in the function body to silence the note: it works against the checker and creates a real local variable, and in long_books_null() above a misspelled column would not raise object 'pages' not found: the expression would silently see the NULL.
Strings or expressions?
A column name arrives in a function either as a bare name (mean_of(books, pages)) or as a string (mean_of(books, "pages")). Standard R code uses strings and [[, which needs no special treatment; a non-standard function converts the bare name with substitute(). Supporting both in one function looks friendly and is fragile, because the function cannot tell whether the symbol it received is the name of a column or the name of a variable that holds a string.
source("scenario.R")
# A string names a column; the standard way to use it is `[[`.
column <- "pages"
books[[column]][1:4]
#> [1] 387 432 214 198
mean(books[[column]], na.rm = TRUE)
#> [1] 268.2143
# A function that takes a column as a bare name needs a conversion.
mean_of <- function(data, col) {
col <- substitute(col)
mean(eval(col, data, parent.frame()), na.rm = TRUE)
}
mean_of(books, pages)
#> [1] 268.2143
mean_of(books, pages / 100)
#> [1] 2.682143
# Accepting both a string and a bare name is tempting and fragile: whether
# `col` is a name or a string depends on a test that can be fooled.
mean_of_either <- function(data, col) {
col <- substitute(col)
if (is.character(col)) {
col <- as.name(col)
}
mean(eval(col, data, parent.frame()), na.rm = TRUE)
}
mean_of_either(books, "pages")
#> [1] 268.2143
mean_of_either(books, pages)
#> [1] 268.2143
tryCatch(
mean_of_either(books, column),
warning = conditionMessage
)
#> [1] "argument is not numeric or logical: returning NA"
# Turning a string into an expression on purpose: str2lang() parses one
# expression, so a string with several statements is rejected.
cond_text <- "pages > 300 & year > 2000"
cond <- str2lang(cond_text)
cond
#> pages > 300 & year > 2000
class(cond)
#> [1] "call"
nrow(subset(books, eval(cond)))
#> [1] 4
tryCatch(str2lang("pages > 300; year > 2000"), error = conditionMessage)
#> [1] "parsing result not of length one, but 2"
mean_of_either(books, column) returned NA with a warning, because column is a variable that holds "pages": the function treated the symbol as a column name, did not find one, and evaluated it as a variable holding a string. The two APIs should not be mixed in one argument. When a string must become an expression, str2lang() parses exactly one expression and fails with parsing result not of length one, but 2 for more, and parse(text = ) produces an expression vector. Both execute whatever the text says, so they are for text the program built itself, not for text a user typed into a form; and in nearly every other case an index with [[ does the job without evaluating anything (R FAQ 7.21: How can I turn a string into a variable? gives that advice for the variable-name case: get() or assign() at most, and preferably a list indexed by name, or substitute() with as.name() when a call has to be built; it is applied in the table at the end of this page).
Building a call instead of text
If a function must run code that varies, build the call as an object rather than as text. bquote() writes the call and splices values into it with .(), call() and as.call() assemble it from parts, and substitute() with a list fills in the names of an expression. The four calls in the block below are the same call, and a call is a list whose first element is the function.
source("scenario.R")
# Three ways to build the call subset(books, pages > 300) without typing it.
n <- 300
# 1. bquote() writes the call and splices in a value with .().
call_1 <- bquote(subset(books, pages > .(n)))
call_1
#> subset(books, pages > 300)
# 2. call() and as.call() assemble it from parts.
call_2 <- call("subset", quote(books), call(">", quote(pages), n))
call_2
#> subset(books, pages > 300)
call_3 <- as.call(list(
as.name("subset"),
quote(books),
call(">", as.name("pages"), n)
))
call_3
#> subset(books, pages > 300)
# 3. substitute() with a list fills in names in an expression.
call_4 <- substitute(subset(d, pages > n), list(d = quote(books), n = n))
call_4
#> subset(books, pages > 300)
# All four are the same call and give the same result.
identical(call_1, call_2)
#> [1] TRUE
identical(call_2, call_3)
#> [1] TRUE
identical(call_3, call_4)
#> [1] TRUE
nrow(eval(call_1))
#> [1] 6
# A call is a list: its first element is the function, the rest the
# arguments. Changing the threshold changes the call.
as.list(call_1)
#> [[1]]
#> subset
#>
#> [[2]]
#> books
#>
#> [[3]]
#> pages > 300
#>
call_1[[3L]][[3L]] <- 400
call_1
#> subset(books, pages > 400)
nrow(eval(call_1))
#> [1] 2
# Many conditions joined with `&`.
conditions <- list(quote(pages > 300), quote(year > 2000))
both <- Reduce(\(a, b) call("&", a, b), conditions)
both
#> pages > 300 & year > 2000
nrow(eval(call("subset", quote(books), both)))
#> [1] 4
Source: R documentation: bquote
call_1[[3L]][[3L]] <- 400 changed the threshold inside the call, because a call is a list-like object whose elements can be replaced, as the manual does with the result of match.call() in its treatment of function calls (The R Language Definition: Manipulation of function calls). Reduce() joined a list of conditions with &, which is the base R counterpart of what the tidy !!! splice does for filter(). The standard-evaluation escape hatches of this section and the previous ones fit in one table.
| You have | Use | Instead of |
|---|---|---|
A column name in a string |
|
|
A variable name in a string |
|
|
Arguments in a list |
|
building the call as text |
A value to put into an expression |
|
|
An expression typed by the user, to forward |
the twin with |
passing the symbol on |
A call to change |
|
editing the text of the call |
One expression in a string that the program wrote |
|
|
The structure of calls is described in Language Objects and Quoting, and the whole subject of why eval(parse(text = )) is a smell comes back in the table of older habits at the end.
local() and evalq()
local(expr, envir = new.env()) is evalq() in a fresh environment. The source printed below is the whole definition: eval.parent(substitute(eval(quote(expr), envir))) builds a call to eval() with the quoted expression and runs it in the caller’s frame, so the default new.env() is created there and becomes the scope of the block. The help page presents it as the way to make anonymous recursive functions and as a kind of limited namespace, since the variables defined in the environment are not visible from outside. The scoping page shows more uses (Scoping, Environments and Closures); the points that belong here are what it is made of and how it differs from a brace block.
source("scenario.R")
# local() is eval(quote(expr), new.env()) in the caller's frame.
formals(local)
#> $expr
#>
#>
#> $envir
#> new.env()
#>
body(local)
#> eval.parent(substitute(eval(quote(expr), envir)))
# Helper variables stay out of the workspace.
due_date <- local({
grace_days <- 3L
function(loan_date) loan_date + loan_days + grace_days
})
due_date(as.Date("2026-06-01"))
#> [1] "2026-06-25"
exists("grace_days")
#> [1] FALSE
ls(environment(due_date))
#> [1] "grace_days"
# The environment can be given: local() with a named environment is evalq().
shelf_env <- new.env()
local({
copies <- 2L
title <- "Paper Moons"
}, envir = shelf_env)
sort(ls(shelf_env))
#> [1] "copies" "title"
identical(
evalq(copies, shelf_env),
local(copies, shelf_env)
)
#> [1] TRUE
# A { } block is not a new scope; local() is.
{ # nolint: brace_linter.
block_value <- 1
}
exists("block_value")
#> [1] TRUE
local({
local_value <- 1
})
exists("local_value")
#> [1] FALSE
# return() inside local() ends the local() call, not the surrounding
# function, because eval() catches it.
outer_fn <- function() {
local({
return("from local")
"never reached"
})
"after local"
}
outer_fn()
#> [1] "after local"
# <<- assigns in the enclosing environments of the local scope, so use it
# with care: here it reaches the global environment.
local({
counter <- 0
counter <<- 99
})
counter
#> [1] 99
A function created inside local() keeps the environment of the block, which is how due_date() carries grace_days with it while the workspace stays clean (that is a closure, built on purpose; see Function Factories and Higher-Order Functions). local(copies, shelf_env) and evalq(copies, shelf_env) are the same call. A { } block, on the other hand, is not a scope: the variable assigned inside it exists in the workspace afterwards. Because local() is implemented with eval(), a return() inside it ends the local() call and not the surrounding function (outer_fn() returned "after local"), and a <<- assigns in the enclosing environments of the block, where it will find the variable or create a global one (the last example created and changed a global counter). Both are reasons to prefer a small named function when the block gets long.
The call stack
Every function call creates a frame, and the frames are linked in the order of the calls. The Language Definition lists the accessors in its section on the call stack: sys.call, sys.frame, sys.nframe, sys.function, sys.parent, sys.calls, sys.frames, sys.parents, sys.on.exit, sys.status and parent.frame, and notes that making variables defined higher up the stack available is dynamic scope, which contradicts the lexical scope R normally uses; some functions, particularly those that use and manipulate model formulas, need to simulate it by directly accessing the stack (The R Language Definition: The call stack). The help page gives the numbering: the global environment is frame 0, and each function evaluation increases the stack by one. Non-negative which values are frame numbers and negative values count back from the current frame. The parent of a frame is the environment the function was called from, which is not necessarily the previous frame number and is not the environment where the function was defined.
# Each function call adds a frame. sys.call(), sys.function() and
# sys.frame() read a frame by number; parent.frame() reads the caller's.
outer_fn <- function(a) middle_fn(a + 1)
middle_fn <- function(b) inner_fn(b * 2, label = "x")
inner_fn <- function(v, label) {
list(
nframe = sys.nframe(),
n_frames = length(sys.frames()),
own_call = sys.call(),
caller_call = sys.call(-1L),
first_call = sys.call(1L),
parents = sys.parents(),
parent_number = sys.parent()
)
}
stack <- outer_fn(1)
str(stack)
#> List of 7
#> $ nframe : int 3
#> $ n_frames : int 3
#> $ own_call : language inner_fn(b * 2, label = "x")
#> $ caller_call : language middle_fn(a + 1)
#> $ first_call : language outer_fn(1)
#> $ parents : int [1:3] 0 1 2
#> $ parent_number: int 2
# sys.calls() is the whole stack; the top-level call is frame 1.
stack_fn <- function() sys.calls()
wrapper_fn <- function() stack_fn()
wrapper_fn()
#> [[1]]
#> wrapper_fn()
#>
#> [[2]]
#> stack_fn()
#>
# sys.function() returns the function that is running.
show_name <- function() deparse1(sys.call()[[1L]])
show_name()
#> [1] "show_name"
which_fn <- function() sys.function()
identical(which_fn(), which_fn)
#> [1] TRUE
# sys.call() inside a frame is the call as written by the caller; the
# arguments are not matched or evaluated.
describe_call <- function(x, ...) sys.call()
describe_call(1, label = "a")
#> describe_call(1, label = "a")
describe_call(label = "a", 1)
#> describe_call(label = "a", 1)
inner_fn() ran at frame 3, with sys.parents() giving 0 1 2: each function was called from the one before. sys.call(-1L) is the call that created the caller, sys.call(1L) the call that started the whole stack, and sys.calls() the whole stack at once, which is what a traceback is made of and what withCallingHandlers() can print without unwinding (see Debugging). sys.call() returns the call as the caller wrote it: the arguments are not matched or named, so describe_call(label = "a", 1) shows the arguments in the order they were typed, which is the reason match.call() exists (next section). length(sys.frames()) is the same number as sys.nframe().
parent.frame() is not always the previous frame
parent.frame(n) is shorthand for sys.frame(sys.parent(n)), the help page says, and it follows the chain of callers, not the numbers. A function that is called by another function that was called by a third has parent.frame(2) as the environment of the third, if the chain is linear. The chain is not linear when a function is invoked through another: lapply() is a function with a frame of its own, so the caller of the function it applies is the frame of lapply(); do.call() evaluates the call in the envir you give it, so that is the parent frame in that case. And code evaluated by eval() sits deeper in the stack than the function that called eval(), because the stack holds both the call to the eval() closure and the context that the evaluator creates for the evaluation, and both print as the call to eval().
# parent.frame() is the frame of the function that called this one, which
# is not always the frame one level up the stack.
whoami <- function() parent.frame()
direct <- function() identical(whoami(), environment())
direct()
#> [1] TRUE
# Called through lapply(): the caller is lapply()'s own frame, so the
# function frame is two generations up.
via_lapply <- function() {
me <- environment()
lapply(1, \(i) {
c(
parent = identical(parent.frame(), me),
grandparent = identical(parent.frame(2L), me)
)
})[[1L]]
}
via_lapply()
#> parent grandparent
#> FALSE TRUE
# Called through do.call(): the caller is the environment given as envir.
via_do_call <- function() {
me <- environment()
elsewhere <- new.env()
c(
default = identical(do.call(whoami, list()), me),
envir = identical(do.call(whoami, list(), envir = elsewhere), elsewhere)
)
}
via_do_call()
#> default envir
#> TRUE TRUE
# Code run by eval() sits two frames deeper: the eval() closure and the
# context of the evaluator are both on the stack, and both print as eval().
frames_inside_eval <- function() {
c(
plain = sys.nframe(),
in_eval = eval(quote(sys.nframe()))
)
}
frames_inside_eval()
#> plain in_eval
#> 1 3
calls_inside_eval <- function() eval(quote(sys.calls()))
calls_inside_eval()
#> [[1]]
#> calls_inside_eval()
#>
#> [[2]]
#> eval(quote(sys.calls()))
#>
#> [[3]]
#> eval(quote(sys.calls()))
#>
In the first function whoami() returned the frame of direct() as expected. Through lapply(), parent.frame() was not the frame of via_lapply() but the frame of lapply(), and parent.frame(2L) was. Through do.call() with an envir, the parent was exactly that environment. The code in frames_inside_eval() ran at frame 3, while the function itself ran at frame 1, and sys.calls() shows the two eval() entries. This is why the help page cautions that internal functions may or may not set contexts and so may not be counted, that S3 methods can do surprising things, and that, as an effect of lazy evaluation, the stack functions look at the stack at the time they are evaluated and not at the time they are called.
The same fact explains a trap in the rule to evaluate a default in the callee. parent.frame() as a default argument is evaluated when the argument is first used, inside the function, so it counts frames from there and gives the function’s caller (a function f(env = parent.frame()) is correct). The help page advises against passing calls to the sys. functions as arguments to other functions, because they still look at the stack and count from the frame of the function evaluation they were called from. When the environment must be captured at a particular moment, capture it in a variable at the top of the function: caller <- parent.frame().
match.call(): the call with every argument named
match.call(definition = sys.function(sys.parent()), call = sys.call(sys.parent()), expand.dots = TRUE, envir = parent.frame(2L)) returns the call in which all the specified arguments are given by their full names, in the order of the formals. The Language Definition introduces it as the function to use instead of sys.call() when a function needs the call with every actual argument bound to its formal (The R Language Definition: Manipulation of function calls). The help page lists the two circumstances where it is most useful: to record the call for later reuse, as the model-fitting functions do in the call element of their result, and to pass most of the call to another function, often model.frame(), usually with expand.dots = FALSE and the ... element removed (R documentation: match.call). The envir argument, new in R 3.2.0, is where the ... of the call are retrieved from; it matters when a function passes its own dots on, as in the fifth use in the second block below.
source("scenario.R")
# match.call() returns the call with every argument named in full.
shelf_fine <- function(days_late, rate = 0.25, ...) match.call()
shelf_fine(3)
#> shelf_fine(days_late = 3)
shelf_fine(rate = 0.5, 3)
#> shelf_fine(days_late = 3, rate = 0.5)
shelf_fine(3, da = 5)
#> shelf_fine(days_late = 5, rate = 3)
shelf_fine(3, 0.1, "extra", note = "waived")
#> shelf_fine(days_late = 3, rate = 0.1, "extra", note = "waived")
# sys.call() is the call as typed.
typed <- function(days_late, rate = 0.25, ...) sys.call()
typed(rate = 0.5, 3)
#> typed(rate = 0.5, 3)
# expand.dots = FALSE collects the dots into one element.
collect <- function(days_late, ...) match.call(expand.dots = FALSE)
cl <- collect(3, "a", note = "waived")
cl
#> collect(days_late = 3, ... = pairlist("a", note = "waived"))
cl$...
#> [[1]]
#> [1] "a"
#>
#> $note
#> [1] "waived"
#>
names(cl$...)
#> [1] "" "note"
# The pieces of a call: the function, then the arguments by name.
cl <- shelf_fine(rate = 0.5, 3)
cl[[1L]]
#> shelf_fine
as.list(cl)[-1L]
#> $days_late
#> [1] 3
#>
#> $rate
#> [1] 0.5
#>
cl$rate
#> [1] 0.5
cl$days_late
#> [1] 3
# match.call() can match a call that was never run, against any closure.
match.call(shelf_fine, quote(shelf_fine(rate = 0.1, 7)))
#> shelf_fine(days_late = 7, rate = 0.1)
# It does not accept primitives.
tryCatch(
match.call(sum, quote(sum(1, 2))),
error = conditionMessage
)
#> [1] "invalid 'definition' argument"
# Arguments that cannot be matched are errors.
tryCatch(
match.call(shelf_fine, quote(shelf_fine(1, rate = 2, rate = 3))),
error = conditionMessage
)
#> [1] "formal argument \"rate\" matched by multiple actual arguments"
Source: R documentation: match.call
shelf_fine(rate = 0.5, 3) was returned with days_late = 3 first and rate = 0.5 second. The third call shows that partial matching happens before the call is recorded: da was expanded to days_late and the unnamed 3 became rate, which is what R would have done when running the call. With expand.dots = FALSE the extra arguments are collected into one element whose tag is ...; in R 4.6.1 this element is a pairlist (the call prints as ... = pairlist("a", note = "waived")), and the manual describes it as a pairlist, not a call to list() as in S. match.call() can match a call that was never run (match.call(shelf_fine, quote(...))), it refuses primitives, and an argument matched twice is an error with the message shown.
A matched call is a list: the first element is the function, the others are the arguments, tagged with the formal names. That is why the idioms of modelling functions work: set an element to NULL to delete an argument, assign to a tag to add or change one, replace the first element (with a name, not a string) to call another function, and evaluate the result in the parent frame, where the expressions make sense. The manual shows the start of lm() as its example.
source("scenario.R")
# 1. Record the call, as lm() does: the fit remembers how it was made.
fit <- lm(pages ~ year, data = books)
fit$call
#> lm(formula = pages ~ year, data = books)
class(fit$call)
#> [1] "call"
# 2. Re-run the call with something changed.
cl <- fit$call
cl$subset <- quote(year > 1990)
cl
#> lm(formula = pages ~ year, data = books, subset = year > 1990)
refit <- eval(cl)
nobs(refit)
#> [1] 11
# 3. Forward the matching arguments to another function, the way
# model.frame() is called from modelling functions.
shelf_model <- function(formula, data, subset, ...) {
cl <- match.call(expand.dots = FALSE)
cl$... <- NULL
cl[[1L]] <- quote(stats::model.frame)
cl
}
shelf_model(pages ~ year, books, year > 1990, na.action = na.omit)
#> stats::model.frame(formula = pages ~ year, data = books, subset = year >
#> 1990)
# 4. Name the caller in a message. sys.call() looks at the stack when it is
# evaluated, so ask for the name before the value is needed by another call.
caller_name <- function() {
as.character(sys.call(-1L)[[1L]])
}
report <- function(x) {
who <- caller_name()
cat("called as", who, "\n")
invisible(x)
}
report_late <- function(x) {
cat("called as", caller_name(), "\n")
invisible(x)
}
alias <- report
alias(1)
#> called as alias
report_late(1)
#> called as cat
# 5. Matching through a wrapper: the dots are resolved in the caller.
inner <- function(a, b) match.call()
wrapper <- function(...) inner(...)
wrapper(1, b = 2)
#> inner(a = 1, b = 2)
wrapper(b = 2, 3)
#> inner(a = 3, b = 2)
Five uses appear in that block. fit$call is the recorded call, and a modified copy re-fit the model on a subset. shelf_model() builds the call to stats::model.frame() from its own matched call, dropping the dots; it is not evaluated here, only printed. report() and report_late() look at sys.call(-1L) and show the lazy-evaluation trap described in the call-stack section: caller_name() evaluated inside the call to cat() found cat() as its caller, because the promise for the argument was forced there, while the same call made before cat() found the right name, alias. Last, inner() called through wrapper(...) matched the dots in the caller and found the expressions that were typed (1, b = 2), as the envir argument of match.call() is designed to do.
sys.call() and the call that errors show
sys.call() is the call as typed, which makes it the right tool for messages. stop() records sys.call(-1) of its own frame, the call of the function that raised the error, and that is what conditionCall() returns and what Error in check_pages(-1) : prints; stop(call. = FALSE) omits it. A helper that signals an error on behalf of its caller can build a condition with a call of its choosing, as fail_in_caller() does with sys.call(-1L). The conditions page covers the classes and the handlers (Conditions and Error Handling); here the point is where the call comes from.
# The call that an error shows is sys.call() of the function that called
# stop(); conditionCall() returns it.
check_pages <- function(pages) {
if (pages < 0) {
stop("pages must not be negative")
}
pages
}
e <- tryCatch(check_pages(-1), error = identity)
conditionMessage(e)
#> [1] "pages must not be negative"
conditionCall(e)
#> check_pages(-1)
# call. = FALSE drops it.
check_pages_quiet <- function(pages) {
if (pages < 0) {
stop("pages must not be negative", call. = FALSE)
}
pages
}
e <- tryCatch(check_pages_quiet(-1), error = identity)
is.null(conditionCall(e))
#> [1] TRUE
# A helper can blame its caller: the call to report is sys.call(-1).
fail_in_caller <- function(msg) {
stop(simpleError(msg, call = sys.call(-1L)))
}
check_year <- function(year) {
if (year > 2100) {
fail_in_caller("implausible year")
}
year
}
e <- tryCatch(check_year(2200), error = identity)
conditionCall(e)
#> check_year(2200)
Source: R documentation: stop
check_pages_quiet() gives is.null(conditionCall(e)) as TRUE. sys.function() returns the function being evaluated and sys.call()[[1L]] its name as called, so show_name() earlier printed "show_name"; both give the information that Recall() and anonymous recursion rely on (Functions has the details).
do.call(): a call built from data
do.call(what, args, quote = FALSE, envir = parent.frame()) constructs and executes a call from a function, or the name of one, and a list of arguments; the names of the list are the argument names (R documentation: do.call). The basic uses (binding a list of data frames with rbind, passing a list of options) are shown in Functions; this section is about what it does with the arguments, because that is where it meets evaluation.
source("scenario.R")
# do.call(what, args, quote, envir): what is a function or its name, args a
# list; the names of the list are the argument names.
do.call(paste, list("a", "b", sep = "-"))
#> [1] "a-b"
do.call("paste", list("a", "b", sep = "-"))
#> [1] "a-b"
args_list <- list(c(310, 288, NA), na.rm = TRUE)
do.call(mean, args_list)
#> [1] 299
# The arguments are values that are put into the call. A symbol or a call in
# the list is evaluated, in the calling environment, unless quote = TRUE.
shelf_name <- "Main shelf"
do.call(paste, list(as.name("shelf_name"), "!"))
#> [1] "Main shelf !"
do.call(paste, list(as.name("shelf_name"), "!"), quote = TRUE)
#> [1] "shelf_name !"
do.call(paste, list(quote(1 + 2), "!"))
#> [1] "3 !"
do.call(paste, list(quote(1 + 2), "!"), quote = TRUE)
#> [1] "+ !" "1 !" "2 !"
# A name is looked up as a function in envir (the calling environment by
# default); a package prefix is not allowed in the string.
tryCatch(
do.call("stats::sd", list(1:3)),
error = conditionMessage
)
#> [1] "could not find function \"stats::sd\""
do.call(stats::sd, list(1:3))
#> [1] 1
env <- new.env()
assign("rate", function(x) x * 0.25, envir = env)
do.call("rate", list(8), envir = env)
#> [1] 2
# Errors from do.call() itself.
tryCatch(do.call(1, list(1)), error = conditionMessage)
#> [1] "'what' must be a function or character string"
tryCatch(do.call(sum, 1:3), error = conditionMessage)
#> [1] "second argument must be a list"
tryCatch(do.call("no_such_fn", list()), error = conditionMessage)
#> [1] "could not find function \"no_such_fn\""
Source: R documentation: do.call
The help page says that with quote = FALSE the arguments are evaluated, in the calling environment and not in envir, and that with quote = TRUE each argument is quoted, so that evaluating an argument only removes the quote and the original arguments are left unevaluated in the constructed call. The example makes it concrete: the symbol shelf_name in the list was looked up ("Main shelf !") unless quote = TRUE, when the function received the symbol itself ("shelf_name !"), and a call such as quote(1 + 2) was evaluated to 3 unless quoted, in which case paste() received the call and split it into its parts. envir is where a function given by name is looked up, and, as the call-stack section showed, the parent frame of the called function, which is the use the help page recommends it for: when what is a string and the arguments are symbols or quoted expressions. A string can name a function but cannot carry a package prefix: "stats::sd" is not the name of any function, and stats::sd (the function) works.
Why do.call("f", list(big_object)) reads badly
The list is turned into a call whose arguments are the values themselves, and everything that reports the call reports the values: error messages, traceback(), sys.call(), match.call() and substitute(). For a small vector the call is short. For a data frame it runs to dozens of lines, and a function given as an object instead of a name puts its whole definition in the call, which is what the traceback of an error shows. The help page warns that the behaviour of substitute() and similar functions under do.call() is undefined and subject to change.
source("scenario.R")
check_input <- function(x) stop("failed")
# The call that do.call() builds holds the values themselves, so a failure
# reports them. With a name for the function the call stays short.
e <- tryCatch(do.call("check_input", list(1:3)), error = identity)
deparse(conditionCall(e))
#> [1] "check_input(1:3)"
# With a big object the call is the whole object.
e <- tryCatch(do.call("check_input", list(books)), error = identity)
length(deparse(conditionCall(e)))
#> [1] 21
substr(deparse(conditionCall(e))[1L], 1, 60)
#> [1] "check_input(structure(list(title = c(\"The Dispossessed\", \"Pr"
# With the function itself, the definition is in the call too.
e <- tryCatch(do.call(check_input, list(1:3)), error = identity)
deparse(conditionCall(e))
#> [1] "(function (x) " "stop(\"failed\"))(1:3)"
# match.call() and substitute() see values, not the names the caller used.
show_arg <- function(x) deparse1(substitute(x))
do.call("show_arg", list(1:3))
#> [1] "1:3"
my_pages <- books$pages[1:3]
show_arg(my_pages)
#> [1] "my_pages"
do.call("show_arg", list(my_pages))
#> [1] "c(387L, 432L, 214L)"
do.call("show_arg", list(as.name("my_pages")))
#> [1] "my_pages"
# A model fitted through do.call() stores the data in its call.
fit <- do.call("lm", list(pages ~ year, data = books))
length(deparse(fit$call))
#> [1] 22
fit_direct <- lm(pages ~ year, data = books)
deparse(fit_direct$call)
#> [1] "lm(formula = pages ~ year, data = books)"
fit_quoted <- do.call("lm", list(pages ~ year, data = quote(books)))
deparse(fit_quoted$call)
#> [1] "lm(formula = pages ~ year, data = books)"
Source: R documentation: do.call
do.call("check_input", list(books)) built a call whose deparse() is 21 lines long, and the model that was fitted through do.call("lm", ...) stored the whole data frame in its call element (22 lines), where lm(pages ~ year, data = books) stores one short line. Everything that prints or deparses the call later, including summary() of the model and update(), pays for it, and update() would re-evaluate a call that contains a copy of the data instead of the name books. Three habits avoid it. Pass the function by name when it is a string. Pass the data as a symbol with quote = TRUE or with as.name(), as the last call in the block did, so that the call contains data = books. And do not build calls with large values when you can build them with names. The cost is not only readability: R 4.1.2 made try() considerably faster when an error has a long call, such as one made by do.call(), which shows that long calls were a practical problem (see the version notes).
delayedAssign() and the environment of a closure
delayedAssign(x, value, eval.env = parent.frame(1), assign.env = parent.frame(1)) creates a promise by hand: it binds x in assign.env to the expression value, which is evaluated, once, in eval.env the first time x is used (R documentation: delayedAssign). A function argument is the same object, made by the call; the page on Lazy Evaluation and Promises has the life cycle of a promise and its pitfalls, and the block below only shows the pieces that matter for non-standard evaluation: the evaluation is delayed, the expression stays visible to substitute() inside a function, and the two environments are independent.
source("scenario.R")
# delayedAssign(x, value, eval.env, assign.env) binds a promise: the value
# expression is evaluated, once, the first time x is used.
delayedAssign("slow_total", {
cat("computing the total\n")
sum(books$pages, na.rm = TRUE)
})
cat("before use\n")
#> before use
slow_total
#> computing the total
#> [1] 3755
slow_total
#> [1] 3755
# Inside a function, substitute() on a promise returns its expression, and
# the expression is still there after the promise has been forced.
inspect_promise <- function() {
delayedAssign("lazy", books$pages[1:3] * 2) # nolint: object_usage_linter.
before <- substitute(lazy)
value <- lazy
list(before = before, value = value, after = substitute(lazy))
}
inspect_promise()
#> $before
#> books$pages[1:3] * 2
#>
#> $value
#> [1] 774 864 428
#>
#> $after
#> books$pages[1:3] * 2
#>
# eval.env is where the expression is evaluated, assign.env where the
# binding is made; they default to the current frame.
data_env <- list2env(list(n = 5))
target_env <- new.env()
delayedAssign(
"five_squared", n^2,
eval.env = data_env, assign.env = target_env
)
exists("five_squared")
#> [1] FALSE
get("five_squared", envir = target_env)
#> [1] 25
# A function argument is the same thing: a promise made by the call.
lazy_arg <- function(x) {
cat("inside, before use\n")
x
invisible(NULL)
}
lazy_arg({
cat("evaluating the argument\n")
1
})
#> inside, before use
#> evaluating the argument
Source: R documentation: delayedAssign
The message "computing the total" appeared when slow_total was first used, not when it was defined, and the second use printed nothing: the value is cached. inspect_promise() returned the expression books$pages[1:3] * 2 both before and after the promise was forced. In the next block five_squared was evaluated in data_env and bound in target_env, so it does not exist in the global environment. And in the last block, the argument was evaluated after inside, before use had been printed, at the moment x was used. These are the properties that eval() with a captured expression imitates by hand.
The environments involved
In an NSE call four environments are in play, and most mistakes are a confusion between two of them. The frame of the function that does the evaluation is where its own variables live; its caller’s frame (parent.frame()) is where the user’s variables live; the closure environment (environment(f)) is where the function was created, which is where free variables of its body are found; and the data mask is a temporary environment between the other two. The scoping page treats the first three in depth (Scoping, Environments and Closures).
source("scenario.R")
# Four environments take part in every NSE call. For each, identical() says
# which one it is.
make_counter <- function() {
count <- 0
function() {
count <<- count + 1 # nolint: assignment_linter.
count
}
}
counter <- make_counter()
counter()
#> [1] 1
probe <- function(data) {
list(
frame = environment(),
caller = parent.frame(),
defined_in = environment(sys.function()),
mask = eval(quote(environment()), data, parent.frame())
)
}
caller_fn <- function(data) {
e <- probe(data)
c(
probe_frame_is_callers = identical(e$frame, environment()),
parent_frame_is_callers = identical(e$caller, environment()),
defined_in_global = identical(e$defined_in, globalenv()),
mask_parent_is_callers = identical(parent.env(e$mask), environment()),
mask_has_columns = all(names(data) %in% ls(e$mask))
)
}
caller_fn(books)
#> probe_frame_is_callers parent_frame_is_callers defined_in_global
#> FALSE TRUE TRUE
#> mask_parent_is_callers mask_has_columns
#> TRUE TRUE
# The closure's environment is where it was created, not where it is called.
environmentName(environment(sd))
#> [1] "stats"
environmentName(environment(mean))
#> [1] "base"
ls(environment(counter))
#> [1] "count"
get("count", envir = environment(counter))
#> [1] 1
environmentName(topenv(environment(counter)))
#> [1] "R_GlobalEnv"
Source: R documentation: environment
For the call to probe(data) made from caller_fn(), the frame of probe() is of course not the caller’s frame, parent.frame() is exactly the caller’s frame, the function is defined in the global environment (environment(sys.function())), and the parent of the data mask is the caller’s frame, because eval() was given parent.frame() as its third argument. sd lives in the namespace of stats and mean in base, and the counter’s own environment holds the state that its closure updates.
Tidy evaluation: a layer over these mechanisms
Everything so far is base R. The tidyverse packages use the same ideas with a different interface, and the interface solves the two pitfalls of the previous sections, forwarding an argument and the ambiguity between a column and a variable. This section is package material (rlang 1.3.0 and dplyr 1.2.1, checked against their package documentation) and is deliberately framed as a translation: for each tidy construct it names the base mechanism that does the same job, and the last script of the section proves the equivalence by running both. The dplyr article on programming says that most dplyr verbs use tidy evaluation, which it describes as the special kind of non-standard evaluation used across the tidyverse, in two forms: data masking (arrange(), count(), filter(), group_by(), mutate() and summarise()) and tidy selection (across(), relocate(), rename(), select() and pull()) (dplyr package documentation: Programming with dplyr). The documentation of every argument says which one it uses, as <data-masking> or <tidy-select>.
library(dplyr) prints a message about masking when it is attached, shown here once. The other scripts of this section use suppressPackageStartupMessages(library(dplyr)) to keep the output short, but the message matters: after library(dplyr) an unqualified filter() is dplyr’s and not the time-series filter of stats.
# Attaching dplyr prints the masking message that the other scripts hide.
library(dplyr)
#>
#> Attaching package: ‘dplyr’
#>
#> The following objects are masked from ‘package:stats’:
#>
#> filter, lag
#>
#> The following objects are masked from ‘package:base’:
#>
#> intersect, setdiff, setequal, union
#>
# After library(dplyr), an unqualified filter() is dplyr's.
environmentName(environment(filter))
#> [1] "dplyr"
environmentName(environment(stats::filter))
#> [1] "stats"
identical(filter, stats::filter)
#> [1] FALSE
The message lists what is masked: filter() and lag() from stats, and intersect(), setdiff(), setequal() and union() from base.
Quosures: an expression with its environment
The first difference is what is captured. substitute() returns an expression and parent.frame() returns the environment, as two separate pieces of information that the programmer has to keep together. rlang::enquo() captures both in one object, a quosure. A quosure carries an expression, which quo_get_expr() returns, and an environment, which quo_get_env() returns; it is hygienic because it evaluates in the environment it carries, and maskable because, when evaluated in a data mask, the mask comes first (rlang package documentation: What are quosures and when are they needed?). The documentation points out that quosures are inspired by the environment-tracking mechanism of formulas and that they resemble promises, with one difference: a promise is evaluated once and cached, while a quosure can be evaluated repeatedly, in different masks, which is what a grouped computation needs. The class vector c("quosure", "formula") and the printed ^ in front of the expression are the visible signs.
source("scenario.R")
suppressPackageStartupMessages(library(dplyr))
library(rlang)
# quo() captures an expression and the environment it was written in.
q <- quo(pages > 300)
q
#> <quosure>
#> expr: ^pages > 300
#> env: global
class(q)
#> [1] "quosure" "formula"
quo_get_expr(q)
#> pages > 300
quo_get_env(q)
#> <environment: R_GlobalEnv>
# A quosure is a formula in disguise: one-sided, with an environment.
is_formula(q)
#> [1] TRUE
f <- ~ pages > 300
class(f)
#> [1] "formula"
environmentName(environment(f))
#> [1] "R_GlobalEnv"
# enquo() does for a function argument what substitute() does, and adds the
# environment of the caller (base: parent.frame()).
capture <- function(x) {
x <- enquo(x)
list(
expr = quo_get_expr(x),
env_is_caller = identical(quo_get_env(x), parent.frame())
)
}
capture(pages * 2)
#> $expr
#> pages * 2
#>
#> $env_is_caller
#> [1] TRUE
#>
in_a_function <- function() {
capture(pages * 2) # nolint: object_usage_linter.
}
in_a_function()$env_is_caller
#> [1] TRUE
# The base equivalent: an expression and an environment, kept apart.
capture_base <- function(x) {
list(expr = substitute(x), env = parent.frame())
}
b <- capture_base(pages * 2)
b$expr
#> pages * 2
identical(b$env, globalenv())
#> [1] TRUE
# Evaluating a quosure needs eval_tidy(); base eval() cannot see the
# environment the quosure carries.
eval_tidy(q, books)[1:5]
#> [1] TRUE TRUE FALSE FALSE TRUE
eval(quo_get_expr(q), books, quo_get_env(q))[1:5]
#> [1] TRUE TRUE FALSE FALSE TRUE
quo(pages > 300) printed as <quosure> with expr: ^pages > 300 and env: global, the environment shown by name when it is a well-known one (a quosure created inside a function prints the address of that function’s frame, which these pages leave out). capture() returned the same expression that substitute() returns in capture_base(), and quo_get_env() is the same environment as parent.frame(). The final two lines show the other direction: a quosure is not a call, so base eval() cannot evaluate it, but its two parts can be passed separately and give the same logical vector as eval_tidy(). The rlang page states the limitation in these terms: for historical reasons, base::eval() does not support quosure evaluation, and quosures currently require eval_tidy().
Forwarding with eval_tidy() and {{ }}
eval_tidy(expr, data) is eval() with a data mask that also understands quosures and the .data pronoun. A function built on it has the same forwarding problem as the base version, because enquo() captures one level up: whatever the user typed, as seen by the function that is called directly. The solution in tidy evaluation is to forward the quosure, and the embrace operator is the way to write it: {{ cond }} captures the argument cond of the current function, with the environment of the user, and injects it into the call. The rlang documentation describes it as combining enquo() and !! in one step (rlang package documentation: Embrace operator).
source("scenario.R")
library(rlang)
# The forwarding problem again, with eval_tidy(): the argument is captured
# one level up, so it is the symbol `cond` in the first function.
count_rows <- function(data, cond) {
cond <- enquo(cond)
sum(eval_tidy(cond, data), na.rm = TRUE)
}
count_rows(books, pages > 300)
#> [1] 6
report_rows <- function(data, cond) {
count_rows(data, cond)
}
tryCatch(
report_rows(books, pages > 300),
error = conditionMessage
)
#> [1] "object 'pages' not found"
# Embracing forwards the user's expression, with the user's environment.
report_rows <- function(data, cond) {
count_rows(data, {{ cond }})
}
report_rows(books, pages > 300)
#> [1] 6
# The expression keeps its own environment, so a variable of the user's
# function is found even though the data is looked up in the first.
count_longer <- function(min_pages) {
report_rows(books, pages > min_pages) # nolint: object_usage_linter.
}
count_longer(350)
#> [1] 4
# The quosure that arrives is the user's, not the wrapper's.
peek <- function(data, cond) {
quo_get_expr(enquo(cond))
}
peek_via <- function(data, cond) {
peek(data, {{ cond }})
}
peek_via(books, pages > 300)
#> pages > 300
The first report_rows() failed with object 'pages' not found, exactly the failure of long_books() earlier, for the same reason: count_rows() captured the symbol cond, evaluated it in the frame of report_rows() and forced the original promise in the global environment, where there is no pages. With {{ cond }} the second version worked and the quosure that reached count_rows() held the user’s expression and the user’s environment. count_longer() shows what the environment is for: min_pages is a variable of that function, the data mask is books, and the expression pages > min_pages is evaluated with both, as in the base pattern with parent.frame(), but across any number of function boundaries. The peek_via() call returns the expression typed by the user, not the symbol cond.
Embracing in dplyr verbs
The verbs of dplyr already defuse their arguments, so inside a function the only job is to forward the argument: embrace it where the verb expects a data-masked expression. The same argument can be embraced in as many places as needed, and an expression works as well as a name.
source("scenario.R")
suppressPackageStartupMessages(library(dplyr))
# Embrace an argument that names a column or an expression.
long_books <- function(data, cond) {
filter(data, {{ cond }})
}
long_books(books, pages > 400) |>
select(title, pages)
#> title pages
#> 1 Pride and Prejudice 432
#> 2 Notes on Gravity 410
mean_by <- function(data, by, var) {
data |>
group_by({{ by }}) |>
summarise(mean = mean({{ var }}, na.rm = TRUE), .groups = "drop")
}
books |>
mutate(genre = tolower(trimws(genre))) |>
mean_by(genre, pages)
#> # A tibble: 6 × 2
#> genre mean
#> <chr> <dbl>
#> 1 classic 356
#> 2 fiction 223.
#> 3 mystery 272.
#> 4 non-fiction 264.
#> 5 poetry 92
#> 6 science fiction 373
# {{ x }} is the short form of !!enquo(x): the two functions are the same.
mean_embrace <- function(data, var) {
summarise(data, mean = mean({{ var }}, na.rm = TRUE))
}
mean_enquo <- function(data, var) {
var <- rlang::enquo(var)
summarise(data, mean = mean(!!var, na.rm = TRUE))
}
identical(mean_embrace(books, pages), mean_enquo(books, pages))
#> [1] TRUE
mean_embrace(books, pages)
#> mean
#> 1 268.2143
# An expression works as well as a name.
mean_embrace(books, pages / 100)
#> mean
#> 1 2.682143
# Embrace the same argument as often as needed.
range_of <- function(data, var) {
summarise(
data,
n = n(),
min = min({{ var }}, na.rm = TRUE),
max = max({{ var }}, na.rm = TRUE)
)
}
range_of(books, year)
#> n min max
#> 1 16 1813 2023
The two functions mean_embrace() and mean_enquo() are equal: {{ var }} is a shorthand for !!enquo(var). The tibble printed by mean_by() is the standard print method of tibbles, with the column types under the names and the numbers shown to three significant digits. The dplyr article recommends embracing, and says to embrace each of several user-supplied expressions separately. It also notes that the data frame argument of a verb is neither data-masked nor tidy-selected, so a function that only passes a data frame on needs no special treatment.
The .data and .env pronouns
Data masking has the ambiguity that every function on this page had: a column and a variable with the same name, with the column winning. The tidy remedy is a pair of pronouns. .data$year is the column and .env$year is the variable of the environment (the rlang documentation says that .data retrieves data-variables from the data frame and .env retrieves env-variables from the environment, so the lookup is explicit and there is no ambiguity; rlang package documentation: .data and .env pronouns). .data[[col]] reads a column whose name is in a string, and it never confuses col with a column called col: the variable inside the double brackets is always an env-variable. The opposite mistake is silent: filter(books, col > 300) compares the text "pages" with the number 300, which is true, and keeps every row without an error.
source("scenario.R")
suppressPackageStartupMessages(library(dplyr))
# The ambiguity: a column and a variable with the same name.
year <- 2020
nrow(filter(books, year > year))
#> [1] 0
# .data and .env say which one is meant.
nrow(filter(books, .data$year > .env$year))
#> [1] 3
nrow(filter(books, year > .env$year))
#> [1] 3
# In a function, a parameter named like a column has the same problem.
books_after <- function(data, year) {
filter(data, .data$year > .env$year) # nolint: object_usage_linter.
}
nrow(books_after(books, 2015))
#> [1] 8
# .data[[ ]] reads a column whose name is in a string. Using the string
# itself compares text with a number and silently keeps every row.
col <- "pages"
nrow(filter(books, col > 300))
#> [1] 16
nrow(filter(books, .data[[col]] > 300))
#> [1] 6
for (col in c("pages", "year")) {
print(summarise(books, max = max(.data[[col]], na.rm = TRUE)))
}
#> max
#> 1 432
#> max
#> 1 2023
# Which one wins when there is no pronoun: the data.
mean_pages <- function(data) {
pages <- 0
summarise(data, mean = mean(pages, na.rm = TRUE))
}
mean_pages(books)
#> mean
#> 1 268.2143
# .data is a pronoun, not a data frame: it has no names.
summarise(books, n_names = length(names(.data)))
#> n_names
#> 1 0
The block shows five facts. With a global year <- 2020, filter(books, year > year) returned no rows, as subset() did; the same condition with pronouns returned the three books published after 2020. The function books_after(data, year) is safe with the pronouns even though its parameter has the name of a column. filter(books, col > 300) returned all sixteen rows, which is the silent failure described above, while .data[[col]] > 300 returned the six long books, and the loop at the end summarised two columns chosen by name. When no pronoun is used the data wins, even over a local variable set to zero in the function (mean_pages() returned the mean of the column). And .data is not a data frame: names(.data) is NULL, so length(names(.data)) is zero. The rlang documentation notes the same: .data is a pronoun, not a real data frame, so you cannot take its names or map a function over its contents, and .env is not an actual environment.
Two practical notes close the topic. In a package, .data appears to the checker as an undefined global just like a bare column name did, so it is imported from rlang or declared with utils::globalVariables(".data"), as the rlang page recommends; unlike the bare names it is a single, uniform declaration. And rlang::.data is a "fake" pronoun: write the unqualified .data in data-masked code, not rlang::.data. The lintr object-usage check also objects to .data and .env in the scripts of this page, as the nolint comment shows.
Injection: !! and !!!
Embracing covers the common case. The general mechanism underneath is injection, also called quasiquotation: changing a piece of code before R evaluates it. !!x injects the value or expression x into a defused expression, and !!!x splices a list of values or expressions as separate arguments. The rlang documentation says that !! works only in functions that take defused and data-masked arguments (including those defused with {{ }} or the en-prefixed operators like enquo()) and inside inject(), and that with base functions inject() has to be used to enable it; it also says that in tidyverse code !! is no longer a common pattern, because the .env pronoun solves the ambiguity problem more naturally and the embrace operator makes defuse-and-inject easier to learn (rlang package documentation: Injection operator). The splice operator !!! provides the same functionality as do.call(), as that page puts it, turning a list of inputs into distinct arguments (rlang package documentation: Splice operator).
The base counterparts are bquote() with .() for a value and with ..() and splice = TRUE for a list (the splicing form was added to bquote() in R 4.0.0), as.call() on a list that starts with the function, and substitute() with a list; the two families are compared at the end of this section.
source("scenario.R")
suppressPackageStartupMessages(library(dplyr))
library(rlang)
# !! injects a value, or an expression, into a defused expression.
min_pages <- 300
expr(pages > min_pages)
#> pages > min_pages
expr(pages > !!min_pages)
#> pages > 300
bquote(pages > .(min_pages))
#> pages > 300
nrow(filter(books, pages > !!min_pages))
#> [1] 6
# Injecting a name: sym() makes a symbol from a string.
col <- "pages"
col_sym <- sym(col)
col_sym
#> pages
expr(mean(!!col_sym, na.rm = TRUE))
#> mean(pages, na.rm = TRUE)
summarise(books, mean = mean(!!col_sym, na.rm = TRUE))
#> mean
#> 1 268.2143
# !!! splices a list into the arguments of a call.
cols <- c("title", "year")
select(books, !!!syms(cols)) |> head(3L)
#> title year
#> 1 The Dispossessed 1974
#> 2 Pride and Prejudice 1813
#> 3 the quiet streets 2019
conditions <- exprs(pages > 300, year > 2000)
conditions
#> [[1]]
#> pages > 300
#>
#> [[2]]
#> year > 2000
#>
nrow(filter(books, !!!conditions))
#> [1] 4
# The base R counterpart of a splice: bquote(splice = TRUE) with ..().
base_conditions <- list(quote(pages > 300), quote(year > 2000))
bquote(filter(books, ..(base_conditions)), splice = TRUE)
#> filter(books, pages > 300, year > 2000)
# !! binds like unary minus: !!a + b is (!!a) + b.
a <- 1
b <- 2
expr(!!a + b)
#> 1 + b
expr(!!a * b + 3)
#> 1 * b + 3
expr(!!(a * b) + 3)
#> 2 + 3
# Outside a function that supports it, !! is the double negation of R.
!!TRUE
#> [1] TRUE
!!c(0, 1)
#> [1] FALSE TRUE
# inject() turns the operators on for any function, base ones included.
nrow(inject(subset(books, pages > !!min_pages)))
#> [1] 6
nrow(inject(rbind(!!!list(books[1:2, ], books[3:4, ]))))
#> [1] 4
The first group compares a free variable, a value injected with !! and the same value injected with bquote(): expr(pages > !!min_pages) and bquote(pages > .(min_pages)) both printed pages > 300, so the number is part of the expression and no longer depends on the environment, which is why injecting a value is a way to settle the column-versus-variable ambiguity. sym("pages") is as.name("pages"), and syms() makes a list of symbols; injecting them with !! and !!! is the string-to-column bridge. !! binds like unary minus, so !!a + b is (!!a) + b and !!a * b + 3 is (!!a) * b + 3, as the rlang NEWS says of version 0.2.0 (rlang package documentation: NEWS). Outside a function that supports injection, !! is the double negation of base R: !!TRUE is TRUE and !!c(0, 1) is FALSE TRUE, which is why a stray !! in ordinary code is a silent mistake. And inject(), available since rlang 0.4.9, turns the operators on for any function: inject(subset(books, pages > !!min_pages)) and inject(rbind(!!!list(...))) use subset() and rbind() from base R.
Dots, names and tidy selection
Dots need no special syntax: a function that passes ... to a data-masking verb passes the expressions untouched, and the verb defuses them. enquos(...) captures the dots as a list of quosures when the function needs to look at them, and the base counterpart is substitute(list(...)) or match.call(expand.dots = FALSE)$.... Names built from the data or from a variable use the := operator with glue syntax on the left: "{{ var }}_mean" := ... takes the name from the argument, and "{prefix}_pages" takes it from a variable. Selecting columns is a second mini-language, tidy selection, and it needs a different bridge for each kind of indirection: embrace an argument, wrap a character vector in all_of() (or any_of(), which ignores missing names). The dplyr article summarises this as two forms of indirection: a data-variable in a function argument is embraced, and an env-variable holding a character vector is indexed with .data[[ ]] for data masking and wrapped in all_of() for tidy selection.
source("scenario.R")
suppressPackageStartupMessages(library(dplyr))
library(rlang)
# Dots are data-masked automatically: pass them straight on.
count_by <- function(data, ...) {
data |>
group_by(...) |>
summarise(n = n(), .groups = "drop")
}
books |>
mutate(genre = tolower(trimws(genre))) |>
count_by(genre) |>
head(3L)
#> # A tibble: 3 × 2
#> genre n
#> <chr> <int>
#> 1 classic 2
#> 2 fiction 4
#> 3 mystery 2
# enquos() captures the dots as a list of quosures, with their labels.
capture_dots <- function(...) enquos(...)
dots <- capture_dots(genre, pages > 300, n = year * 2)
names(dots)
#> [1] "" "" "n"
vapply(dots, as_label, character(1L))
#> n
#> "genre" "pages > 300" "year * 2"
class(dots)
#> [1] "quosures" "list"
# The base R counterparts: substitute() and match.call().
base_dots <- function(...) substitute(list(...))
base_dots(genre, pages > 300, n = year * 2)
#> list(genre, pages > 300, n = year * 2)
vapply(as.list(base_dots(genre, pages > 300))[-1L], deparse1, character(1L))
#> [1] "genre" "pages > 300"
# Names made from data: := with glue syntax on the left-hand side.
mean_named <- function(data, var) {
summarise(data, "{{ var }}_mean" := mean({{ var }}, na.rm = TRUE))
}
mean_named(books, pages)
#> pages_mean
#> 1 268.2143
mean_named(books, year)
#> year_mean
#> 1 1998.733
prefix <- "avg"
summarise(books, "{prefix}_pages" := mean(pages, na.rm = TRUE))
#> avg_pages
#> 1 268.2143
# Selecting columns is a second mini-language, tidy selection. A character
# vector is wrapped in all_of(); an argument is embraced.
cols <- c("pages", "year")
summarise(books, across(all_of(cols), \(x) mean(x, na.rm = TRUE)))
#> pages year
#> 1 268.2143 1998.733
mean_of <- function(data, vars) {
summarise(data, across({{ vars }}, \(x) mean(x, na.rm = TRUE)))
}
mean_of(books, c(pages, year))
#> pages year
#> 1 268.2143 1998.733
mean_of(books, where(is.numeric))
#> year pages
#> 1 1998.733 268.2143
count_by(books, genre) counted without any special syntax, because group_by(...) received the dots. capture_dots() returned quosures labelled by as_label(); base_dots() returned the same expressions as a call to list built by substitute(), which is how data.frame() finds the names. mean_named() produced pages_mean and year_mean columns from the argument, and across(all_of(cols), ...) computed the mean of two columns whose names were in a character vector, while mean_of() embraced a tidy-selection argument and accepted c(pages, year) and where(is.numeric). across() was added to dplyr in version 1.0.0, as the rlang page on the splice operator remarks when it says that splicing symbolised column names is no longer as useful since dplyr 1.0 and across().
The base equivalents side by side
The equivalences claimed in this section can be checked. The block runs each pair and compares the results with identical(); the last pair is the complete job, the tidy filter() function written with an embrace and the base one written with substitute() and eval().
source("scenario.R")
suppressPackageStartupMessages(library(dplyr))
library(rlang)
min_pages <- 300
# 1. Capturing an argument: enquo() is substitute() plus parent.frame().
capture_tidy <- function(x) enquo(x)
capture_base <- function(x) list(expr = substitute(x), env = parent.frame())
q <- capture_tidy(pages > min_pages)
b <- capture_base(pages > min_pages)
identical(quo_get_expr(q), b$expr)
#> [1] TRUE
identical(quo_get_env(q), b$env)
#> [1] TRUE
# 2. Evaluating it against data: eval_tidy() is eval() with a data mask.
identical(
eval_tidy(q, books),
eval(b$expr, books, b$env)
)
#> [1] TRUE
# 3. Injecting a value: !! is bquote() with .().
identical(expr(pages > !!min_pages), bquote(pages > .(min_pages)))
#> [1] TRUE
# 4. Splicing arguments: !!! is bquote(splice = TRUE) with ..().
args <- list(quote(pages > 300), quote(year > 2000))
identical(
expr(f(!!!args)),
bquote(f(..(args)), splice = TRUE)
)
#> [1] TRUE
identical(expr(f(!!!args)), as.call(c(quote(f), args)))
#> [1] TRUE
# 5. Names: sym() is as.name().
identical(sym("pages"), as.name("pages"))
#> [1] TRUE
# 6. Labels: as_label() is a short deparse().
as_label(quote(mean(pages, na.rm = TRUE)))
#> [1] "mean(pages, na.rm = TRUE)"
deparse1(quote(mean(pages, na.rm = TRUE)))
#> [1] "mean(pages, na.rm = TRUE)"
# 7. The whole pipeline: embrace in dplyr, substitute and eval in base.
filter_tidy <- function(data, cond) filter(data, {{ cond }})
filter_base <- function(data, cond) {
rows <- eval(substitute(cond), data, parent.frame())
data[rows & !is.na(rows), , drop = FALSE]
}
t1 <- filter_tidy(books, pages > min_pages)
t2 <- filter_base(books, pages > min_pages)
identical(t1$title, t2$title)
#> [1] TRUE
Every comparison printed TRUE (the splice was checked against both bquote() and as.call()), the two labels are the same text, and the two filter functions returned the same titles. The table collects the translations used on this page; each row names the base mechanism that does the job, and the rlang page on data masking lists the same three ingredients (defusal with substitute(), environments, explicit evaluation with eval()) as the base of the whole system.
| Job | Base R | Tidy evaluation (rlang, dplyr) |
|---|---|---|
Capture an argument |
|
|
Capture the dots |
|
|
Capture local code |
|
|
Evaluate against data |
|
|
Forward an argument to another NSE function |
build the call with |
|
Insert a value into an expression |
|
|
Insert a list of arguments |
|
|
Make a symbol from a string |
|
|
Say which variable is meant |
|
|
Enable the operators in a base function |
not applicable |
|
Make a label |
|
|
Dynamic names |
|
|
Common errors and warnings
Every message in the table was produced by a script of this page. Most of them appear in the sections above; the next script provokes the base R messages that no earlier script needed, with the shelf_subset() function of the section on writing a non-standard evaluation function.
source("scenario.R")
# The real text of the errors this page keeps meeting.
msg <- function(expr) tryCatch(expr, error = conditionMessage)
shelf_subset <- function(data, cond) {
rows <- eval(substitute(cond), data, parent.frame())
data[rows & !is.na(rows), , drop = FALSE]
}
msg(shelf_subset(books, price > 3))
#> [1] "object 'price' not found"
msg(shelf_subset(books, no_such_fn(pages)))
#> [1] "could not find function \"no_such_fn\""
msg(shelf_subset())
#> [1] "argument \"data\" is missing, with no default"
msg(eval(quote(x), "environment"))
#> [1] "invalid 'envir' argument of type 'character'"
msg(eval(quote(x), c(1, 2)))
#> [1] "numeric 'envir' arg not of length one"
msg(str2lang("pages >"))
#> [1] "<text>:2:0: unexpected end of input\n1: pages >\n ^"
msg(match.call(sum, quote(sum(1, 2))))
#> [1] "invalid 'definition' argument"
msg(sys.call(10L))
#> [1] "not that many frames on the stack"
msg(parent.frame(-1L))
#> [1] "invalid 'n' value"
msg(do.call("no_such_fn", list()))
#> [1] "could not find function \"no_such_fn\""
msg(do.call(sum, 1:3))
#> [1] "second argument must be a list"
msg(subset(books, year))
#> [1] "'subset' must be logical"
Source: R documentation: eval
The tidy evaluation messages come from a second script, which is package material: it runs against dplyr 1.2.1 and rlang 1.3.0. A dplyr error names the argument that was being computed, and the underscore verbs and funs() of older tutorials answer with their lifecycle messages.
source("scenario.R")
suppressPackageStartupMessages(library(dplyr))
library(rlang)
# What the tidy versions of the errors look like. The message of a dplyr
# error says which argument was being computed.
msg <- function(expr) tryCatch(expr, error = conditionMessage)
msg(filter(books, pages > pages_typo))
#> [1] "ℹ In argument: `pages > pages_typo`.\nCaused by error:\n! object 'pages_typo' not found"
msg(filter(books, .data$no_such_column > 1))
#> [1] "ℹ In argument: `.data$no_such_column > 1`.\nCaused by error in `.data$no_such_column`:\n! Column `no_such_column` not found in `.data`."
msg(summarise(books, mean(.data[["no_such_column"]])))
#> [1] "ℹ In argument: `mean(.data[[\"no_such_column\"]])`.\nCaused by error in `.data[[\"no_such_column\"]]`:\n! Column `no_such_column` not found in `.data`."
# Outside a function that supports injection, the operators are errors.
msg(UQ(1))
#> [1] "`UQ()` can only be used within a defused argument"
# The underscore verbs of older tutorials are defunct, funs() is deprecated.
msg(filter_(books, ~ pages > 300))
#> [1] "`filter_()` was deprecated in dplyr 0.7.0 and is now defunct.\nℹ Please use `filter()` instead.\nℹ See vignette('programming') for more help"
msg(summarise_(books, ~ mean(pages)))
#> [1] "`summarise_()` was deprecated in dplyr 0.7.0 and is now defunct.\nℹ Please use `summarise()` instead."
tryCatch(funs(mean), warning = conditionMessage)
#> [1] "`funs()` was deprecated in dplyr 0.8.0.\nℹ Please use a list of either functions or lambdas:\n\n# Simple named list: list(mean = mean, median = median)\n\n# Auto named with `tibble::lst()`: tibble::lst(mean, median)\n\n# Using lambdas list(~ mean(., trim = .2), ~ median(., na.rm = TRUE))"
| Message | When it appears | What to do |
|---|---|---|
|
A name in a data-masked expression is neither a column nor a variable in the enclosure. With an argument forwarded to another NSE function it is the column the user typed ( |
Fix the name, or fix the forwarding: capture with |
|
The expression calls a function that is not visible from the enclosure; with |
Pass the function itself ( |
|
The NSE function evaluated its |
Supply it, or test with |
|
|
Pass an environment, a list, a data frame, |
|
|
Give a single frame number. |
|
|
Fix the text. Do not build code by pasting strings together. |
|
|
Use |
|
The |
Write a comparison. |
|
|
Use |
|
|
|
|
|
Remove the duplicate. |
|
|
Use a smaller number, or |
|
|
Use a positive number of generations. |
|
|
Pass a function or its name. |
|
|
Use |
|
A data-masked argument of a dplyr verb refers to a name that does not exist; the message names the argument that was being computed. |
Fix the name, or use |
|
|
Check the names. |
|
The rlang injection function |
Use |
|
The underscore verbs of older tutorials are gone. |
Use |
The text object 'price' not found has several causes in non-standard evaluation code, and the only way to tell them apart is to ask the question of the previous sections: which environment was the expression evaluated in, and which names were visible from it?
Pitfalls collected
| Pitfall | What happens | What to do |
|---|---|---|
|
The caller’s variables are not found; the function’s own variables leak into the expression |
Always |
A parameter named like a column |
The column wins: |
Rename the parameter, or use |
A misspelled column that exists as a variable |
The variable is used silently |
Prefer ordinary evaluation ( |
Forwarding a data-masked argument |
The inner function sees the symbol, not the user’s expression |
Standard-evaluation twin with an |
|
The argument is evaluated first, in the caller |
|
Assignment inside a data frame mask |
The change is lost |
|
|
They are evaluated later and count frames from where they are forced |
Capture in a variable first |
|
Error messages and |
Pass names or use |
|
The definition is printed in every traceback |
Pass the name as a string |
|
Long expressions come back as several strings |
|
|
Runs arbitrary text, defeats the checker, and cannot be traced |
|
A column in a package function |
|
Ordinary evaluation, |
|
Variables of the data override locals |
Standard subsetting with |
Version notes
| Version | Change | Source |
|---|---|---|
R 3.2.0 |
|
NEWS 3.2.0 |
R 3.5.0 |
The |
NEWS 3.5.0 |
R 3.6.0 |
|
NEWS 3.6.0 |
R 4.0.0 |
|
NEWS 4.0.0 |
R 4.1.0 |
|
NEWS 4.1.0 |
R 4.1.2 |
|
NEWS 4.1.2 |
R 4.3.0 |
The data frame method of |
NEWS 4.3.0 |
rlang 0.2.0 (2018-02-20) |
|
rlang NEWS |
rlang 0.3.1 (2019-01-08) |
|
rlang NEWS |
rlang 0.4.0 (2019-06-25) |
The embrace operator |
rlang NEWS |
rlang 0.4.9 (2020-11-25) |
|
rlang NEWS |
dplyr 0.3 (2014-10-04) |
Every dplyr function that does non-standard evaluation gets a standard-evaluation version ending in |
dplyr NEWS |
dplyr 0.7.0 (2017-06-09) |
Tidy evaluation replaces |
dplyr NEWS |
dplyr 0.8.0 |
|
message of dplyr 1.2.1 |
dplyr 1.0.0 |
|
rlang documentation of the splice operator |
attach() is documented on R documentation: attach. The R 3.x entries are in the NEWS of the R 3 series. The rlang entries are in the rlang changelog and the dplyr deprecation messages are the ones printed by the scripts above; none of these package changes is a feature of R itself, and the R-devel NEWS (4.7.0, not released) lists no change to eval(), match.call() or do.call() at the time of writing.
Where older tutorials differ
Material on non-standard evaluation was written for the S language, for early versions of R, for the lazyeval package and for the first versions of rlang, and each generation says something different. The table lists what current code does instead.
| Older tutorial | Current practice | Why | Version |
|---|---|---|---|
|
|
text is run as code, defeats the checker and cannot be traced; R FAQ 7.21 recommends lists |
always; |
|
A named list built with |
the FAQ calls this fighting against the language |
always |
|
|
|
always |
|
|
|
R 4.0.0 |
|
|
the help pages warn that the non-standard evaluation can have unanticipated consequences |
always |
|
|
the underscore verbs are defunct in dplyr 1.2.1; |
dplyr 0.7.0 |
|
|
deprecated in dplyr 0.8.0 |
dplyr 0.8.0 |
|
|
|
rlang 0.2.0, 0.3.1 |
|
|
the same, shorter and easier to teach |
rlang 0.4.0 |
"A quosure is a formula" |
A quosure has the class |
rlang 0.2.0 changed the print method |
rlang 0.2.0 |
"NSE is a tidyverse invention" |
Base R has used it since the S days ( |
the history in the rlang documentation |
always |
|
|
the third argument chooses the caller |
always |
Community material on the same topic includes the metaprogramming chapters of the book Advanced R (Advanced R: Metaprogramming) and the dplyr article on programming, which points to them for the theory. They are community material and none of them is used as a source for a claim above, except where the text names the package documentation explicitly.
Where to go next
-
Language Objects and Quoting for symbols, calls,
quote(),bquote(),deparse()and walking an expression tree. -
Formulas for the formula as an expression with an environment,
model.frame()andterms(). -
Lazy Evaluation and Promises for what
substitute()reads and when an argument is evaluated. -
Scoping, Environments and Closures for frames, enclosures and
parent.frame(). -
Functions for argument matching,
sys.function(),Recall()and the basics ofdo.call(). -
Conditions and Error Handling and Debugging for
conditionCall(),sys.calls()in handlers and tracebacks. -
The tidyverse and dplyr for the verbs that tidy evaluation exists to support.
-
Data Frames and Subsetting and Indexing for the standard-evaluation tools that replace
subset()in functions. -
Style, Linting and Static Analysis for the checks that report undefined globals.
References
-
The R Language Definition: Computing on the language, More on evaluation, Evaluation of expression objects, Manipulation of function calls, Manipulation of functions, The call stack, Argument evaluation
-
R FAQ 7.21: How can I turn a string into a variable?
-
R NEWS: Changes in R 4.0.0, 4.1.0, 4.1.2 and 4.3.0; R NEWS (R 3.x): Changes in R 3.2.0, 3.5.0 and 3.6.0
-
rlang: data masking, quosures, enquo and enquos, embrace operator, injection operator, splice operator, .data and .env pronouns, inject, rlang NEWS