R的内部机制
王诗翔 · 2018-08-01
内容:
- 惰性求值 (Lazy evaluation)
- 复制-修改机制 (Copy-on-modify mechanism)
- 词法作用域 (Lexical scoping)
- 环境 (Environment) 理解这些概念能够明白大部分的R代码,同时提升工作效率。
惰性求值
创建如下函数:
test0 = function(x, y) {
if (x > 0) x else y
}
函数在x
不大于0时才需要y
的存在,如果我们只为该函数设定x
参数,函数会因为y
不存在而报错么?
test0(1)
#> [1] 1
即使没有对y
赋值,函数竟然仍能够运行!看来调用函数时不必对所有的参数进行赋值,只对必需参数赋值即可。当然,如果这里x
是一个负数,必须有y
值的存在:
test0(-1)
#> Error in test0(-1): argument "y" is missing, with no default
我们已知知道函数并不需要指定所有参数,如果指定了额外的参数,R是在调用函数前进行计算,还是根本就不会计算呢?
我们使用stop()
函数来探究一番:
test0(1, stop("Stop Now!"))
#> [1] 1
test0(-1, stop("Stop Now!"))
#> Error in test0(-1, stop("Stop Now!")): Stop Now!
很明显,stop()
函数在第一个语句中没起到作用,在第二个语句中起作用了,说明参数只有在实际用到时才会被执行。这个机制称为惰性求值。
惰性求值是按需计算,可以节省时间并避免不必要的计算。如果你对它不了解,你可能会认为执行以下操作会比较耗时:
test0(1, rnorm(10000000))
我们来看下:
system.time(test0(1, rnorm(10000000)))
#> user system elapsed
#> 0 0 0
而本身rnorm
执行的时间为:
system.time(rnorm(10000000))
#> user system elapsed
#> 0.580 0.023 0.605
用我的计算机需要超过1秒的时间生成1千万个随机数,而运行test0()
函数的消耗几乎为0。由此可见,在需要时再计算可以减少不必要的浪费。也许你会说,1秒算什么,但如果你的程序中循环迭代使用几千上万次这样的运算呢?那可是小时乃至天了。
但惰性求值也是把双刃剑,在调用函数时,其参数只被解析而不被计算(使用时才计算),所有我们只能确定参数表达式在语法上是正确的,但很难确定它的有效性。
如果我们把函数
test2 = function(x, n=floor(length(x) / 2)){
x[1:n]
}
写成了:
test3 = function(x, n=floor(length(m) / 2)){
x[1:n]
}
因为创建函数时并不计算,所以创建test3
时并不会报错,只有实际调用时才会报错:
test3(1:10)
#> Error in test3(1:10): object 'm' not found
有趣的是,利用惰性求值我们可以创建一些有意思的函数用法,比如下面函数控制输入参数只接受y
或n
,否则报错:
check_input = function(x){
switch (x,
y = message("yes"),
n = message("no"),
stop("Invalid input...")
)
}
使用看看:
check_input("y")
#> yes
check_input("n")
#> no
check_input("what")
#> Error in check_input("what"): Invalid input...
复制-修改机制
这里介绍R的一个重要特性,以便于更安全地使用数据。
先创建一个数值向量x1
,并赋值给x2
:
x1 = c(1, 2, 3)
x2 = x1
现在x1
与x2
值完全相同,如果我们修改其中一个,另一个也会跟着改变吗?
x1[1] = 0
x1
#> [1] 0 2 3
x2
#> [1] 1 2 3
结果显示x1
的改变不会影响x2
,我们可能以为赋值操作会自动复制值,然后使新变量指向数据的副本,而不是原始数据,那么实际如何我们用tracemem()
函数来追踪一下。
先重置x1
与x2
,再追踪:
x1 = c(1, 2, 3)
x2 = x1
tracemem(x1)
#> [1] "<0x7fee7b6717f8>"
tracemem(x2)
#> [1] "<0x7fee7b6717f8>"
可以发现两个向量值相同,并共享内存地址,说明它们指向相同的数据,而赋值操作并没有自动复制数据!那么数据是什么时候被复制了呢?
x1[1] = 0
#> tracemem[0x7fee7b6717f8 -> 0x7fee787d7e78]: eval eval withVisible withCallingHandlers handle timing_fn evaluate_call <Anonymous> evaluate in_dir block_exec call_block process_group.block process_group withCallingHandlers process_file <Anonymous> <Anonymous> eval eval eval eval eval.parent local
内存追踪到x1
的地址发生了改变。
如果多个变量指向同一对象,那么修改一个变量会生成该对象的一个副本,这就是复制——修改机制。
另外,修改函数的参数和对象属性也会生成一个新的副本以确保外部的对象不受影响。
修改函数外部的对象
如果我们在运行函数时想要修改外部对象的值怎么办?运算符<<-
可以执行这一操作:
x = 0
modify_x = function(value) {
x <<- value
}
调用函数会更改x
:
modify_x(3)
x
#> [1] 3
运算符<<-
的一个用法是“拉平”一个嵌套列表。假设我们有如下列表:
nested_list = list(
a = c(1, 2, 3),
b = list(
x = c("a", "b", "c"),
y = list(
z = c(TRUE, FALSE),
w = c(2, 3, 4)
)
)
)
str(nested_list)
#> List of 2
#> $ a: num [1:3] 1 2 3
#> $ b:List of 2
#> ..$ x: chr [1:3] "a" "b" "c"
#> ..$ y:List of 2
#> .. ..$ z: logi [1:2] TRUE FALSE
#> .. ..$ w: num [1:3] 2 3 4
我们现在想要拉平该列表——即将所有的嵌套部分放在最外层。以下代码使用rapply()
于<<-
实现这一过程。rapply()
是lapply()
的递归版本,每一次迭代都将函数作用到列表特定的原子向量上。
先创建一个用于存放嵌套向量的空列表和一个计数器:
flat_list = list()
i = 1
然后利用rapply()
将一个函数递归应用到nested_list()
列表上,每一次迭代,函数通过x
获得一个该列表的原子向量,然后将flat_list
的第i
个元素设为x
,并将i
加1。
res = rapply(nested_list, function(x) {
flat_list[[i]] <<- x
i <<- i + 1
})
res
#> a b.x b.y.z b.y.w
#> 2 3 4 5
我们再将res
中的元素名赋给flat_list
,以标明每个元素的原始层级。
names(flat_list) = names(res)
str(flat_list)
#> List of 4
#> $ a : num [1:3] 1 2 3
#> $ b.x : chr [1:3] "a" "b" "c"
#> $ b.y.z: logi [1:2] TRUE FALSE
#> $ b.y.w: num [1:3] 2 3 4
至此,实现完成。
词法作用域
一般也常称为变量作用域,这常体现在函数的使用中。函数有内部与外部之分,在函数的内部,我们能够使用外部变量和函数,但外部不能使用内部变量和函数(除非使用<<-
创建全局变量)。
例如:
start1 = 1
end1 = 10
fun1 = function(x){
c(start1, x, end1)
}
fun1(c(4,5,6))
#> [1] 1 4 5 6 10
可以看到函数fun1
只有一个参数,但它却可以使用函数外部定义的变量。
在本文最前面讲述了“惰性求值”,此处函数虽然只设定了x
参数,但在函数的使用时它发现start
与end
参数不在函数内部,所以它会尝试往上一级寻找(直到最高一级,即这里函数的外部),如果寻找不到,则报错。
使用函数内部参数:
fun2 = function(x, start1=2, end1=3){
c(start1, x, end1)
}
fun2(c(4,5,6))
#> [1] 2 4 5 6 3
如果参数不存在:
rm(start1, end1)
fun1 = function(x){
c(start1, x, end1)
}
fun1(c(4,5,6))
#> Error in fun1(c(4, 5, 6)): object 'start1' not found
环境的工作方式
我们前面学习的惰性求值、复制-修改机制和词法作用域与一种对象高度相关,那就是环境。
环境对象
环境是一组名称组成的对象,每个环境(除了空环境)都有一个父环境。每个名称(称符号或变量)都指向一个对象,当我们查找一个符号时,如果它在当前环境中,R就会在当前环境中搜索并返回该符号指向的对象。如果这个符号在当前环境中没有找到,R就会到它的父环境中搜索(直到找遍所有环境)。
创建并链接环境
我们使用new.env()
函数创建一个新环境:
e1 = new.env()
环境通常用十六进制数表示,即内存地址:
e1
#> <environment: 0x7fee7a2da0b8>
我们可以使用提取操作符$
和[[
在环境中创建变量,代码看起来像是操作一个列表:
e1$x = 1
e1[["x"]]
#> [1] 1
注意,环境和列表有三大主要区别:
- 环境没有索引
- 环境有父环境
- 环境具有引用语义
我们会给出详细解释。
访问环境
函数没有索引——所以不能提取和构建子集:
e1[1:3] #索引
#> Error in e1[1:3]: object of type 'environment' is not subsettable
e1[[1]] #构建子集
#> Error in e1[[1]]: wrong arguments for subsetting an environment
正确方式是先用exists()
检查环境是否存在某个变量,然后使用get()
获取它的值:
exists("x", e1)
#> [1] TRUE
get("x", e1)
#> [1] 1
可以调用ls()
列出环境中的所有变量:
ls(e1)
#> [1] "x"
可以使用$
与[[
访问环境中存在的变量,如果变量不存在,会返回NULL
。
e1$x
#> [1] 1
e1[["x"]]
#> [1] 1
e1$y
#> NULL
e1[["y"]]
#> NULL
链接环境
环境有父环境,如果一个符号(变量)不存在环境中,R会到它的父环境中寻找。
我们再创建一个新的环境e2
,令e1
为e2
的父环境:
e2 = new.env(parent = e1)
不同的环境有不同的内存地址,这里我们将e1
设定为e2
的父环境,那么e2
的父环境的内存地址应该和e1
一致。
下面进行验证:
e2
#> <environment: 0x7fee83829200>
e1
#> <environment: 0x7fee7a2da0b8>
parent.env(e2)
#> <environment: 0x7fee7a2da0b8>
在e2
中创建变量y
e2$y = 2
ls(e2)
#> [1] "y"
这里我们没有在e2
中定义变量x
,所以:
e2$x
#> NULL
e2[["x"]]
#> NULL
操作返回NULL
。
当使用exists()
与get()
时,父环境派上用场,由于e2
找不到x
,函数在父环境e1
中寻找:
exists("x", e2)
#> [1] TRUE
get("x", e2)
#> [1] 1
如果不想让函数搜索父环境,可以设定inherits = FALSE
。
exists("x", e2, inherits = FALSE)
#> [1] FALSE
使用get
会报错:
get("x", e2, inherits = FALSE)
#> Error in get("x", e2, inherits = FALSE): object 'x' not found
环境可以有很多层,形成链一样的存在。在R的背后,环境至关重要,它标明了数据、函数、符号的存储空间,它们相互独立又相互联系。
在引用语义下使用环境
引用语义这里是指——修改环境时不会复制该环境,无论它有多个名称还是作为参数传递给函数。
创建变量e3
:
ls(e1)
#> [1] "x"
e3 = e1
当我们通过任意一个变量修改环境时,不会创建环境副本。我们这里通过e1
和e3
观察变化,因为它们指向完全相同的环境。
e3$y
#> NULL
e1$y = 2
e3$y
#> [1] 2
将环境作为参数传递函数也会发生同样情况:
modify = function(e){
e$z = 10
}
如果参数传入的时列表,函数会创建并修改一个局部副本,但该副本在函数调用结束时便丢失:
list1 = list(x = 1, y = 2)
list1$z
#> NULL
modify(list1)
list1$z
#> NULL
但如果将环境传入参数,修改环境不会产生局部副本,而是在环境中直接创建新变量z
:
e1$z
#> NULL
modify(e1)
e1$z
#> [1] 10
内置环境
环境是R一种特殊类型的对象,前面学习的从函数的调用到词法作用域机制,都是基于环境实现的。实际上,一段R代码运行就是在一个环境中进行的。
要想知道我们是在哪个环境中运行代码,可以调用environment()
函数:
environment()
#> <environment: R_GlobalEnv>
结果显示当前是全局环境。事实上,每一次开启R线程会话,其工作环境都是全局环境。我们一般是在这个环境中创建变量和函数进行分析。
因为环境也是对象,所以我们可以将环赋值给变量,并用它创建新的符号:
global = environment()
global$some_obj = 1
上面代码完全等价于some_obj = 1
。
另有其他方法访问全局环境——globalenv()
和.GlobalEnv
:
globalenv()
#> <environment: R_GlobalEnv>
.GlobalEnv
#> <environment: R_GlobalEnv>
全局环境提供用户的工作空间,而基础环境baseenv()
则提供基础函数和运算符:
baseenv()
#> <environment: base>
全局环境和基础环境是最重要的内置环境,你可以会对它们所处的环境链感兴趣,我们用以下函数进行寻找:
parents = function(env) {
while (TRUE) {
name = environmentName(env)
txt = if (nzchar(name)) name else format(env)
cat(txt, "\n")
env = parent.env(env)
}
}
全局环境链:
parents(globalenv())
#> R_GlobalEnv
#> package:stats
#> package:graphics
#> package:grDevices
#> package:utils
#> package:datasets
#> package:methods
#> Autoloads
#> base
#> R_EmptyEnv
#> Error in parent.env(env): the empty environment has no parent
因为环境链终止于空环境,所以最后报错了。我们可以使用emptyenv()
查看空环境。
parents(baseenv())
#> base
#> R_EmptyEnv
#> Error in parent.env(env): the empty environment has no parent
环境链是内置环境和扩展包环境的组合,使用search()
函数可以从全局环境视角获取变量的搜索路径:
search()
#> [1] ".GlobalEnv" "package:stats" "package:graphics"
#> [4] "package:grDevices" "package:utils" "package:datasets"
#> [7] "package:methods" "Autoloads" "package:base"
我们举例子进行说明:
median(c(1, 2, 1+3))
#> [1] 2
虽然这个计算表达式非常简单,但实际的运行过程却要复杂得多——首先,R在环境链中寻找median()
函数,该函数处于stats
包环境中,然后再基础环境中找到了c()
函数,另外+
也是一个函数,它也在基础包环境中。
事实上,当我们加载一个扩展包,这个包得环境都会插入搜索路径,并位于全局环境之前。如果需要调用两个包得同名函数,则会优先选取后加载得包中定义得函数,即后添加的包函数会屏蔽掉之前加载包的同名函数,因为后加载的包环境更接近全局环境。
与函数相关的环境
函数环境也会控制符号的查找,有3个与函数及其运行过程相关的重要环境:执行环境、封闭环境以及调用环境。
每次调用函数时,R会创建一个新的环境来主管函数的执行过程,这就是函数调用的执行环境,函数的参数和在函数中创建的变量实际上是执行环境中的变量。
函数的执行环境也有父环境,称为封闭环境,即定义函数的环境。这意思是在函数执行时,任何未在执行环境中定义的变量都会到封闭环境中寻找,这正是词法作用域的机理。
有时,了解调用环境(调用函数的环境)是很有用的,可以使用parent.frame()
来获取当前执行函数的调用环境。
我们现在用实例进行理解:
simple_fun = function() {
cat("Executing environment: ")
print(environment())
cat("Enclosing environment: " )
print(parent.env(environment()))
}
上面这个函数用来输出函该数被调用的执行环境和封闭环境:
simple_fun()
#> Executing environment: <environment: 0x7fee7d9660e0>
#> Enclosing environment: <environment: R_GlobalEnv>
simple_fun()
#> Executing environment: <environment: 0x7fee840ca898>
#> Enclosing environment: <environment: R_GlobalEnv>
simple_fun()
#> Executing environment: <environment: 0x7fee7d97e930>
#> Enclosing environment: <environment: R_GlobalEnv>
每次调用函数时,其执行环境都在变化,但封闭环境是相同的。事实上定义函数时封闭函数已经确认了,我们使用environment()
进行查看:
environment(simple_fun)
#> <environment: R_GlobalEnv>
下面的例子涉及3个嵌套函数的3个环境输出:
f1 = function() {
cat("[f1] Executing in ")
print(environment())
cat("[f1] Enclosed by ")
print(parent.env(environment()))
cat("[f1] Calling from ")
print(parent.frame())
f2 = function() {
cat("[f2] Executing in ")
print(environment())
cat("[f2] Enclosed by ")
print(parent.env(environment()))
cat("[f2] Calling from ")
print(parent.frame())
}
f3 = function() {
cat("[f3] Executing in ")
print(environment())
cat("[f3] Enclosed by ")
print(parent.env(environment()))
cat("[f3] Calling from ")
print(parent.frame())
f2()}
f3()
}
如果你觉得理解了上述3个环境,可以尝试猜一下f1()
的输出结果。
答案如下:
f1()
#> [f1] Executing in <environment: 0x7fee7d147d98>
#> [f1] Enclosed by <environment: R_GlobalEnv>
#> [f1] Calling from <environment: R_GlobalEnv>
#> [f3] Executing in <environment: 0x7fee7b27c188>
#> [f3] Enclosed by <environment: 0x7fee7d147d98>
#> [f3] Calling from <environment: 0x7fee7d147d98>
#> [f2] Executing in <environment: 0x7fee7b275c10>
#> [f2] Enclosed by <environment: 0x7fee7d147d98>
#> [f2] Calling from <environment: 0x7fee7b27c188>
这些输出结果说明了:
f1()
的封闭环境和调用环境都是全局环境f3()
的封闭环境和调用环境f2()
的封闭环境和f1()
的执行环境相同f2()
的调用环境和f3()
的执行环境相同
简单来说,即
f1()
在全局环境中被定义和调用f3()
在f1()
中被定义并调用f2()
在f1()
中被定义,但在f3()
中被调用
如果想深入学习,推荐Hadley Wickham
的《Advance R》一书。
学习和整理自《R语言编程指南》