metaprogramming
R Metaprogramming with rlang
Metaprogramming is the ability to defuse, create, and inject R expressions. The core pattern is defuse-and-inject: capture code as data, optionally transform it, then inject it into another context for evaluation.
Quick Reference
| Task | Function/Operator |
|---|---|
| Defuse your own expression | expr(x + 1) |
| Defuse user's single argument | enquo(arg) |
Defuse user's ... arguments |
enquos(...) |
| Inject single expression | !! or {{ |
| Splice list of expressions | !!! |
| Get expression from quosure | quo_get_expr(q) |
| Get environment from quosure | quo_get_env(q) |
| Build symbol from string | sym("name") |
| Build symbol with .data pronoun | data_sym("name") |
| Build symbols from vector | syms(names) / data_syms(names) |
| Auto-label expression | as_label(quo) |
| Format argument as string | englue("{{ x }}") |
| Interpolate name in dynamic dots | "{name}" := value |
| Interpolate argument in name | "{{ arg }}" := value |
Defusing Expressions
Defusing stops evaluation and returns the expression as a tree-like object (a "blueprint" for computation).
# Normal evaluation returns result
1 + 1
#> [1] 2
# Defusing returns the expression
expr(1 + 1)
#> 1 + 1
expr() vs enquo()
| Function | Defuses | Returns | Use When |
|---|---|---|---|
expr() |
Your own code | Expression | Building expressions locally |
enquo() |
User's argument | Quosure | Forwarding function arguments |
enquos() |
User's ... |
List of quosures | Forwarding multiple arguments |
# Defuse your own expression
my_expr <- expr(mean(x, na.rm = TRUE))
# Defuse user's function argument (returns quosure)
my_function <- function(var) {
enquo(var)
}
my_function(cyl + am)
#> <quosure>
#> expr: ^cyl + am
#> env: global
enquos() with .named
Auto-label unnamed arguments:
g <- function(...) {
vars <- enquos(..., .named = TRUE)
names(vars)
}
g(cyl, 1 + 1)
#> [1] "cyl" "1 + 1"
g(foo = cyl, bar = 1 + 1)
#> [1] "foo" "bar"
Types of Defused Expressions
- Calls:
f(x, y),1 + 1- function invocations - Symbols:
x,df- named object references - Constants:
1,"text",NULL- literal values
Quosures
A quosure wraps an expression with its original environment. This is critical for correct evaluation when expressions travel across function and package boundaries.
Why Environments Matter
# In package A
my_function <- function(data, var) {
# 'var' was defined in the user's environment
# The quosure tracks that environment
var <- enquo(var)
# When passed to package B's function, the quosure
# ensures symbols resolve in the correct environment
pkg_b_function(data, !!var)
}
Without environment tracking, symbols might resolve to wrong objects when code crosses package boundaries.
When You Need Quosures
| Situation | Use Quosure? |
|---|---|
| Defusing function arguments | Yes - use enquo() |
| Building local expressions | No - use expr() |
| Cross-package composition | Yes - environments matter |
| Simple local evaluation | No - expr() + eval() suffices |
Quosure Operations
q <- enquo(x + 1)
quo_get_expr(q) # Extract expression: x + 1
quo_get_env(q) # Extract environment
# Create quosure manually
new_quosure(expr(x + 1), env = global_env())
# Convert expression to quosure
as_quosure(expr(x + 1), env = global_env())
Injection Operators
Injection inserts defused expressions back into code before evaluation.
{{ (Embrace)
Defuses and injects in one step. Equivalent to !!enquo(arg):
# These are equivalent:
my_summarise <- function(data, var) {
data |> dplyr::summarise({{ var }})
}
my_summarise <- function(data, var) {
data |> dplyr::summarise(!!enquo(var))
}
Use {{ when you simply need to forward an argument. Use enquo() + !! when you need to inspect or transform the expression first.
!! (Bang-Bang)
Injects a single expression:
var <- expr(cyl)
mtcars |> dplyr::summarise(mean(!!var))
#> Equivalent to: summarise(mean(cyl))
# Inject a value to avoid name collisions
x <- 100
df |> dplyr::mutate(x = x / !!x)
#> Uses column x divided by env value 100
!!! (Splice)
Injects each element of a list as separate arguments:
vars <- exprs(cyl, am, vs)
mtcars |> dplyr::select(!!!vars)
#> Equivalent to: select(cyl, am, vs)
# With enquos()
my_group_by <- function(.data, ...) {
.data |> dplyr::group_by(!!!enquos(...))
}
Where Operators Work
- Data-masked arguments: Implicitly enabled (dplyr, ggplot2, etc.)
- inject(): Explicitly enables operators in any context
- Dynamic dots:
!!!and{name}work in functions usinglist2()
# Enable injection in base functions
inject(
with(mtcars, mean(!!sym("cyl")))
)
Building Expressions from Data
sym() and syms()
Convert strings to symbols:
var <- "cyl"
sym(var)
#> cyl
vars <- c("cyl", "am")
syms(vars)
#> [[1]]
#> cyl
#> [[2]]
#> am
data_sym() and data_syms()
Create .data$col expressions (safer in tidy eval, avoids collisions):
data_sym("cyl")
#> .data$cyl
data_syms(c("cyl", "am"))
#> [[1]]
#> .data$cyl
#> [[2]]
#> .data$am
Use sym() for base R functions; use data_sym() for tidy eval functions.
Building Calls
# With call()
call("mean", sym("x"), na.rm = TRUE)
#> mean(x, na.rm = TRUE)
# With expr() and injection
var <- sym("x")
expr(mean(!!var, na.rm = TRUE))
#> mean(x, na.rm = TRUE)
Name Interpolation (Glue Operators)
In dynamic dots, use glue syntax for names.
{ for Variable Values
name <- "foo"
tibble::tibble("{name}" := 1:3)
#> # A tibble: 3 x 1
#> foo
#> <int>
#> 1 1
#> 2 2
#> 3 3
tibble::tibble("prefix_{name}" := 1:3)
#> Column named: prefix_foo
{{ for Argument Labels
my_mutate <- function(data, var) {
data |> dplyr::mutate("mean_{{ var }}" := mean({{ var }}))
}
mtcars |> my_mutate(cyl)
#> Creates column: mean_cyl
englue() for String Formatting
my_function <- function(var) {
englue("Column: {{ var }}")
}
my_function(some_column)
#> [1] "Column: some_column"
Advanced: Manual Expression Transformation
When you need to modify expressions before injection:
my_mean <- function(data, var) {
# 1. Defuse
var <- enquo(var)
# 2. Transform: wrap in mean()
wrapped <- expr(mean(!!var, na.rm = TRUE))
# 3. Inject
data |> dplyr::summarise(mean = !!wrapped)
}
For multiple arguments:
my_mean <- function(.data, ...) {
vars <- enquos(..., .named = TRUE)
# Transform each expression
vars <- purrr::map(vars, ~ expr(mean(!!.x, na.rm = TRUE)))
.data |> dplyr::summarise(!!!vars)
}
Base R Equivalents
| rlang | Base R | Notes |
|---|---|---|
expr() |
bquote() |
bquote uses .() for injection |
enquo() |
substitute() |
substitute returns naked expr, not quosure |
enquos(...) |
eval(substitute(alist(...))) |
Workaround for dots |
!! |
.() in bquote |
Only inside bquote |
eval_tidy() |
eval() |
eval_tidy supports .data/.env pronouns |
Pitfalls
{{ on Non-Arguments
{{ should only wrap function arguments. On regular objects, it captures the value, not the expression:
# Correct: var is a function argument
my_fn <- function(var) {{ var }}
# Problematic: x is not an argument
x <- 1
{{ x }} # Returns 1, not the expression
Operators Out of Context
Outside tidy eval/inject contexts, operators have different meanings:
| Operator | Intended | Outside Context |
|---|---|---|
{{ |
Embrace | Double braces (returns value) |
!! |
Inject | Double negation (logical) |
!!! |
Splice | Triple negation (logical) |
These fail silently. See the tidy-evaluation skill for details on proper usage contexts.
See Also
- tidy-evaluation: Programming patterns for data-masked functions
- designing-tidy-r-functions: Function API design principles
- rlang-conditions: Error handling with rlang
Reference Files
- topic-quosure.md - Complete quosure reference
- topic-metaprogramming.md - Advanced transformation patterns
- topic-multiple-columns.md - Multiple columns patterns
Vignettes
Access detailed rlang documentation via R:
# Defusing expressions
vignette("topic-defuse", package = "rlang")
# Injection operators
vignette("topic-inject", package = "rlang")
# Or browse all vignettes
browseVignettes("rlang")