17 Big picture

Published

August 7, 2025

Modified

August 8, 2025

Introduction

元编程(metaprogramming)可以说是本书中最难的章节,因为它需要你结果之前不相关的章节来处理你之前从未想过的问题,并且你会接触到大量的抽象概念。即使你是一个使用另一种语言的经验丰富的程序员,你现有的技能也不太可能提供太多帮助,因为现代流行语言很少有能够有R所提供的元编程水平。所以,如果你一开始感到沮丧或困惑,不要惊讶;这是这个过程中每个人都会经历的自然过程!

但在过去几年中,元编程的理论和实践已经大幅成熟,为解决常见问题提供了坚实的基础和工具,学习元编程比以往任何时候都更容易。在本章中,你将了解所有主要元素的大致情况,以及它们是如何组合在一起的。

Outline

  • 17.2节:code is tree,使用抽象语法树(abstract syntax tree)将code转换为树。
  • 17.3节:code is data,创建或修改捕获的code字符串(使用树结构)。
  • 17.4节:如何以编程方式创建新表达式。
  • 17.5节:如何通过在环境中评估表达式来执行它们。
  • 17.6节:如何通过在新环境中提供自定义功能来自定义评估。
  • 17.7节:将这种自定义扩展到数据掩码,使环境和数据框之间的界限变得模糊。
  • 17.8节:引入一种新的数据结构,称为 Quoseure, 它使这一切变得更加简单和正确。

Prerequisites

我们使用“rlang”包来介绍上述内容,再后续章节中介绍base R中的等价物。同时使用“lobstr”包来查看code的树结构。

library(rlang)
library(lobstr)

请确保你对环境和dataframe的数据结构十分了解。

Code is tree

抽象语法树(abstract syntax tree)几乎是每个编程语言的底层逻辑。R不同的地方在于,可以真实得查看和修改树结构。

使用lobstr::ast()函数可以直观地感受到代码就是一个树形结构。其中“函数”形成树地分支点,其他常量或变量形成树的叶子。

lobstr::ast(f(a, "b"))
#> █─f 
#> ├─a 
#> └─"b"

嵌套函数会创建更深的分支树:

lobstr::ast(f1(f2(a, b), f3(1, f4(2))))
#> █─f1 
#> ├─█─f2 
#> │ ├─a 
#> │ └─b 
#> └─█─f3 
#>   ├─1 
#>   └─█─f4 
#>     └─2

因为R中所有的函数都可以写成标准形式(function(x) x),所以所有的代码都会生成树形结构:

lobstr::ast(1 + 2 * 3)
#> █─`+` 
#> ├─1 
#> └─█─`*` 
#>   ├─2 
#>   └─3

Code is data

正因为R代码的底层是树形结构,所以R代码可以作为数据处理。lobstr::ast()只是直接展示了树形结构,我们需要使用rlang::expr()将这种树形结构数据类型提取出来。

expr(mean(x, na.rm = TRUE))
#> mean(x, na.rm = TRUE)
test <- expr(10 + 100 + 1000)
test
#> 10 + 100 + 1000
str(test)
#>  language 10 + 100 + 1000
class(test)
#> [1] "call"
typeof(test)
#> [1] "language"

通常称被捕获的代码为表达式(expression)。但表达式不仅只有“代码”(call),还有:变量(symbol),常量(constant),成对列表(pairlist)。

当你在函数中使用expr()时会失效,它不会捕捉传入的参数,而是直接捕获它的输入:

capture_it <- function(x) {
  expr(x)
}
capture_it(a + b + c)
#> x

需要使用enexpr()函数来捕获传入的参数:

capture_it <- function(x) {
  enexpr(x)
}
capture_it(a + b + c)
#> a + b + c

一旦捕获了表达式,我们就可以修改它了。可以把树形结构数据当作列表处理,使用[[$获取“元素”:

ast(f(x = 1, y = 2))
#> █─f 
#> ├─x = 1 
#> └─y = 2

f <- expr(f(x = 1, y = 2))

# Add a new argument
f$z <- 3
f
#> f(x = 1, y = 2, z = 3)

# Or remove an argument:
f[[2]] <- NULL
f
#> f(y = 2, z = 3)

需要注意:列表的第一个元素是函数本身f,修改第一个参数x使用[[2]]

Code can generate code

捕获代码后可以创建代码对应的树,相应地,我们可以将提取创建好地树转换为代码。

rlang::call2()函数可以构建一个“调用函数代码”:第一个参数是函数名,后续是参数。

call2("f", 1, 2, 3)
#> f(1, 2, 3)
call2("+", 1, call2("*", 2, 3))
#> 1 + 2 * 3

除了call2()函数,我们也可以使用rlang::expr()和解引操作符(unquote operator)来生成代码,这种方式特别适合构建复杂的嵌套代码。

xx <- expr(x + x)
yy <- expr(y + y)

expr(!!xx / !!yy)
#> (x + x)/(y + y)

注意:生成的是正确的语法(x + x) / (y + y),不是x + x / y + y,也不是x + (x / y) + y

解引操作符在构建函数是会更有用:首先使用enexpr()捕获参数,然后使用!!expr()创建新的代码。下面是一个生成计算变异系数代码的示例:

cv <- function(var) {
  var <- enexpr(var)
  expr(sd(!!var) / mean(!!var))
}

cv(x)
#> sd(x)/mean(x)
cv(x + y)
#> sd(x + y)/mean(x + y)

即使输入是奇怪的字符,函数也可以正常运行:

cv(`)`)
#> sd(`)`)/mean(`)`)

Evaluation runs code

当捕获并修改代码后,我们可以提供一个运行环境,然后重新运行(evaluate)代码。

base::eval()函数有两个参数:1. 代码;2. 运行环境。

eval(expr(x + y), env(x = 1, y = 10))
#> [1] 11
eval(expr(x + y), env(x = 2, y = 100))
#> [1] 102

如果环境参数缺失,会自动使用当前环境:

x <- 1
y <- 10
eval(expr(x + y))
#> [1] 11

重新评估代码的一大优势是可以调整环境。这样做主要有两个原因:

  • 临时重写函数以实现特定于领域的语言。
  • 添加数据掩码,以便可以像引用环境中的变量一样引用数据框中的变量。

Customising evaluation with functions

在重新评估代码时使用的环境中,我们不仅可以添加变量,还可以添加函数。这样我们就可以在特定的环境中重新命名已有的函数,例如:我们可以构建一个字符串的+*函数。

string_math <- function(x) {
  e <- env(
    caller_env(),
    `+` = function(x, y) paste0(x, y),
    `*` = function(x, y) strrep(x, y)
  )

  eval(enexpr(x), e)
}

name <- "Hadley"
string_math("Hello " + name)
#> [1] "Hello Hadley"
string_math(("x" * 2 + "-y") * 3)
#> [1] "xx-yxx-yxx-y"

dplyr将这个想法发挥到极致,在一个生成SQL并能在远程数据库中执行的环境中运行代码:

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

con <- DBI::dbConnect(RSQLite::SQLite(), filename = ":memory:")
mtcars_db <- copy_to(con, mtcars)

mtcars_db %>%
  filter(cyl > 2) %>%
  select(mpg:hp) %>%
  head(10) %>%
  show_query()
#> <SQL>
#> SELECT `mpg`, `cyl`, `disp`, `hp`
#> FROM `mtcars`
#> WHERE (`cyl` > 2.0)
#> LIMIT 10

DBI::dbDisconnect(con)

Customising evaluation with data

重定义函数是一种极其强大的工具,但是它要大量的投入。相反,数据掩码是一种即时快速的应用—-直接引用数据框中的变量而不是从环境中获取。数据掩码技术在很多函数中应用:subset()transform()aes()summarise()等等。虽然eval()函数能够实现数据掩码技术,但存在潜在的风险(20.6节),本节使用rlang::eval_tidy()函数来完成。

eval_tidy()函数同样接受代码和运行环境两个参数,但此处的环境特指数据框。

df <- data.frame(x = 1:5, y = sample(5))
eval_tidy(expr(x + y), df)
#> [1]  5  5  4  6 10

虽然数据掩码技术使得直接调用x + y成为可能,方便了交互使用,但同时也会造成歧义。在20.4节中我们会介绍使用.data.env来避免歧义。

搭配enexpr()函数,我们开一个创建适配数据掩码的“with”函数——with2()

# Unfortunately, this function has a subtle bug and we need a new data structure to help deal with it.
with2 <- function(df, expr) {
  eval_tidy(enexpr(expr), df)
}

with2(df, x + y)
#> [1]  5  5  4  6 10

Quosures

上面的with2()函数存在潜在风险,它会优先调用函数运行环境中的变量:

with2 <- function(df, expr) {
  a <- 1000
  eval_tidy(enexpr(expr), df)
}

df <- data.frame(x = 1:3)
a <- 10
with2(df, x + a)
#> [1] 1001 1002 1003

幸运地是,我们可以使用新的数据结构——quosure——来解决这个问题。eval_tidy()函数知道如何处理quosure,所以我们只需要使用enquo()函数来替换enexpr()函数:

with2 <- function(df, expr) {
  a <- 1000
  eval_tidy(enquo(expr), df)
}

with2(df, x + a)
#> [1] 11 12 13

无论何时使用数据掩码,都必须始终使用enquo()而不是enexpr()

Back to top