vlambda博客
学习文章列表

R语言进阶|变量赋值背后的机制

工欲善其事,必先利其器。本篇推文从变量赋值展开一步步分享如何优化 R 语言程序,让你的 R 语言程序加速起来~

为什么要了解变量赋值?

变量赋值牵涉到对象和变量名,理解对象和变量名之间的区别和联系将对你有如下帮助:

(1)帮助你更精准预测代码的行为和内存的使用情况;(2)避免代码运行过程中不必要的对象复制,从而加快代码运行的速度;(3)帮助你进一步了解 R 语言函数式编程的原理。

理解绑定(banding)

x <- c(123)

阅读上面这行代码,我们自然地理解为:”创建一个名为 x 的对象,其包括元素值 1,2 和 3“。但实际上这种理解是不准确的,我们可以认为这行代码背后做了两件事情:(1)创建一个向量对象,即 c(1, 2, 3);(2)将这个对象和变量名 x 绑定起来。换句话说对象可以有一定的类型但是没有名字,而变量名可以通过和对象绑定从而指向一定的值。


此时如果将变量 x 赋值给 y,那么 x 和 y 会指向同一个对象。

y <- x

##通过lobstr::obj_addr()查看x和y的存储地址
obj_addr(x)
#> [1] "0x7fe11b31b1e8"
obj_addr(y)
#> [1] "0x7fe11b31b1e8"
R语言进阶|变量赋值背后的机制

syntactic names

符合语法规则的变量名(syntactic names),R 的变量名要求由字母、数字、下划线、小数点组成, 开头不能是数字、下划线、小数点, 中间不能使用空格、减号、井号等特殊符号, 变量名不能与ifNA等保留字相同(可用?Reserved命令查看所有的保留字)。有时为了与其它软件系统兼容, 需要使用不符合规则的变量名, 这时只要将变量名两边用反引号 (``)保护即可。

值得一提的是在用 read.csv()读取文件时,不符合命名规则的变量名会被强制改为符合命名规则的名称(比如有的基因名称中的"-"会被改变成".",造成一定的麻烦),这时候可以通过 check.names 参数进行关闭这种强制行为。另外 make.unique()make.names()也是和变量名相关的函数,感兴趣的读者可继续了解。

Copy-on-modify 机制

以下代码将 x 和 y 同时绑定至同一向量,然后再修改变量 y。

x <- c(123)
y <- x

y[[3]] <- 4
x
#> [1] 1 2 3

虽然开始 x 和 y 指向同一对象(0x74b),但当改变变量 y,变量 x 并没有改变。x 仍然指向原来的向量对象(0x74b),而 y 则指向了修改后的另一个副本对象(0xcd2),这个对象实际上是通过复制原来的对象(0x74b)并进行对应的修改得来的。即复制行为是通过修改而引发的,故这种行为称为copy-on-modify。

R语言进阶|变量赋值背后的机制

tracemem()函数

x <- c(123)
cat(tracemem(x), "\n")
#> <0x7f80c0e0ffc8>

y <- x
y[[3]] <- 4L
#> tracemem[0x7f80c0e0ffc8 -> 0x7f80c4427f40]:   #提示发生一次复制

Function calls

以上讲的关于变量的复制规则也适用于函数使用时。

f <- function(a) {
  a
}

x <- c(123)
cat(tracemem(x), "\n")
#> <0x7fe1121693a8>

z <- f(x)
# 调用函数过程中并没有发生复制,即变量z和x指向同一对象!

untracemem(x)

当函数运行时,函数里的a变量将会指向x指向的对象:

R语言进阶|变量赋值背后的机制

从上面的例子可以看出, 函数fx为实参, 但不修改x的元素, 不会生成x的副本(不发生复制), 返回的值是x指向的对象本身, 再次赋值给z, 也不制作副本, zx绑定到同一对象(0x74b)。

R语言进阶|变量赋值背后的机制

Lists

l1 <- list(123)

对于列表 l1 而言,表面上似乎和上面提到的数字向量类似(即 l1 指向了一个包括三个元素的列表对象)。但实际上列表会更加复杂一点,因为列表存储的不是值本身而是存储指向某些值的链接。

R语言进阶|变量赋值背后的机制

当改变一个列表时:

l2 <- l1

R语言进阶|变量赋值背后的机制

l2[[3]] <- 4

R语言进阶|变量赋值背后的机制

和数字向量一样,列表同样遵守  copy-on-modify规则。即原始的列表被保留下不作变化,R 会创建一个经过修改的副本。但这里复制其实是浅拷贝(shallow copy),简单讲就是 l2 和 l1 并不是完全独立的两个对象,l2 中未经改变的元素(前两个元素)还是和 l1 共享的。

ref(l1, l2)
#> █ [1:0x7fe11166c6d8] <list>
#> ├─[2:0x7fe11b6d2078] <dbl>
#> ├─[3:0x7fe11b6d2040] <dbl>
#> └─[4:0x7fe11b6d2008] <dbl>
#>
#> █ [5:0x7fe11411cc18] <list>
#> ├─[2:0x7fe11b6d2078]
#> ├─[3:0x7fe11b6d2040]
#> └─[6:0x7fe114130a70] <dbl>

Data frames

数据框(data frames)是由多个向量组成,copy-on-modify规则在数据框中也成立,数据框中的每个元素都指向某个对应的向量。

d1 <- data.frame(x = c(156), y = c(243))
R语言进阶|变量赋值背后的机制

如果你只修改某一列,那么仅仅这一列会被修改,其他的还是指向原始的对象。

d2 <- d1
d2[, 2] <- d2[, 2] * 2
R语言进阶|变量赋值背后的机制

如果你修改某一行,则其实每一列都会被修改,这就意味着每一列都会被复制。

d3 <- d1
d3[1, ] <- d3[1, ] * 3

R语言进阶|变量赋值背后的机制

Character vectors

字符串向量和数字向量是不同的。我们常常会用如下图去理解字符串向量。

x <- c("a""a""abc""d")
R语言进阶|变量赋值背后的机制

但实际上对于字符串向量,R 常常会使用global string pool,这个 pool 里面包含所有不重复的(unique)字符串向量元素,每个元素可以被重复指向。这样的好处显而易见,可以减少内存使用。

R语言进阶|变量赋值背后的机制

另外可以用ref()函数来查看字符串向量内部的存储结构。

ref(x, character = TRUE)  #记得设置character参数
#> █ [1:0x7fe114251578] <chr>
#> ├─[2:0x7fe10ead1648] <string: "a">
#> ├─[2:0x7fe10ead1648]
#> ├─[3:0x7fe11b27d670] <string: "abc">
#> └─[4:0x7fe10eda4170] <string: "d">

对象大小(Object size)

可以通过lobstr::obj_size()函数来查看一个对象的大小。

obj_size(letters)
#> 1,712 B
obj_size(ggplot2::diamonds)
#> 3,456,344 B

因为列表的元素并不是具体值而是指向值的链接。所以下面代码中 y 变量的大小可能比预计中的要小得多。

x <- runif(1e6)
obj_size(x)
#> 8,000,048 B

y <- list(x, x, x)
obj_size(y)
#> 8,000,128 B

y 的大小比 x 大 80bytes,实际上这 80bytes 就是具有三个元素的空列表的大小。

obj_size(list(NULLNULLNULL))
#> 80 B

同样的,因为 R 使用global string pool存储字符串向量的元素,所以下面代码中即使当字符串的数量增加 100 倍,但向量的大小并没有增加 100 倍。

banana <- "bananas bananas bananas"
obj_size(banana)
#> 136 B
obj_size(rep(banana, 100))
#> 928 B

另外一个值得注意特征是:用冒号(:)产生的连续变化的元素组成的字符串向量(如 1:3),不管这个向量跨度有多大,所占的大小都是一样的。因为此时只会存储首尾两个元素。

obj_size(1:3)
#> 680 B
obj_size(1:1e3)
#> 680 B
obj_size(1:1e6)
#> 680 B
obj_size(1:1e9)
#> 680 B

Modify-in-place

正如我们在上面所看到的,修改 R 对象时通常会创建一个副本,但有两个例外的情况:

  • 当对象只和一个变量名绑定时会进行特殊优化处理,修改对象时不创建副本;

  • 环境(Environments)对象

单绑定

v <- c(123)
R语言进阶|变量赋值背后的机制
v[[3]] <- 4
R语言进阶|变量赋值背后的机制

但是作为编写代码的人,在实际应用中其实很难判断一个对象什么时候会应用该优化的机制,主要原因包括两点:

  • 与 python 不同,R 语言的引用计数只包括 0 1 many。这意味着如果一个对象有两个绑定,并且一个消失了,那么引用计数不会回到 1。反过来,这意味着 R 有时会在不需要时进行复制。

  • 当你调用绝大多数的函数时,它都会对对象进行引用(“primitive” C 编写的函数例外)。

所以,哪怕是经验丰富的 R 语言爱好者也可能很难准备凭借经验来判断解释器是否会创建副本,这里建议如有需要使用tracemem函数进行追踪调试。

我们来看一个例子,我们实现将一个大数据框的每一列减去其中位数的操作:

x <- data.frame(matrix(runif(5 * 1e4), ncol = 5))
medians <- vapply(x, median, numeric(1))

for (i in seq_along(medians)) {
  x[[i]] <- x[[i]] - medians[[i]]
}

这个循环运行速度会非常慢,因为涉及到大量的内存分配、副本创建的操作:

cat(tracemem(x), "\n")
#> <0x7f80c429e020>

for (i in 1:5) {
  x[[i]] <- x[[i]] - medians[[i]]
}
#> tracemem[0x7f80c429e020 -> 0x7f80c0c144d8]:
#> tracemem[0x7f80c0c144d8 -> 0x7f80c0c14540]: [[<-.data.frame [[<-
#> tracemem[0x7f80c0c14540 -> 0x7f80c0c145a8]: [[<-.data.frame [[<-
#> tracemem[0x7f80c0c145a8 -> 0x7f80c0c14610]:
#> tracemem[0x7f80c0c14610 -> 0x7f80c0c14678]: [[<-.data.frame [[<-
#> tracemem[0x7f80c0c14678 -> 0x7f80c0c146e0]: [[<-.data.frame [[<-
#> tracemem[0x7f80c0c146e0 -> 0x7f80c0c14748]:
#> tracemem[0x7f80c0c14748 -> 0x7f80c0c147b0]: [[<-.data.frame [[<-
#> tracemem[0x7f80c0c147b0 -> 0x7f80c0c14818]: [[<-.data.frame [[<-
#> tracemem[0x7f80c0c14818 -> 0x7f80c0c14880]:
#> tracemem[0x7f80c0c14880 -> 0x7f80c0c148e8]: [[<-.data.frame [[<-
#> tracemem[0x7f80c0c148e8 -> 0x7f80c0c14950]: [[<-.data.frame [[<-
#> tracemem[0x7f80c0c14950 -> 0x7f80c0c149b8]:
#> tracemem[0x7f80c0c149b8 -> 0x7f80c0c14a20]: [[<-.data.frame [[<-
#> tracemem[0x7f80c0c14a20 -> 0x7f80c0c14a88]: [[<-.data.frame [[<-

untracemem(x)

我们惊恐地发现,循环一次竟然触发了三次开辟内存新建副本的操作!所以我们需要优化我们的代码,比如我们将data.frame转换成list,性能会得到显著提高:

y <- as.list(x)
cat(tracemem(y), "\n")
#> <0x7f80c5c3de20>

for (i in 1:5) {
  y[[i]] <- y[[i]] - medians[[i]]
}
#> tracemem[0x7f80c5c3de20 -> 0x7f80c48de210]:

Environments

环境变量(Environments)是 R 语言里面一种特殊的数据类型,该种数据类型的修改永远遵守modify in place的原则。我们举例说明:

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

如果我们修改其中的属性,其修改也是modify in place:

e1$c <- 4
e2$c
#> [1] 4

垃圾回收

我们在之前的推文中给大家介绍过《python 垃圾回收机制》,趁着这个机会也给大家分享一下 R 语言的垃圾回收机制。python 的垃圾回收机制主要利用引用计数 和分代回收两个算法来实现,而 R 语言则利用tracing GC的方法。这意味着 R 语言会跟踪从 global environment 中可访问的每一个对象,以及从这些对象中可访问的所有对象(即递归搜索列表和环境中的引用)。

每当 R 需要更多内存来创建新对象时,垃圾收集器 (GC) 就会自动运行。从用户角度来讲,基本上无法预测 GC 什么时候会运行。如果你想知道 GC 什么时候运行,调用 gcinfo(TRUE),GC 会在每次运行时向控制台打印一条消息。

用户可以通过调用 gc()来强制进行垃圾收集。在必要的时候,你可以手动调用  gc()快速释放内存给操作系统,以便其他程序可以正常运行,或者统计内存使用情况:

gc()
#>           used (Mb) gc trigger  (Mb) limit (Mb) max used (Mb)
#> Ncells  884876 47.3    1698228  90.7         NA  1478961   79
#> Vcells 5026893 38.4   17228590 131.5      16384 17226182  132


lobstr::mem_used()  # 或则使用该函数
#> 89,748,952 B

需要注意的是,上面所显示的内存使用情况可能和操作系统的内存使用情况不一致,主要有以下三个原因:

  1. 它包括由 R 创建但不由 R 解释器创建的对象

  2. R 和操作系统的统计结果都有一定的延迟

  3. 内存碎片:R 计算对象占用的内存,但由于删除的对象可能存在空白。

写在篇末

小编将开始一个全新的翻译学习系列advanced R[1],向大家分享 R 语言进阶方面的底层知识,敬请保持关注~

[1]

advanced R:https://adv-r.hadley.nz