advanced r - meta programming

代码也是数据,可以用代码进行检查和修改。为了实现对代码数据的修改,我们需要区分代码数据和代码本身,毕竟它们在形式上是一致的。我们需要将代码用expr()包裹,然后对这个返回的对象进行修改,这个返回的对象称之为表达式(expression),也就是我们可以用于修改的代码数据。但是对于传入函数的参数,我们需要用enexpr()对其进行包裹,才能返回表达式。表达式是一类数据的总称,包括四个方面的内容,call, symbol, constant, pairlist

和其它语言一样,代码数据被抽象为类似树的结构,称为抽象语法树(abstract syntax tree)。函数的调用为树的分支,树的叶子为symbol, contants。一个例子如下:

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

既然表达式是数据,那么我们就可以用代码生成该数据。其中一个生成call表达式一个方法就是call2()函数。

r$> call2("f", 1, 2, 3)
f(1, 2, 3)

这方法比较简单,但是在构建比较复杂的表达式时比较困难。还有一种方法就是通过对简单表达式进行组合,形成比较复杂的表达式。为了组合不同的表达式我们需要使用特殊的符号!!,该符号所进行的操作称为“解引用”。你可以将其理解为插入操作,其是在表达式中的一个特殊语法,只有在返回表达式的函数,例如expr()中可以理解该语法,类似于R中的胶水语法,可以将变量代表的表达式插入表达式。一个例子如下:

r$> xx <- expr(x + x)

r$> yy <- expr(y + y)

r$> expr(!!xx / !!yy)
(x + x)/(y + y)

对代码数据,即表达式,修改之后怎么执行呢?我们唯一需要做的就是定义这个表达式所在的环境,也就是对表达式的symbol提供来源。baseR提供了函数base::eval用于执行表达式:

r$> eval(expr(x + y), env(x = 1, y = 2))
[1] 3

如果你没有在base::eval中定义环境,那么该函数会默认使用当前环境。手动执行表达式的好处在于,你可以自己定义执行的环境,这主要有两个用处:

  • 在写领域内特定的函数时,重写某些函数的定义,比如+

  • 可以把数据框的变量作为环境(data mask)。

以下是一个重写函数定义的例子:

r$> string_math <- function(x) {
      e <- env(
        caller_env(),
        `+` = function(x, y) paste0(x, y),
        `*` = function(x, y) strrep(x, y)
      )
 
      eval(enexpr(x), e)
    }

r$> name <- "Hadley"

r$> string_math("Hello " + name)
[1] "Hello Hadley"

r$> string_math(("x" * 2 + "-y") * 3)
[1] "xx-yxx-yxx-y"

dplyr把这一思想发挥到了极致,dplyr会把R在一个会将R代码生成SQL的环境中运行,然后在远端数据库中执行这些SQL代码。

当然,在环境中重新定义函数的意义需要很多的投入,我们更加常用的是,将表达式在数据框(数据框,列表,环境的结构类似)中执行(data mask)。这里我们用rlang::eval_tidy作为例子,base::eval当然也可以做到,不过它具有某些缺点,我们后面再讨论。

r$> df <- data.frame(x = 1:5, y = sample(5))

r$> eval_tidy(expr(x + y), df)
[1]  2  6  5  7 10

我们将它封装起来,就可以形成一个类似于base::with()的函数。

r$> with2 <- function(df, expr) {
      eval_tidy(enexpr(expr), df)
    }

r$> with2(df, x + y)
[1]  2  6  5  7 10

但是这个函数有个问题,稍微修改一下这个函数:

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

r$> df <- data.frame(x = 1:3)

r$> a <- 10

r$> with2(df, x + a)
[1] 1001 1002 1003

这里的问题在于,当我们给函数传递参数的时候,我们希望的是它的值就是我们在外面定义的值,即a <- 10。但是在执行时,函数显然使用的是with2函数环境中的a <- 1000。为了解决这个问题,我们引入一种新的数据结构quosurequosure将表达式与它的环境绑定在一起,为了方便,我将quosure()函数返回的数据称之为quosure表达式,因为其除了用属性记录其环境以外,其它方面与之前提到的表达式并无区别。eval_tidy函数正是为quosure表达式而设计的,因为在使用data mask时,引入了一个问题:在你执行表达式时,表达式中的变量x的是来源于数据框还是环境呢?为了解决这个问题,eval_tidy可以识别新的语法,即代词前缀。eval_tidy中的表达式可以显式的指定变量的来源-eval_tidy(expr(.data$x + .env$y), df),这种表达式中使用代词前缀的语法,只有在eval_tidy中可以识别,用于解决在使用data mask时,表达式中歧义的问题。

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

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

当你在使用data mask的时候,应该始终使用enquo(),而不是enexpr()

tidy-like function

r$> tidy_mean <- function(df, x) {
    arg <- enexpr(x)
    eval_tidy(expr(mean(!!arg)), df)
    }

r$> df <- tibble(x = runif(10), y = runif(10))

r$> tidy_mean(df, y)
[1] 0.3527402

Expression

表达式是我们需要用代码进行修改的代码数据。为了将代码本身与代码数据区分开来(毕竟它们在形式上是一致的),我们需要对代码进行一些函数操作然后返回表达式对象,之后才能用代码修改这些对象。

Abstract syntax trees

抽象语法树是将代码数据从语法上进行抽象的树结构。在R中我们可以用rlang::ast()将代码的结构抽象为树。一个例子如下:

r$> lobstr::ast(
      y <- x * 10 # important
      )
█─`<-` 
├─y 
└─█─`*` 
  ├─x 
  └─10 

需要注意的是从代码到树的结构,信息是有损失的,可以看到在原代码中的注释信息,空格没有在树的结构中表现。

Expressionsion construction

表达式可以包含以下的数据类型,constant, symbols, calls以及不怎么常见的pairlist等。

constant是自引的,这意味着expr(1)1是一样的,所以如果我们需要构建一个constant expression,不用进行特殊的操作。即代码中的constant与表达式中的constant是一致的。

r$> identical(1, expr(1))
[1] TRUE

你也可以用is_syntax_identical()进行判断。

r$> is_syntactic_literal(1)
[1] TRUE

为了构建一个symbol expression,你可以这么操作:

expr(x)

sym("x")

calls expression的结构是一种特殊的列表,可以通过如下的方式进行构建以及修改。

r$> call2(expr(base::mean), x = expr(x), na.rm = TRUE) -> a

r$> a
base::mean(x = x, na.rm = TRUE)

r$> typeof(a)
[1] "language"

r$> is.call(a)
[1] TRUE

r$> a[[1]]
base::mean

r$> a[[2]]
x

r$> a[[2]] <- expr(y)

r$> a
base::mean(x = y, na.rm = TRUE)

或者直接用expr()

r$> b <- expr(base::mean(x = c(1, 2, 3), na.rm = TRUE))

r$> b
base::mean(x = c(1, 2, 3), na.rm = TRUE)

r$> list(b[[1]], b[[2]])
[[1]]
base::mean

[[2]]
c(1, 2, 3)

一个比较特殊的call expression结构:

r$> lobstr::ast(pkg::foo(1))
█─█─`::` 
│ ├─pkg 
│ └─foo 
└─1 

parser and grammer

一门编程语言将字符转换为表达式的过程称之为parser,而转换过程所使用的规则称之为grammer

装换的过程有两个问题:

  1. 中缀函数的运算优先级,R中的中缀函数的优先级有18组,可以通过?Syntax查看。需要注意的就是!符号的优先级可能比你想的要低很多。

  2. 同一个中缀函数,从左边还是右边开始计算呢?R中大多数都从左边开始计算,除了赋值符号以及指数运算。

你也可以自己手动将字符转为表达式,或者将表达式转为字符。但是两者并不是完全等价的因为parse会生成语法树,这意味着,字符中的反引号,空格,以及注释都会被去掉。例子如下:

r$> x <- "1 + 2"

r$> a <- parse_expr(x)

r$> a
1 + 2

r$> typeof(a)
[1] "language"

r$> expr_text(a)
[1] "1 + 2"

base R中也有类似的函数,不过那是为将文本转换为代码设计的,你可以这么操作:

r$> parse(text = x)
expression(1 + 2)

r$> is.expression(parse(text = x))
[1] TRUE

它返回的是一个表达式的向量。同样地base R中的deparse返回的也是一个向量。

Specialised data structures

  • pairlists

pairlists是R过去的遗留产物。只有calls expression中的函数为function函数时,函数的参数值就存储在pairlists的结构中。一般情况下,完全可以把pairlists当作list进行操作。

r$> f <- expr(function(x, y = 10) x + y)

r$> args <- f[[2]]

r$> args
$x


$y
[1] 10


r$> typeof(args)
[1] "pairlist"
  • missing arguments
r$> missing_arg()


r$> typeof(missing_arg())
[1] "symbol"

r$> is_missing(missing_arg())
[1] TRUE

missing_arg的特别之处在于,你不能设置一个变量指向它,除非将它存储在其它数据结构之中。在用到它的时候我们一般会用到辅助函数maybe_missing函数。缺失参数主要用于参数...

m <- missing_arg()
m
#> Error in eval(expr, envir, enclos): argument "m" is missing, with no default

ms <- list(missing_arg(), missing_arg())
ms[[1]]
  • expression vectors

表达式向量仅通过两个函数构建;expression() 以及 parse()

r$> exp1 <- parse(text = c("
    x <- 4
    x
    "))
    exp2 <- expression(x <- 4, x)

r$> typeof(exp1)
[1] "expression"

r$> typeof(exp2)
[1] "expression"

r$> exp1[[1]]
x <- 4

r$> exp2[[2]]
x

Quasiquotation

本章旨在介绍如何将用户通过函数参数输入的代码转化为表达式,并与开发者提供的表达式进行组合。所谓的非标准性评估的函数,其实关键在于函数参数是否被“引用”。

quote

将代码,或者函数的参数值转换为表达式,这个操作称之为引用(quote)。 tidy:

DeveloperUser
Oneexpr()enexpr()
Manyexprs()enexprs()

base:

DeveloperUser
Onequote()substitute()
Manyalist()as.list(substitute(...()))

substitute函数正如它的名字一样,除了将参数值转化为表达式外,还具有替换功能,实际上一定程度上实现了表达式的组合。

r$> f4 <- function(x) substitute(x * 2)

r$> f4(a + b + c)
(a + b + c) * 2

unquote

解引用的含义在于,执行表达式expr()中以符号!!开始的代码,然后返回新的表达式,即expr(!!x),这样就可以实现代码数据的修改以及组合,所以符号!!称为插入操作符,因为它可以在表达式中插入表达式,如序所说,你可以将符号!!理解为在返回表达式的函数中支持的一种特殊的插入语法。而eval()执行的是整个表达式,即eval(expr())

一个例子如下:

r$> x <- list(x = 10, y = 100)

r$> y <- expr(mean(c(1, 2, 3)))

r$> z <- sum(1, 2)

r$> expr(map(!!x, ~ .x * 10 + !!y + z))
map(list(x = 10, y = 100), ~.x * 10 + mean(c(1, 2, 3)) + z)

r$> a <- expr(map(!!x , ~ .x * 10 + !!y + !!z))

r$> a
map(list(x = 10, y = 100), ~.x * 10 + mean(c(1, 2, 3)) + 3)

r$> eval(a)
$x
[1] 105

$y
[1] 1005

一些特殊的解引用:

  • 插入函数
r$> f <- expr(foo)
    expr((!!f)(x, y))
foo(x, y)
  • 插入缺失参数
arg <- missing_arg()
expr(foo(!!maybe_missing(arg), !!maybe_missing(arg)))
#> foo(, )
  • 非前缀函数
x <- expr(x)
expr(`$`(df, !!x))
#> df$x
  • 插入多个参数

如果你有一个列表含有多个表达式,你可以通过!!!将列表的每个元素同时插入。需要注意的是,这种情况下,你只能将其插入函数,作为函数参数。

xs <- exprs(1, a, -b)
expr(f(!!!xs, y))
#> f(1, a, -b, y)

# Or with names
ys <- set_names(xs, c("a", "b", "c"))
expr(f(!!!ys, d = 4))
#> f(a = 1, b = a, c = -b, d = 4)

实际上,你不仅可以在表达式中插入表达式,还可以插入其它复杂的对象。但是,在你打印出返回的表达式时,这些复杂对象的属性将不会打印出来,为了方便查看结果,你可以使用rlang::expr_print()

r$> x1
class(list(x = 10))

r$> expr_print(x1)
class(<df[,1]>)

r$> eval(x1)
[1] "data.frame"

Non-quoting

base R通过其它技术实现类似的表达式的组合的效果,称之为non-quoting,其中含有一个类似的函数可以实现解应用。

r$ xyz <- bquote(x + y +z)

r$> bquote(-.(xyz) / 2)
-(x + y + z)/2

但是该函数无法用于函数参数,实现代码重组,baseR采用其它的技术实现类似的效果。

  • A pair of quoting and non-quoting functions.

  • A pair of quoting and non-quoting arguments.

  • An argument that controls whether a different argument is quoting or non-quoting.

  • Quoting if evaluation fails.

...(dot-dot-dot)

上面提到“解引用”时,符号!!!在返回表达式的函数中意义是将列表切割插入表达式中,实际上不仅返回表达式的函数支持该语法,函数list2()中也支持该语法,并且在其中支持表达式作为变量名。

r$> arg <- list(x = 1, y = 2)

r$> f <- function(...) enexprs(...)

r$> f(!!!arg)
$x
[1] 1

$y
[1] 2


r$> f <- function(...) list2(...)

r$> f(!!!arg)
$x
[1] 1

$y
[1] 2

表达式作为变量名:

r$> var <- "x"
    val <- c(4, 3, 9)

r$> tibble::tibble(!!var := val)
# A tibble: 3 × 1
      x
  <dbl>
1     4
2     3
3     9

注意这里使用的是符号:=,而不是=,因为=不支持这种做法。我们将用函数list2(...)中的点称为tidy dots

如果你想要在非tidy dot函数中使用这个技巧(将函数参数放在一个列表之中,以及用表达式作为参数名),你可以这么做:

# Directly
exec("mean", x = 1:10, na.rm = TRUE, trim = 0.1)
#> [1] 5.5

# Indirectly
args <- list(x = 1:10, na.rm = TRUE, trim = 0.1)
exec("mean", !!!args)
#> [1] 5.5

# Mixed
params <- list(na.rm = TRUE, trim = 0.1)
exec("mean", x = 1:10, !!!params)
#> [1] 5.5

特别地,你还可以同时相同参数的不同函数同时处理。

x <- c(runif(10), NA)
funs <- c("mean", "median", "sd")

x <- c(runif(10), NA)
funs <- c("mean", "median", "sd")

purrr::map_dbl(funs, exec, x, na.rm = TRUE)
#> [1] 0.444 0.482 0.298

实际上list2()函数是对dots_list()函数的封装,其设置了一些默认的行为,你可以用dots_list()函数对参数做更为精密的控制。

其实在base R中已经提供了一个非常有效的函数do.call(),用于解决函数参数在一个列表中的问题。

do.call("rbind", dfs)
#>   x
#> 1 4
#> 2 3
#> 3 9

同时,base R中还有一些其它的技术,用于避免使用do.call函数。

case

  • rlang::ast函数会引用其参数,为了展示存储在变量中的表达式结构,我们可以在ast函数中使用插入符号!!x <- expr(x + y); ast(!!x)

  • 生成线性表达式,主要通过map以及reduce函数实现。

  • 数组切割函数,主要涉及到缺失参数的处理。

  • new_function,使用函数各部分的表达式定义函数,用于函数工厂以及曲线绘图函数-曲线绘图涉及描点,即定义一些变量x的取值,然后得到y值,对这些点进行连接得到曲线,然后把表达式转为字符,作为y轴坐标。

Evaluation

Together, quasiquotation, quosures, and data masks form what we call tidy evaluation, or tidy eval for short.

The user-facing inverse of quotation is unquotation: it gives the user the ability to selectively evaluate parts of an otherwise quoted argument. The developer-facing complement of quotation is evaluation: this gives the developer the ability to evaluate quoted expressions in custom environments to achieve specific goals.

Basics

对于evaluation来说,执行的一定是表达式,才可以自定义环境执行。如果不是,则会按正常的代码执行。

r$> x <- 10

r$> eval(x + 1, env(x = 1000))
[1] 11

r$> eval(expr(x + 1), env(x = 1000))
[1] 1001

几个例子:

  • local()。用于将会生成无用的占用内存比较大的计算。其原理就是简单的把代码转换为表达式,然后在临时环境中执行。就像函数执行一样,执行完以后,这个环境就不在了。
local2 <- function(expr) {
  env <- env(caller_env()) # 新环境以调用环境作为父环境,就可以获取调用环境的变量
  eval(enexpr(expr), env)
}
  • source()。文本按行读入,将字符转换为表达式,然后在调用环境中执行每一个表达式即可,最后不可见的返回最后一个表达式的结果。
source2 <- function(path, env = caller_env()) {
  file <- paste(readLines(path, warn = FALSE), collapse = "\n")
  exprs <- parse_exprs(file)

  res <- NULL
  for (i in seq_along(exprs)) {
    res <- eval(exprs[[i]], env)
  }

  invisible(res)
}

如果这里使用parse函数,会返回表达式向量,eval会按顺序执行其中的每一个表达式,而不用使用循环,使得代码更加紧凑。

  • Gotcha。 当你在使用evalexpr定义函数时,它的打印结果可能会让你意外。实际上这是由于函数的srcref属性,函数会打印出它的源码。
r$> x <- 10

r$> y <- 20

r$> f <- eval(expr(function(x, y) !!x + !!y))

r$> f
function(x, y) !!x + !!y

r$> f()
[1] 30

r$> attributes(f) <- NULL

r$> f
function (x, y)
10 + 20

Quosures

quosure, 将表达式与它的环境结合在一起形成的数据结构,该名称取自quote以及closure。

  • creating. enquo, enquos, quo, quos, new_quosure

  • evaluating.

q1 <- new_quosure(expr(x + y), env(x = 1, y = 10))
eval_tidy(q1)
#> [1] 11
  • under the hood

quosure实际上继承自formula类的。如下:

r$> form <- y ~ x + 1

r$> str(form)
Class 'formula'  language y ~ x + 1
  ..- attr(*, ".Environment")=<environment: R_GlobalEnv>

r$> a <- new_quosure(expr(x + 1))

r$> str(a)
 language ~x + 1
 - attr(*, ".Environment")=<environment: R_GlobalEnv>

r$> class(a)
[1] "quosure" "formula"

~就是r中用于构建formula类的符号,可以捕获代码以及它所在的环境。

  • nested quosures

正如之前提到的,quosure表达式与普通的表达式没有本质的区别,同样可以在返回表达式的函数中支持插入语法!!。而eval_tidy的设计可以正确识别返回的表达式中,每个变量的环境。

q2 <- new_quosure(expr(x), env(x = 1))
q3 <- new_quosure(expr(x), env(x = 10))

x <- expr(!!q2 + !!q3)

如果你将它打印出来,你可以看到其以符号~开始,代表了一个公式(quosure继承自formula类)。或者你可以使用函数expr_print打印出来,这里以符号^开始,代表了一个表达式。

x
#> (~x) + ~x

expr_print(x)
#> (^x) + (^x)

data mask

data mask的含义是在数据框的环境中执行表达式,但是,在数据框和环境中如果同时含有某个变量,这种执行表达式的方式会引起歧义,为了解决这个问题,我们可以用前置代词.env.data显式的指定该变量来自哪里。eval_tidy的设计支持其执行的表达式中使用代词。

with2 <- function(data, expr) {
  expr <- enquo(expr)
  eval_tidy(expr, data)
}

x <- 1
df <- data.frame(x = 2)

with2(df, .data$x)
#> [1] 2
with2(df, .env$x)
#> [1] 1

case-subset:

subset2 <- function(data, rows) {
  rows <- enquo(rows)
  rows_val <- eval_tidy(rows, data) # 执行表达式,返回逻辑值
  stopifnot(is.logical(rows_val))

  data[rows_val, , drop = FALSE]
}

subset2(sample_df, b == c)
#>   a b c
#> 1 1 5 5
#> 5 5 1 1

case-transform

transform2 <- function(.data, ...) {
  dots <- enquos(...)

  for (i in seq_along(dots)) {
    name <- names(dots)[[i]] # 参数名作为变量名,其值为表达式执行的结果
    dot <- dots[[i]]

    .data[[name]] <- eval_tidy(dot, .data)
  }

  .data
}

transform2(df, x2 = x * 2, y = -y)
#>   x       y x2
#> 1 2 -0.0808  4
#> 2 3 -0.8343  6
#> 3 1 -0.6008  2

case-select

实际上,表达式还可以在列表环境中执行。

select2 <- function(data, ...) {
  dots <- enquos(...)

  vars <- as.list(set_names(seq_along(data), names(data)))
  cols <- unlist(map(dots, eval_tidy, vars))

  data[, cols, drop = FALSE] # 数据框列名作为列表元素名,每个元素分别赋值为1, 2, ..., 执行表达式返回选取列的位置1, 3, ...
}
select2(df, b:d)
#>   b c d
#> 1 2 3 4

using tidy evaluation

如果你想要封装一个使用tidy-evauation的函数,你需要其与普通的参数值传递的差别。下面的例子可以说明,在传递参数值时普通参数和被“引用”的参数的区别。

r$> f2 <- function(x)  x

r$> f1 <- function(x) f2(x)

r$> f1(expr(x + y))
x + y

r$> f4 <- function(x) x

r$> f4 <- function(x) enquo(x)

r$> f3 <- function(x) f4(x)

r$> f3(expr(x + y))
<quosure>
expr: ^x
env:  0x0000016b180bdb98

可以看到,对于引用的参数而言,从外部函数传递进去的值,并没有被其接受,为了接受外部函数的参数,有下面两种方法。

r$> f3 <- function(x) {arg <- enquo(x); f4(!!arg)}

r$> f3(expr(x + y))
<quosure>
expr: ^expr(x + y)
env:  global

r$> f3 <- function(x) f4({{x}})

r$> f3(expr(x + y))
<quosure>
expr: ^expr(x + y)
env:  global

另外,在使用data mask时,对于传递给eval_tidy的表达式,我们应该使用代词.data以及.env指明变量来源。需要记住的是,一般来说,当你使用.env代词时,其始终可以被符号!!代替,两者区别在于执行该变量的时间不同,前者在eval_tidy中执行,后者在表达式中立即执行。

base evaluation

主要涉及两个函数substitute以及match.call

如果你想要对base R的NSE函数进行封装,有三个步骤需要考虑:

  • You capture the unevaluated arguments using enexpr(), and capture the caller environment using caller_env().

  • You generate a new expression using expr() and unquoting.

  • You evaluate that expression in the caller environment. You have to accept that the function will not work correctly if the arguments are not defined in the caller environment. Providing the env argument at least provides a hook that experts can use if the default environment isn’t correct.

case:

resample_lm2 <- function(formula, data, env = caller_env()) {
  formula <- enexpr(formula)
  resample_data <- resample(data, n = nrow(data))

  lm_env <- env(env, resample_data = resample_data)
  lm_call <- expr(lm(!!formula, data = resample_data))
  expr_print(lm_call)
  eval(lm_call, lm_env)
}
resample_lm2(y ~ x, data = df)
#> lm(y ~ x, data = resample_data)
#> 
#> Call:
#> lm(formula = y ~ x, data = resample_data)
#> 
#> Coefficients:
#> (Intercept)            x  
#>        4.42         3.11

总结:整个章节涉及两个方面的内容,一个是如何创建对参数进行引用的函数(NSE函数),另一个问题是如何对NSE函数进行封装。第一个问题:为了实现用户提供的代码(参数值)与开发者在函数内部提供的代码进行组合,对于返回表达式的函数,设计引进了插入符号!!用于实现代码的组合。进一步地,考虑到执行表达式时,表达式与环境的密切关系,引入了quosure数据结构即所谓的quosure表达式,以及其配对的eval_tidy函数。为了解决在使用data mask时歧义的问题,对于eval_tidy函数又引入了对表达式前缀的识别的语法。第二个问题:对于tidy-evaluation函数的封装有多种形式,一般情况下使用简写{{}}符号就可以。对于Base RNSE函数则需要捕获整个表达式以及调用环境,然后执行。

Last-modified in 2023-01-10