19 函数进阶

19.1 函数调用的各种形式

在R语言中,有两条简明的理解R程序的原则:

  • 任何成分都是R的对象(变量、函数等等);
  • 任何活动都是调用函数(求子集、四则运算、比较、函数调用等)。

函数调用有四种方式:

一、前缀形式。 这也是一般的格式,如fsub(5, 2)

二、中缀形式。 二元运算符实际上都是函数,5 - 2的写法是中缀形式, 等同于`-`(5, 2)。因为-不是合法的R变量名(函数名), 所以在写成前缀形式时要用反向单撇号`保护。 这样,在lapply等泛函中可以使用`+`这样的四则运算作为输入的操作。 如

5 - 2
## [1] 3
`-`(5, 2)
## [1] 3

为了给1:5每个元素减去2,可以写成

sapply(1:5, `-`, 2)
## [1] -1  0  1  2  3

用户也可以自己定义函数名如%x%这样的中缀函数, 可以用中缀格式调用。 如:

`%+%` <- function(x, y) paste0(x, y)
"xyz" %+% "123"
## [1] "xyz123"

三、替换形式。 对属性的修改经常这样写, 如

x <- 1:2
names(x) <- c("a", "b")
x
## a b 
## 1 2

看起来是在对一个函数的输出结果赋值, 这很不合理, 但实际相当于前缀形式的

x <- 1:2
`*tmp*` <- x
x <- `names<-`(x, c("a", "b"))
rm(`*tmp*`)
x

即制作了x的副本,调用names<-函数, 将x重新绑定到names<-函数的返回值。

四、特殊形式x[1], x[[1]]这些取子集或元素以及修改, (), {}if结构、for循环等本质上也是函数调用, 只不过用了特殊的语法。 这些函数在R中都是初等函数(primitive functions)。 初等函数仅在基本R中定义, 是由C代码实现的, 用户无法访问其三个部分。

取子集的特殊函数例如:

x <- 1:5
x[1]
## [1] 1
`[`(x, 1)
## [1] 1
x[1] <- 999
x
## [1] 999   2   3   4   5
x <- 1:5
x <- `[<-`(x, 1, 999)
x
## [1] 999   2   3   4   5

注意上面的x[1] <- 999的替代写法中, 调用`[<-`(x, 1, 999)是将其返回值(一个向量对象)重新绑定给变量x, 才达到了修改x的目的。

for循环也是函数调用, 如for(i in 1:3) print(i)可以等价地写成`for`(i, 1:3, print(i))

19.2 嵌套定义与句法作用域(lexical scoping)

R语言允许在函数体内定义函数。 比如,

x <- -1
f0 <- function(x){
  f1 <- function(){
    x + 100
  }
  f1()
}

其中内嵌的函数f1()称为一个closure(闭包)。

内嵌的函数体内在读取某个变量值时, 如果此变量在函数体内还没有被赋值, 它就不是局部的, 会向定义的外面一层查找, 外层一层找不到,就继续向外查找。 上面例子f1()定义中的变量x不是局部变量, 就向外一层查找, 找到的会是f0的自变量x,而不是全局空间中x。 如

f0(1)
## [1] 101

最后x+100x取的是f0的实参值x=1, 而不是全局变量x=-1

这样的变量查找规则叫做句法作用域(lexical scoping), 即函数运行中需要使用某个变量时, 从其定义时的环境向外层逐层查找, 而不是在调用时的环境中查找。

例如,

f0 <- function(){
  f1 <- function(){
    x <- -1
    f2 <- function(){
      x + 100
    }
    f2()
  }
  x <- 1000
  f1()
}
f0()
## [1] 99

其中f2()运行时, 用到的xf1()函数体内的局部变量x=-1, 而不是被调用时f0()函数体内的局部变量x=1000, 所以结果是-1 + 100 = 99

“句法作用域”指的是函数调用时查找变量是查找其定义时的变量对应的存储空间, 而不是定义时变量所取的历史值。 函数运行时在找到某个变量对应的存储空间后, 会使用该变量的当前值,而不是函数定义的时候该变量的历史值。 这种规则称为动态查找(dynamic lookup)。 例如

f0 <- function(){
  x <- -1
  f1 <- function(){
    x + 100
  }
  x <- 1000
  f1()
}
f0()
## [1] 1100

结果为什么不是-1 + 100 = 99而是1000 + 100 = 1100? 这是因为, f1()在调用时, 使用的xf0函数体内局部变量x的值, 但是要注意的是程序运行时会访问该变量的当前值,即1000, 而不是函数定义的时候x的历史值-1。 句法作用域与动态查找一个说的是如何查找某个变量对应的存储空间, 一个说的是使用该存储空间何时的存储值, 程序运行时两个规则需要联合使用。

句法作用域不仅适用于查找变量, 也适用于函数体内调用别的函数时查找函数。 查找函数的规则与查找变量规则相同。

19.3 辅助嵌套函数

有时内嵌函数仅仅是函数内用来实现模块化的一种工具, 和正常的函数作用相同,没有任何特殊作用。 例如,如下的程序在自变量x中输入一元二次方程\(a x^2 + b x + c = 0\)的三个系数, 输出解:

solve.sqe <- function(x){
  fd <- function(a, b, c) b^2 - 4*a*c
  d <- fd(x[1], x[2], x[3])
  if(d >= 0){
    return( (-x[2] + c(1,-1)*sqrt(d))/(2*x[1]) )
  } else {
    return( complex(real=-x[2], imag=c(1,-1)*sqrt(-d))/(2*x[1]) )
  }
}

在这个函数中内嵌的函数fd仅起到一个计算二次判别式的作用, 没有用到任何的闭包特性, 其中的形参变量a, b, c都是局部变量。 运行如

solve.sqe(c(1, -2, 1))
## [1] 1 1
solve.sqe(c(1, -2, 0))
## [1] 2 0
solve.sqe(c(1, -2, 2))
## [1] 1+1i 1-1i

这样的内嵌函数与直接在全局空间中定义的函数区别不大, 只有一条区别: 只能在定义它的函数内运行, 不能被直接调用, 可以看成是函数内的私有函数, 可以避免名字冲突。

19.4 懒惰求值

R函数在调用执行时, 除非用到某个形式变量的值才求出其对应实参的值。 这一点在实参是常数时无所谓, 但是如果实参是表达式就不一样了。 形参缺省值也是只有在函数运行时用到该形参的值时才求值。

例如,

f <- function(x, y=ifelse(x>0, TRUE, FALSE)){
  x <- -111
  if(y) x*2 else x*10
}
f(5)
## [1] -1110

可以看出,虽然形参x输入的实参值为5, 但是这时形参y并没按x=5被赋值为TRUE, 而是到函数体中第二个语句才被求值, 这时x的值已经变成了-111, 故y的值是FALSE

另外要注意的是, 懒惰求值使得缺省值在初次访问时, 是在函数内的环境(局部变量作用域)内求值的, 不是在其调用处的环境内求值。

在函数内部, 用missing(x)对形参x判断用户是否没有提供对应的实参, 对位置形参和有缺省值的形参都适用。

19.5 程序调试

19.5.1 基本调试策略

自己编写的某些涉及到复杂的算法的程序可能一开始并不能给出期望的结果。 这包括如下的情况:

  • 程序报错, 需要找到出错的地方加以纠正;
  • 程序正常运行, 输出了结果, 但是结果明显有错;
  • 最糟糕的是, 程序结果也看起来很正常, 但实际结果是错误的。

以上这三种情况是依次越来越糟糕的。 直接运行出错的情况一般是比较容易处理的。

为了尽可能保证程序结果正确, 在自己编写新算法时, 要运用模块化思想, 将问题分解为若干个小问题, 使得每一个小问题都比较容易验证结果是否正确, 将每一个小问题编写成一个单独的函数, 这样也可以避免一段很长的程序中许多变量混杂在一起。

在划分模块并编写好程序后, 应该编写一系列的测试函数, 对每个函数进行测试, 保证其在各种情况下的结果是正确的。 最好采纳R的规则化的测试策略进行自动化测试, 在编写R扩展包时就推荐同时提供测试代码。

如果程序还是有错误, 首先可以求助于搜索引擎、用户社区等。 如果这个问题是常见问题, 往往这样就可以解决问题。

如果问题没有解决, 需要将问题最小化: 减少引起错误的程序的复杂程度, 将不必要的代码尽可能用固定的输入数据代替, 使得出错程序很短, 而且错误可重复。 有时会有不可重复的错误, 这样的错误往往很难解决, 超出了一般R用户的能力。

在将问题程序简化并且错误可重复以后, 就要试图定位错误。 一般编程语言都有如下的一些一般性查错(debugging)方法:

  • 在程序中适当位置加上输出命令(语句), 输出怀疑有错的变量值。
  • 某些变成语言提供了跟踪以及条件跟踪命令, 可以在程序运行到某个语句或者触发了某个条件时程序中止, 但允许用户控制逐行运行程序并随时查看变量值, 称为跟踪调试(tracing)。 跟踪调试可以是命令行工具, 也可以是集成在RStudio这样的集成编程环境中的图形界面工具。

在查错时, 科学研究思维照样起作用: 根据错误表现提出可能的原因假设, 制作测试输入数据验证假设, 记录相应输出并分析是否与假设的错误原因吻合。 如此反复直到找到出错原因并加以纠正。

查错并纠正就可能会破坏了成熟的代码, 造成新的错误, 所以最好能有自动化的测试策略, 在每次修改程序后都重新测试程序是否保持输出正确。

19.5.2 找到出错的函数

在较复杂的程序出错时, 需要首先将错误定位到某一函数调用。如:

f1 <- function(x) f2(x)
f2 <- function(x) 1/x
f1("abc")
## Error in 1/x : 二进列运算符中有非数值参数

为了在多层次函数调用中找到出错的函数,可以用如下命令:

traceback()
## 2: f2(x) at #1
## 1: f1("abc")

结果是所谓的反向追踪(traceback), 一般编程语言中称为调用栈(calling stack)。 这个输出是从下向上堆叠显示, 下层是调用方, 上层是被调用方。

在RStudio中运行时, 出错程序的右端可以显示“Show Traceback”以及“Rerun with Debug”快捷图标, 点击“Show Traceback”图标也可以显示反向追踪结果。 如果是一个源文件用source命名或图标运行时出错, 在显示反向追踪结果时还可以显示调用的各命令所在的程序行号。 点击“Rerun with Debug”可以进入跟踪调试状态, 显示出错时的运行环境中的变量值。

19.5.3 跟踪调试

R和RStudio提供了很好的跟踪运行程序的能力。 R的browser()命令可以用在程序中, 命令进入跟踪调试; RStudio的源文件显示界面可以用鼠标点击定义跟踪调试位置。

函数定义一般都包含多行,所以一般不在命令行定义函数, 而是把函数定义以及较长的程序写在源程序文件中, 用source命令运行。 用source命令调入运行的程序与在命令行运行的效果基本相同, 这样定义的变量也是全局变量。

考虑如下函数定义:

f <- function(x){
  for(i in 1:n){
    s <- s + x[i]
  }
}

这个函数定义有许多问题。 用一个测试输入调用f,发现有错误:

print(f(1:5))
## Error in 1:n : NA/NaN参数

简单的函数可以直接仔细检查发现错误, 用cat, print等输出中间结果查找错误。 R提供了一个browser()函数, 在程序中插入对browser()函数的调用, 可以进入跟踪调试状态, 可以实时地查看甚至修改运行时变量的值。

在RStudio的编辑窗口中打开.R源程序文件, 在某一程序行行号左端的空白处用鼠标单击, 就可以设定某一行为断点, 在用source命令运行到该处时就可以进入跟踪调试状态。

加入browser()命令后的程序如:

f <- function(x){
  browser()
  for(i in 1:n){
    s <- s + x[i]
  }
}

程序运行遇到browser()函数或设定的断点时程序进入跟踪调试状态, 命令行的提示符变成“Browse[1]>”。 这个命令行的环境一般不再是全局环境, 而是断点所在的函数的运行环境, 可以看到函数的局部变量。 可以在调试环境中用命令去查看当前定义的变量值、逐个命令地运行, 但是用RStudio则可以更方便地执行这些操作。

在调试命令行,可以使用如下命令:

  • 输入变量名查看变量值;
  • n命令或者换行键逐句运行;
  • s命令跟踪进调用的函数内部逐句运行;
  • f命令快速执行到循环末尾或者函数末尾;
  • c命令恢复正常运行,不再跟踪;
  • Q命令强行终止程序运行。

进入调试状态后, RStudio界面提供了相应的支持。 这时RStudio的命令行窗格(Console)将会显示用于控制运行的图标, 包括执行下一语句(Next)、跟踪进入要调用的函数运行(Step into)、执行到函数末尾或者循环末尾(Finish)、不再跟踪继续正常运行(Continue)、终止运行(Stop)。 同时, 在RStudio的Environment窗格中会显示当前执行的命令所在的运行环境的内容, 包括函数内的局部变量; 如果点击其中的下拉菜单还可以显示函数的各层父环境。 在同一窗格中还会显示向后追踪(Traceback), 即函数是如何被调用的。

为调试如上函数f的程序, 在定义中插入对browser()的调用如:

f <- function(x){
  browser()
  for(i in 1:n){
    s <- s + x[i]
  }
}

当在RStudio中调试运行时, 程序编辑窗口将显示当前要运行的程序行, 用命令行窗口(Console)的Next快捷图标可以运行到下一行。 命令行的显示如:

> print(f(1:5))
Called from: eval(expr, p)
Browse[1]> n
debug在D:/disk/projects/Rbookweb/tmp2.R#2: for (i in 1:n) {
    s <- s + x[i]
}
Browse[2]> 

继续用“Next”图标运行,命令行结果如:

Browse[2]> n
Error in 1:n : NA/NaN参数

发现是在for(i in 1:n)行遇到未定义的变量n

在源文件中把出错行改为for(i in 1:length(x)), 再次运行, 发现在运行s <- s + x[i]行时, 遇到“错误: 找不到对象’s’”。 这是忘记初始化引起的。 在for语句前添加s <- 0语句,函数定义变成:

f <- function(x){
  browser()
  s <- 0
  for(i in 1:length(x)){
    s <- s + x[i]
  }
}

再次运行, 在跟踪到循环时, 为了避免繁琐的跟踪过程, 可以用“执行到函数末尾或者循环末尾”快捷图标或命令行的f命令, 或者“Continue”快捷图标或命令行的c命令。 程序不显示错误但是也没有显示结果为NULL而不是我们期望得输入元素之和。 检查可以看出错误是忘记把函数返回值写在函数定义最后。

在函数定义最后添加s一行, 再次运行,程序结果与手工验算结果一致。 函数变成

f <- function(x){
  browser()
  n <- length(x)
  s <- 0
  for(i in 1:n){
    s <- s + x[i]
  }
  s
}

自定义函数应该用各种不同输入测试其正确性和稳定性。 比如,上面的函数当自变量x为零长度向量时应该返回0才合适, 但是上面的写法会返回一个numeric(0)结果, 这个结果表示长度为零的向量:

f(numeric(0))
## Called from: f(numeric(0))
## Browse[1]> c
## numeric(0)

程序输入了零长度自变量, 我们期望其输出为零而不是numeric(0)。 在自变量x为零长度时, 函数中for(i in 1:length(x)应该一次都不进入循环, 跟踪运行可以发现实际对i=1和i=0共运行了两轮循环。 把这里的1:length(x)改成seq_along(x)解决了问题, seq_along(x)生成x的下标序列, 如果x是零长度的则下标序列为零长度向量。

函数不需要修改后, 可以把对browser()的调用删除或注释掉, 在RStudio中关闭断点。 函数最终修改为:

f <- function(x){
  s <- 0
  for(i in seq_along(x)){
    s <- s + x[i]
  }
  s
}

这里只是用这个简单函数演示如何调试程序, 求向量元素和这个问题本身是不需要我们去定义新函数的, sum函数本来就是完成这样的功能。 实际上,许多我们认为需要自己编写程序做的事情, 在R网站都能找到别人已经完成的扩展包。

19.5.4 条件断点

browser()函数与R的if结构配合可以制作条件断点。 比如, 在调试带有循环的程序时, 发现错误发生在循环内, 如果从循环开始就跟踪运行, 会浪费许多时间。 设已知错误发生在循环变量i等于501的时候, 就可以在循环内插入:

  if(i == 501) browser()

这样就可以在更接近出错的位置进入跟踪运行。

19.5.5 开启对一个函数的调试

可以用debug(f)命令对函数f开启跟踪运行, 这时每次调用f()时都会自动进入跟踪运行状态。 用undebug(f)取消对f的这种操作。

19.5.6 出错调试选项

比较长的程序在调试时如果从开头就跟踪, 比较耗时。可以设置成出错后自动进入跟踪模式, 检查出错时的变量值。只要进行如下设置:

options(error=recover)

则在出错后可以选择进入出错的某一层函数内部, 在browser环境中跟踪运行。

在RStudio中调试某一源程序文件时, 可以选择“Debug–On Error”菜单, 并选择“Break in Code”, 就可以在出错时自动在出错处进入跟踪状态。

例如,用options()函数进行设置后, 前面那个求向量元素和的例子程序按最初的定义, 运行时出现如下的选择:

## Error in f(1:5) : object 'n' not found
## 
## Enter a frame number, or 0 to exit   
## 
## 1: f(1:5)
## 
## Selection: f(1:5)
## 
## Selection: 1
## Called from: top level 
## Browse[1]> 

Selection后面输入了1,就进入了函数内部跟踪。 用Q终止运行并退出整个browser跟踪。 当函数调用函数时可以选择进入哪一个函数进行跟踪。 如果在RStudio中设置了“Break in Code”, 会自动在出错处进入跟踪运行状态。

19.5.7 stop()warning()message()

编写程序时应尽可能提前发现不合法的输入和错误的状态。 发现错误时, 可以用stop(s)使程序运行出错停止, 其中s是一个字符型对象, 用来作为显示的出错信息。

发现某些问题后如果不严重, 可以不停止程序运行, 但用warning(s)提交一个警告信息, 其中s是字符型的警告信息。 警告信息的显示可能比实际运行要延迟一些。

有些警告信息实际是错误, 用options()warn参数可以设置警告级别, 如设置warn=2则所有警告当作错误处理。 设置如

options(warn=2)

其中warn=0是默认做法, warn=1表示不延迟显示。

函数message()stop()warning()类似, 不算是错误或者警告, 是提示性的信息输出。 message()不会像warning()那样延迟显示。 比如, 长时间的等待之前给出一个提示, 正在读写文件或者网络时给出提示, 等等。 与cat()等相比较, cat()是用户要求的输出, 而message()是程序员对用户的提示。

19.5.8 预防性设计

在编写自定义函数时, 可以检查自变量输入以确保输入符合要求。 函数stopifnot可以指定自变量的若干个条件, 当自变量不符合条件时自动出错停止。

例如,函数f()需要输入两个数值型向量x, y, 需要长度相等, 可以用如下的程序

f <- function(x, y){
  stopifnot(is.numeric(x),
            is.numeric(y),
            length(x)==length(y))
  ## 函数体程序语句...
}

19.5.9 出错处理机制

R运行可以用stop()产生错误状态, 停止运行; 用warning()产生警告, 用message()产生提示。 基本R提供了tryCatch()函数, 用来保护可能出错的代码, 并可以指定出错时用的恢复或诊断代码。 try()函数也可以保护可能出错的代码, 使得出错时错误信息照样显示但不中断运行。

扩展包rlang提供了一些对出错处理机制的增强功能。 详见(Hadley Wickham 2019)第8章:Conditions。

19.6 单元测试

现代程序设计的最重要的思想是模块化设计。 将整个任务分解为功能明确的多个小模块, 在R中用函数表示, 确保每个函数的功能正确就可以使得整个程序正确。

在程序的反复修改调试过程中, 程序员会使用许多调试代码测试函数是否正确运行, 这些测试代码也是大的编程项目的重要内容, 所以应该将这些测试代码收集起来放到一个或若干个程序文件中, 每次程序修改后都重新运行这些测试代码, 看结果是否改变了。

人工地去检查每个测试的结果是否正确过于繁琐了, 没有很好地发挥计算机的自动执行繁琐任务的能力。 所以, 应该将测试时调用每个函数的正确结果与测试代码放在一起, 直接判断函数的输出是否符合预期。 R的testthat扩展包提供了这样的自动化测试的功能。 这个功能主要用于R扩展包的开发测试, 但是较复杂的一般R程序也可以使用。

testthat包用test_that()执行测试, 用expect_equal()等函数规定所测试的函数(代码)应有的行为。

例如,自定义了如下的计算简单收益率的函数:

simple.return <- function(x){
  x <- as.vector(x)
  c(NA, diff(x) / x[1:(length(x)-1)])
}

单独写一个包含测试的R文件,如test-return.R, 内容如:

library(testthat)
test_that("simple.return calculates simple return", {
  expect_equal(simple.return(c(1, 2, 3)), 
    c(NA, 1, 0.5))
  expect_equal(simple.return(ts(c(1, 2, 3))), 
    c(NA, 1, 0.5))
  expect_equal(simple.return(ts(c(1, 2, 3), 
    start=c(2000,1), frequency=12)), 
    c(NA, 1, 0.5))
})

结果显示Test passed。 有多个测试时会显示有多少个通过,多少个失败, 多少个被跳过, 失败的测试会给出出错程序位置。 在RStudio中运行时还用不同颜色区分通过、失败和跳过。 一个测试文件中可以多个test_that()调用, 每个调用测试一个特定的功能, 每个test_that()调用中有若干个exepect_xxx()调用进行测试。 除了expect_equal()以外, 还有expect_match(), expect_true()expect_output()等测试。 详见(Hadley Wickham and Bryan 2022)第12章:Testing。

19.7 环境

环境是R语言比较困难的概念, 一般用户也不需要了解环境就能很好地使用R, 也不影响自定义函数。 环境是支持变量作用域、命名空间、R6类型等功能的数据结构, 了解环境有助于更好地理解作用域等概念。

这部分内容主要来自(Hadley Wickham 2019)相应章节。

19.7.1 基本概念

19.7.1.1 基本认识

环境作为一个数据结构与有名的列表相似, 但是其中的名字必须都互不相同, 且没有次序(类似集合), 环境都有一个父环境, 修改环境内容时都不制作副本。

rlang扩展包可以比较方便地操作R的语法内容。 可以用rlang::env()生成新的环境, 这类似于list()函数的用法, 如:

e1 <- rlang::env(
  a = FALSE,
  b = "a",
  c = 2.3,
  d = 1:3)

环境的作用是将一系列的名字(变量名、函数名等)与R对象绑定起来, 即建立从名字到对象的对应关系, 不计次序。 对环境的修改是直接进行而不制作副本的。 如:

e1$e <- list(x=1, y="abcd")

显示环境, 只会显示一个地址信息, 对用户没有什么用处:

e1
## <environment: 0x000001d185e88860>

rlang包的env_print()函数可以给出较多的信息:

rlang::env_print(e1)
## <environment: 000001D185E88860>
## parent: <environment: global>
## bindings:
##  * a: <lgl>
##  * b: <chr>
##  * c: <dbl>
##  * d: <int>
##  * e: <list>

rlang::env_names()可以获得环境中绑定的名字组成的字符型向量:

rlang::env_names(e1)
## [1] "a" "b" "c" "d" "e"

19.7.1.2 重要环境

rlang::current_env()或基本R的environment()返回调用代码时所在的环境, 比如, 在命令行调用时,返回<environment: R_GlobalEnv>

rlang::global_env()和基本R的globalenv()返回全局环境, 这是另一个重要环境, 也称为“工作空间”, 是在命令行运行时所处的环境。

为了比较两个环境是否同一个, 需要用indentical(e1, e2), 而不能用==比较。

19.7.1.3 父环境

每个环境都有一个父环境, 这样在按句法规则查找变量的绑定对象时, 就依次查找环境本身、其父环境、父环境的父环境, 等等。内嵌函数的父环境是定义它的函数的内部环境。

rlang的env()在生成环境时, 可以用第一个无名的参数输入父环境, 如:

e1 <- rlang::env(a=1, b=2)
e2 <- rlang::env(e1, c=3, d=4)

rlang::env()没有输入父环境时, 父环境就设为调用时的环境。 用rlang::env_parent()获得父环境, 用rlang::env_parents()获得各层父环境,如:

rlang::env_print(e1)
## <environment: 000001D185DDE7D0>
## parent: <environment: global>
## bindings:
##  * a: <dbl>
##  * b: <dbl>
rlang::env_parent(e2)
## <environment: 0x000001d185dde7d0>
rlang::env_parents(e2)
## [[1]]   <env: 000001D185DDE7D0>
## [[2]] $ <env: global>

对于以全局环境为父环境的环境, env_parents()的输出截止到全局环境为止; 但是, 全局环境的上层还有加载的各个扩展包的环境, 这也是查找变量的较后面的搜索路径。

为了制造一个上层不包含全局环境的环境, 可以用rlang::empty_env()作为父环境, 这个环境称为空环境,记为R_EmptyEnv

19.7.1.4 在上层环境中赋值

用“<<-”在各级父环境中赋值, 最先在那一层父环境中找到变量就在那一层中赋值, 如果直到全局环境都没有找到变量, 就在全局环境中新建一个变量。 全局变量应谨慎使用, 它使得程序之间的数据输入输出变得不明晰。 在使用闭包时常常需要使用这种赋值方式保存并修改一个闭包的状态。

如:

f0 <- function(){
  x <- 0
  f1 <- function(){
    f2 <- function(){
      x <<- x+1
      x
    }
    f2()
  }
  f1
}
f01 <- f0()
f01()
## [1] 1
f01()
## [1] 2

在上面的例子中, <<-首先在f1环境内查找x, 没有找到就继续向上在f0的环境内查找x

19.7.1.5 环境中名字的访问

类似于列表元素访问,用“环境$名字”格式或者“环境[["名字"]]”读取环境的元素, 不存在时返回NULL。如:

e1 <- rlang::env(rlang::empty_env(), x=1, y=2)
e2 <- rlang::env(e1, a=3, b=4)
e2$a
## [1] 3
e2[["b"]]
## [1] 4
e2$x
## NULL

从上例也可以看出不能直接读取父环境中的变量。

如果希望在找不到变量时出错, 可以用rlang::env_get(环境名, "名字"),如:

rlang::env_get(e2, "x")
## Error in rlang::env_get(e2, "x") : 找不到对象'x'

可以设置env_get()在查找不到时的缺省值,如:

rlang::env_get(e2, "x", default=NA)
## [1] NA

为了在环境中增加绑定或重新绑定, 可以用$[[格式直接赋值, 可以用rlang::env_poke()rlang::env_bind()rlang::env_bind()运行同时进行多个绑定,如:

e1 <- rlang::env(x=1, y=2)
e1$z <- 3
rlang::env_poke(e1, "a", 11)
rlang::env_bind(e1, b=12, c=13)
rlang::env_names(e1)
## [1] "x" "y" "z" "a" "b" "c"

rlang::env_has()检查某个环境中是否绑定了指定的名字,如:

rlang::env_has(e1, c("x", "c", "f"))
##     x     c     f 
##  TRUE  TRUE FALSE 

为了在环境中删除一个名字的绑定, 需要用rlang::env_unbind(),如:

rlang::env_unbind(e1, c("z", "c"))
rlang::env_names(e1)
## [1] "x" "y" "a" "b"

注意env_unbind()只是解除了绑定, 原来的对象并不会马上被删除, 如果没有其它名字引用该对象, R的垃圾收集器会随后删除该对象。

基本R的get()assign(), exists()rm()等函数起到与rlang包中的env_get()env_poke(), env_has(), env_unbind()类似的功能, 但是这些函数通常都针对调用时的当前环境, 不容易处理其他环境, 另外它们都有一个inherits选项默认为TRUE, 可以自动搜索父环境, 所以不如rlang包的函数功能明确。

19.7.1.6 两种较少使用的特殊环境

rlang::env_bind_lazy()可以创造延迟的绑定, 就是类似于R函数形参缺省值的懒惰求值那样, 第一次使用其值的时候才进行绑定。 利用这种技术, 可以实现类似autoload()的功能, autoload()可以使得用到某个扩展包中指定的名字时才自动载入该扩展包, 利用延迟绑定, 可以使得数据框看起来像是已经在内存中, 但实际是用到该数据框时才中硬盘中读入。 基本R中提供了类似的delayedAssign()函数。

rlang::env_bind_acitive()可以制造一个环境, 每次访问环境中的名字都重新求值并绑定一次。 基本R中提供了类似的makeActiveBinding()函数。

19.7.2 逐层向上访问环境

要逐层向上访问环境, 可以利用R的递归函数。 下面写一个逐层向上查找指定的名字所在的环境的自定义函数。 (来自Advanced R)。

where <- function(name, env = rlang::caller_env()) {
  if (identical(env, empty_env())) {
    # 找到了顶层都没有找到
    stop("找不到 ", name, call. = FALSE)
  } else if (rlang::env_has(env, name)) {
    # 在当前的env环境中找到了,返回找到时的环境
    env
  } else {
    # 利用递归向上层查找
    Recall(name, rlang::env_parent(env))
  }
}

自变量name是要查找的名字, env是从那个环境开始逐层向上查找, env的缺省值是调用where()函数时的环境。 定义中分了三种情况: 到顶层(空环境)都没有找到, 出错停止; 在向上逐层查找中在某个环境中找到了, 返回找到时的环境; 否则就利用递归向上层查找。

这个例子可以用作环境逐层向上遍历的模板。

19.7.3 特殊环境

实际上, 一般用户都不会用到rlang::env()生成的环境; 但是用户都会接触到使用R语言时自然产生的各种环境, 只不过许多用户可能没有认识到自己是在使用环境。

R语言使用中涉及到环境的情景有:

  • 扩展包的环境和搜索路径;
  • 函数环境;
  • 命名空间;
  • 运行环境。

19.7.3.1 扩展包环境

每次用library()require()命令载入一个扩展包, 它定义的变量和函数就构成一个环境, 这个环境变成全局环境的父环境。 这样, 全局环境的各层父环境包括用户载入的扩展包和启动R会话时会自动载入的扩展包(如stats等), 载入最晚的一个扩展包的环境是全局环境的父环境, 载入越早的扩展包的环境距离全局环境的层次越远。 实际上, 在查找某个名字时, 在当前环境没有找到时会逐层向父环境查找, 这些父环境一般就包括全局环境和全局环境上层的各个加载了的扩展包形成的环境, 这种搜索次序称为当前搜索路径, search()返回当前搜索路径,如:

search()
## [1] ".GlobalEnv"        "tools:rstudio"     "package:stats"
## [4] "package:graphics"  "package:grDevices" "package:utils"
## [7] "package:datasets"  "package:methods"   "Autoloads"
## [10]"package:base"     

rlang::search_envs()返回以环境为元素的搜索路径。

搜索路径中除了全局环境之外, 最后两个比较特殊:

  • Autoloads用类似于函数缺省值懒惰求值的方法在需要用到某个变量时才从磁盘将其载入到内存中, 适用于占用存储空间很大的数据框之类的对象。
  • package:base是基本R的环境,必须先载入这一环境才能加载其它环境。 可以直接用rlang::base_env()返回这一环境。

19.7.3.2 函数内部的环境

自定义函数包括形参表、函数体和定义时绑定的环境三个部分, 非内嵌的也不在扩展包中定义的函数一般都与全局环境绑定, 这样的函数的绑定环境没有什么用处, 即使不了解环境部分也能够很好地使用这样的函数。

内嵌在函数内定义的函数称为闭包, 闭包的绑定的环境是定义它的函数的内部环境, 如果这个闭包作为定义它的函数的输出, 闭包对象带有一个私有环境, 即定义它的函数的内部环境, 可以用来保存闭包函数的状态。 在前面的讲到函数工厂时给出了闭包的例子。

rlang::fn_env(f)可以求函数f的绑定环境。 如:

f1 <- function(x) 2*x
rlang::fn_env(f1)
## <environment: R_GlobalEnv>

又如闭包:

f1 <- function(){
  times <- 0
  f2 <- function(){
    times <<- times + 1
    cat("NO. ", times, "\n", sep="")
  }
  print(rlang::fn_env(f2))
  f2
}
f2b <- f1()
## <environment: 0x00000201dc5096d8>
print(rlang::fn_env(f2b))
## <environment: 0x00000201dc5096d8>
f2b()
## NO. 1
f2b()
## NO. 2

这个例子显示的f2f2b的环境都是f1内部的环境, 在现实f2b的环境时虽然f1()已经结束运行, 但是闭包可以保存其定义时的环境。

19.7.3.3 命名空间

变量名和函数名的搜索路径中包含了已载入的扩展包的环境, 这就造成一个问题: 后载入的扩展包中的函数会遮盖住先载入的扩展包中的同名函数, 变量也是如此。 所以, 应该仅载入必要的扩展包, 尽可能用“扩展包名::函数名”的格式调用。

这些问题是用户可控的, 还有一个本质性的问题: 假设扩展包A中的函数f1要用到扩展包B中的函数f11, 先载入了扩展包B, 然后载入了扩展包A, 这时调用A中的f1()没有问题。 现在假设随后又调入了一个扩展C, 扩展包C中也定义了一个f11函数, 那么, 现在调用A中的f1时, 会调用B中的f11还是C中的f11? 如果调用C中的f11就是会程序出错或给出错误结果。

为了避免这样的不可控的错误发生, R语言的扩展包开发进行了严格的规定。 R的扩展包与两个环境有关, 一个就是扩展包的环境, 这实际是用户能看到的R扩展包提供的变量和函数, 在载入扩展包时会插入到搜索路径中。 另一个环境是命名空间环境, 这是扩展包私有的一个环境, 其中的变量和函数有一些对包的用户不可见, 扩展包环境中那些用户可见的变量和函数也在命名空间环境中。 R扩展包在设计时都会利用命名空间严格限定包内部调用的其它包中的函数, 不至于引起歧义。

每个扩展包的命名空间环境都有如下的一套上层环境:

  • imports环境,其中包含所有的用到的其它扩展包的函数, 这是由扩展包的开发者确定的,所以不会错误调用错误的包;
  • imports环境的父环境是基本R环境对应的命名空间环境,但其父环境与基本R环境的父环境不同;
  • 基本R命名空间环境的父环境是全局环境。注意基本R环境的父环境是空环境。

所以, 扩展包内调用其它扩展包的函数是需要开发者明确地加入到imports环境中的, 不受用户调用时载入了那些扩展包和载入次序影响。 扩展包环境(针对用户的)和扩展包命名空间环境(包开发者自用)这`两个环境不发生直接的引用联系, 可以通过函数环境逐层向上变量发生联系。

19.7.3.4 运行环境

函数在调用执行时自动生成一个运行环境, 其父环境为函数定义时的环境, 比如, 设f是在命令行定义的函数, 调用f()时自动生成一个f的运行环境, 相当于f的局部变量和形参的环境, 其父环境为f定义时的环境, 即全局环境。

设函数f2在函数工厂f1中定义并被f1输出为一个闭包f2b, 则调用f2b时自动生成一个f2b的运行环境, 相当于f2b的局部变量和形参组成的环境, 此运行环境的父环境是定义时的环境, 即f2函数内部的环境。 函数执行结束则运行环境消失。

为了能够保留下来运行环境, 一种办法是将运行环境在运行时用rlang::current_env()获取并作为函数的返回值保存到变量中, 另一种办法是像函数工厂那样输出一个闭包, 闭包的环境就是函数工厂的运行环境。

19.7.4 调用栈

函数在被调用时, 还涉及到调用它的环境, 可以用rlang::caller_env()获得。 调用环境与调用时的实参计算有关, 需要了解调用栈(call stack)概念, 调用栈由若干个分层的框架(frames)组成。 R运行出错时会显示一个traceback()结果, 就是调用栈的各个框架。 如:

f1 <- function(x) {
  f2(x = 2)
}
f2 <- function(x) {
  f3(x = 3)
}
f3 <- function(x) {
  stop()
}
f1()

##  Error in f3(x = 3) : 
## 4. stop() 
## 3. f3(x = 3) 
## 2. f2(x = 2) 
## 1. f1() 

上面例子在用stop()产生出错信号时显示调用栈, 下面的函数调用了上面的函数。

可以用lobstr::cst()显示函数的调用栈。

当调用时有懒惰求值时, 调用栈就可能有多个分支。

调用栈中的每一次调用称为一个框架, 或求值上下文(evaluation context)。 框架是支持R语言的重要成分, R程序仅能对框架数据结构作很少的操作。 每个框架有三个构成部分:

  • 一个表示调用函数的表达式expr
  • 一个环境,通常是调用的函数的运行环境。 但是,全局框架的环境还是全局环境, 使用eval()会造出一个框架, 其环境则是可以由用户干预的。
  • 父框架,即调用它的框架。

框架还会保存函数的一些非正常执行路径, 如on.exit()指定的操作, 出错时的处理。

R采用句法作用域, 即由定义决定变量作用域, 有少数语言如Lisp采用动态作用域(dynamic scoping), 即在调用栈上查找变量值。

19.7.5 将环境用作一般数据结构

环境中的变量都是引用, 或者绑定, 不需要制作环境副本, 这使得环境可以当作一种高级的数据类型使用。

将环境作为一般数据结构使用, 可以用在如下一些方面:

  • 因为不需要制作副本, 所以可以节省内存空间。 但是直接使用环境不够友好, 可以使用R6类型的数据, R6类型是建立在环境的基础上的。
  • 在自己建立的扩展包中, 用环境保存包的状态。 这样一个包中的函数多次调用时, 可以在多次调用之间传递一些状态信息。 可以在包中用get.xxx()函数和set.xxx()函数提供包用户访问状态的接口函数。
  • 环境可以当作一个杂凑表用, 杂凑表可以在常数时间将名字对应到值。

References

Wickham, Hadley. 2019. Advanced R. 2nd Edition.
Wickham, Hadley, and Jenny Bryan. 2022. R Packages. https://r-pkgs.org/index.html.