library(rlang)
library(lobstr)
17 Big picture
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的树结构。
请确保你对环境和dataframe的数据结构十分了解。
Code is tree
抽象语法树(abstract syntax tree)几乎是每个编程语言的底层逻辑。R不同的地方在于,可以真实得查看和修改树结构。
使用lobstr::ast()
函数可以直观地感受到代码就是一个树形结构。其中“函数”形成树地分支点,其他常量或变量形成树的叶子。
::ast(f(a, "b"))
lobstr#> █─f
#> ├─a
#> └─"b"
嵌套函数会创建更深的分支树:
::ast(f1(f2(a, b), f3(1, f4(2))))
lobstr#> █─f1
#> ├─█─f2
#> │ ├─a
#> │ └─b
#> └─█─f3
#> ├─1
#> └─█─f4
#> └─2
因为R中所有的函数都可以写成标准形式(function(x) x),所以所有的代码都会生成树形结构:
::ast(1 + 2 * 3)
lobstr#> █─`+`
#> ├─1
#> └─█─`*`
#> ├─2
#> └─3
Code is data
正因为R代码的底层是树形结构,所以R代码可以作为数据处理。lobstr::ast()
只是直接展示了树形结构,我们需要使用rlang::expr()
将这种树形结构数据类型提取出来。
expr(mean(x, na.rm = TRUE))
#> mean(x, na.rm = TRUE)
<- expr(10 + 100 + 1000)
test
test#> 10 + 100 + 1000
str(test)
#> language 10 + 100 + 1000
class(test)
#> [1] "call"
typeof(test)
#> [1] "language"
通常称被捕获的代码为表达式(expression)。但表达式不仅只有“代码”(call),还有:变量(symbol),常量(constant),成对列表(pairlist)。
当你在函数中使用expr()
时会失效,它不会捕捉传入的参数,而是直接捕获它的输入:
<- function(x) {
capture_it expr(x)
}capture_it(a + b + c)
#> x
需要使用enexpr()
函数来捕获传入的参数:
<- function(x) {
capture_it enexpr(x)
}capture_it(a + b + c)
#> a + b + c
一旦捕获了表达式,我们就可以修改它了。可以把树形结构数据当作列表处理,使用[[
和$
获取“元素”:
ast(f(x = 1, y = 2))
#> █─f
#> ├─x = 1
#> └─y = 2
<- expr(f(x = 1, y = 2))
f
# Add a new argument
$z <- 3
f
f#> f(x = 1, y = 2, z = 3)
# Or remove an argument:
2]] <- NULL
f[[
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)来生成代码,这种方式特别适合构建复杂的嵌套代码。
<- expr(x + x)
xx <- expr(y + y)
yy
expr(!!xx / !!yy)
#> (x + x)/(y + y)
注意:生成的是正确的语法(x + x) / (y + y)
,不是x + x / y + y
,也不是x + (x / y) + y
。
解引操作符在构建函数是会更有用:首先使用enexpr()
捕获参数,然后使用!!
和expr()
创建新的代码。下面是一个生成计算变异系数代码的示例:
<- function(var) {
cv <- enexpr(var)
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
如果环境参数缺失,会自动使用当前环境:
<- 1
x <- 10
y eval(expr(x + y))
#> [1] 11
重新评估代码的一大优势是可以调整环境。这样做主要有两个原因:
- 临时重写函数以实现特定于领域的语言。
- 添加数据掩码,以便可以像引用环境中的变量一样引用数据框中的变量。
Customising evaluation with functions
在重新评估代码时使用的环境中,我们不仅可以添加变量,还可以添加函数。这样我们就可以在特定的环境中重新命名已有的函数,例如:我们可以构建一个字符串的+
和*
函数。
<- function(x) {
string_math <- env(
e caller_env(),
`+` = function(x, y) paste0(x, y),
`*` = function(x, y) strrep(x, y)
)
eval(enexpr(x), e)
}
<- "Hadley"
name 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
<- DBI::dbConnect(RSQLite::SQLite(), filename = ":memory:")
con <- copy_to(con, mtcars)
mtcars_db
%>%
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
::dbDisconnect(con) DBI
Customising evaluation with data
重定义函数是一种极其强大的工具,但是它要大量的投入。相反,数据掩码是一种即时快速的应用—-直接引用数据框中的变量而不是从环境中获取。数据掩码技术在很多函数中应用:subset()
、transform()
、aes()
、summarise()
等等。虽然eval()
函数能够实现数据掩码技术,但存在潜在的风险(20.6节),本节使用rlang::eval_tidy()
函数来完成。
eval_tidy()
函数同样接受代码和运行环境两个参数,但此处的环境特指数据框。
<- data.frame(x = 1:5, y = sample(5))
df 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.
<- function(df, expr) {
with2 eval_tidy(enexpr(expr), df)
}
with2(df, x + y)
#> [1] 5 5 4 6 10
Quosures
上面的with2()
函数存在潜在风险,它会优先调用函数运行环境中的变量:
<- function(df, expr) {
with2 <- 1000
a eval_tidy(enexpr(expr), df)
}
<- data.frame(x = 1:3)
df <- 10
a with2(df, x + a)
#> [1] 1001 1002 1003
幸运地是,我们可以使用新的数据结构——quosure——来解决这个问题。eval_tidy()
函数知道如何处理quosure,所以我们只需要使用enquo()
函数来替换enexpr()
函数:
<- function(df, expr) {
with2 <- 1000
a eval_tidy(enquo(expr), df)
}
with2(df, x + a)
#> [1] 11 12 13
无论何时使用数据掩码,都必须始终使用enquo()
而不是enexpr()
。