R语言进阶 | 变量赋值背后的机制与R语言内存优化
為什么要了解變量賦值?
變量賦值牽涉到對(duì)象和變量名,理解對(duì)象和變量名之間的區(qū)別和聯(lián)系將對(duì)你有如下幫助:
(1)幫助你更精準(zhǔn)預(yù)測(cè)代碼的行為和內(nèi)存的使用情況;(2)避免代碼運(yùn)行過(guò)程中不必要的對(duì)象復(fù)制,從而加快代碼運(yùn)行的速度;(3)幫助你進(jìn)一步了解R語(yǔ)言函數(shù)式編程的原理。
理解綁定(banding)
x <- c(1, 2, 3)閱讀上面這行代碼,我們自然地理解為:”創(chuàng)建一個(gè)名為x的對(duì)象,其包括元素值1,2和3“。但實(shí)際上這種理解是不準(zhǔn)確的,我們可以認(rèn)為這行代碼背后做了兩件事情:(1)創(chuàng)建一個(gè)向量對(duì)象,即c(1, 2, 3);(2)將這個(gè)對(duì)象和變量名x綁定起來(lái)。換句話說(shuō)對(duì)象可以有一定的類型但是沒(méi)有名字,而變量名可以通過(guò)和對(duì)象綁定從而指向一定的值。
可以參考下圖進(jìn)一步理解,左邊方框中的變量名x和右邊的向量對(duì)象通過(guò)綁定從而聯(lián)系起來(lái)。右邊對(duì)象下方的”0x74b“可以理解為這個(gè)向量對(duì)象的識(shí)別符(identifier),實(shí)際上是該對(duì)象在內(nèi)存中的存儲(chǔ)地址。一個(gè)對(duì)象的存儲(chǔ)地址可以通過(guò)R包lobstr的obj_addr()函數(shù)進(jìn)行獲取。
此時(shí)如果將變量x賦值給y,那么x和y會(huì)指向同一個(gè)對(duì)象。
y <- x##通過(guò)lobstr::obj_addr()查看x和y的存儲(chǔ)地址 obj_addr(x) #> [1] "0x7fe11b31b1e8" obj_addr(y) #> [1] "0x7fe11b31b1e8"符合語(yǔ)法規(guī)則的變量名(syntactic names)
R的變量名要求由字母、數(shù)字、下劃線、小數(shù)點(diǎn)組成, 開(kāi)頭不能是數(shù)字、下劃線、小數(shù)點(diǎn), 中間不能使用空格、減號(hào)、井號(hào)等特殊符號(hào), 變量名不能與if、NA等保留字相同(可用?Reserved命令查看所有的保留字)。有時(shí)為了與其它軟件系統(tǒng)兼容, 需要使用不符合規(guī)則的變量名, 這時(shí)只要將變量名兩邊用反引號(hào) (``)保護(hù)即可。
值得一提的是在用read.csv()讀取文件時(shí),不符合命名規(guī)則的變量名會(huì)被強(qiáng)制改為符合命名規(guī)則的名稱(比如有的基因名稱中的"-“會(huì)被改變成”.",造成一定的麻煩),這時(shí)候可以通過(guò)check.names 參數(shù)進(jìn)行關(guān)閉這種強(qiáng)制行為。另外 make.unique()和make.names()也是和變量名相關(guān)的函數(shù),感興趣的讀者可繼續(xù)了解。
Copy-on-modify機(jī)制
以下代碼將x和y同時(shí)綁定至同一向量,然后再修改變量y。
x <- c(1, 2, 3) y <- xy[[3]] <- 4 x #> [1] 1 2 3雖然開(kāi)始x和y指向同一對(duì)象(0x74b),但當(dāng)改變變量y,變量x并沒(méi)有改變。x仍然指向原來(lái)的向量對(duì)象(0x74b),而y則指向了修改后的另一個(gè)副本對(duì)象(0xcd2),這個(gè)對(duì)象實(shí)際上是通過(guò)復(fù)制原來(lái)的對(duì)象(0x74b)并進(jìn)行對(duì)應(yīng)的修改得來(lái)的。即復(fù)制行為是通過(guò)修改而引發(fā)的,故這種行為稱為copy-on-modify。
tracemem()函數(shù)
你可以通過(guò)base::tracemem函數(shù)觀測(cè)一個(gè)對(duì)象是否被復(fù)制。如果對(duì)一個(gè)對(duì)象使用這個(gè)命令,首先會(huì)返回這個(gè)對(duì)象的存儲(chǔ)地址,之后如果這個(gè)對(duì)象發(fā)生了復(fù)制則會(huì)輸出對(duì)應(yīng)的地址變化信息,除非使用 base::untracemem()函數(shù)取消對(duì)這個(gè)對(duì)象的跟蹤。
x <- c(1, 2, 3) cat(tracemem(x), "\n") #> <0x7f80c0e0ffc8> y <- x y[[3]] <- 4L #> tracemem[0x7f80c0e0ffc8 -> 0x7f80c4427f40]: #提示發(fā)生一次復(fù)制Function calls
以上講的關(guān)于變量的復(fù)制規(guī)則也適用于函數(shù)使用時(shí)。
f <- function(a) {a }x <- c(1, 2, 3) cat(tracemem(x), "\n") #> <0x7fe1121693a8>z <- f(x) # 調(diào)用函數(shù)過(guò)程中并沒(méi)有發(fā)生復(fù)制,即變量z和x指向同一對(duì)象!untracemem(x)當(dāng)函數(shù)運(yùn)行時(shí),函數(shù)里的a變量將會(huì)指向x指向的對(duì)象:
從上面的例子可以看出, 函數(shù)f以x為實(shí)參, 但不修改x的元素, 不會(huì)生成x的副本(不發(fā)生復(fù)制), 返回的值是x指向的對(duì)象本身, 再次賦值給z, 也不制作副本, z和x綁定到同一對(duì)象(0x74b)。
Lists
l1 <- list(1, 2, 3)對(duì)于列表了l1而言,表面上似乎和上面提到的數(shù)字向量類似(即l1指向了一個(gè)包括三個(gè)元素的列表對(duì)象)。但實(shí)際上列表會(huì)更加復(fù)雜一點(diǎn),因?yàn)榱斜泶鎯?chǔ)的不是值本身而是存儲(chǔ)指向某些值的鏈接。
當(dāng)改變一個(gè)列表時(shí):
l2 <- l1 l2[[3]] <- 4和數(shù)字向量一樣,列表同樣遵守 copy-on-modify規(guī)則。即原始的列表被保留下不作變化,R會(huì)創(chuàng)建一個(gè)經(jīng)過(guò)修改的副本。但這里復(fù)制其實(shí)是淺拷貝(shallow copy),簡(jiǎn)單講就是l2和l1并不是完全獨(dú)立的兩個(gè)對(duì)象,l2中未經(jīng)改變的元素(前兩個(gè)元素)還是和l1共享的。
為了觀察兩個(gè)列表中元素的共享情況,可以用lobstr::ref()函數(shù)輸出每個(gè)列表元素的存儲(chǔ)地址,存儲(chǔ)地址相同的元素就是共享的元素。
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
數(shù)據(jù)框(data frames)是由多個(gè)向量組成,copy-on-modify規(guī)則在數(shù)據(jù)框中也成立,數(shù)據(jù)框中的每個(gè)元素都指向某個(gè)對(duì)應(yīng)的向量。
d1 <- data.frame(x = c(1, 5, 6), y = c(2, 4, 3))如果你只修改某一列,那么僅僅這一列會(huì)被修改,其他的還是指向原始的對(duì)象。
d2 <- d1 d2[, 2] <- d2[, 2] * 2如果你修改某一行,則其實(shí)每一列都會(huì)被修改,這就意味著每一列都會(huì)被復(fù)制。
d3 <- d1 d3[1, ] <- d3[1, ] * 3Character vectors
字符串向量和數(shù)字向量是不同的。我們常常會(huì)用如下圖去理解字符串向量。
x <- c("a", "a", "abc", "d")但實(shí)際上對(duì)于字符串向量,R常常會(huì)使用global string pool,這個(gè)pool里面包含所有不重復(fù)的(unique)字符串向量元素,每個(gè)元素可以被重復(fù)指向。這樣的好處顯而易見(jiàn),可以減少內(nèi)存使用。
另外可以用ref()函數(shù)來(lái)查看字符串向量?jī)?nèi)部的存儲(chǔ)結(jié)構(gòu)。
ref(x, character = TRUE) #記得設(shè)置character參數(shù) #> █ [1:0x7fe114251578] <chr> #> ├─[2:0x7fe10ead1648] <string: "a"> #> ├─[2:0x7fe10ead1648] #> ├─[3:0x7fe11b27d670] <string: "abc"> #> └─[4:0x7fe10eda4170] <string: "d">對(duì)象大小(Object size)
可以通過(guò)lobstr::obj_size()函數(shù)來(lái)查看一個(gè)對(duì)象的大小。
obj_size(letters) #> 1,712 B obj_size(ggplot2::diamonds) #> 3,456,344 B因?yàn)榱斜淼脑夭⒉皇蔷唧w值而是指向值的鏈接。所以下面代碼中y變量的大小可能比預(yù)計(jì)中的要小得多。
x <- runif(1e6) obj_size(x) #> 8,000,048 By <- list(x, x, x) obj_size(y) #> 8,000,128 By的大小比x大80bytes,實(shí)際上這80bytes就是具有三個(gè)元素的空列表的大小。
obj_size(list(NULL, NULL, NULL)) #> 80 B同樣的,因?yàn)镽使用global string pool存儲(chǔ)字符串向量的元素,所以下面代碼中即使當(dāng)字符串的數(shù)量增加100倍,但向量的大小并沒(méi)有增加100倍。
banana <- "bananas bananas bananas" obj_size(banana) #> 136 B obj_size(rep(banana, 100)) #> 928 B另外一個(gè)值得注意特征是:用冒號(hào)(:)產(chǎn)生的連續(xù)變化的元素組成的字符串向量(如1:3),不管這個(gè)向量跨度有多大,所占的大小都是一樣的。因?yàn)榇藭r(shí)只會(huì)存儲(chǔ)首尾兩個(gè)元素。
obj_size(1:3) #> 680 B obj_size(1:1e3) #> 680 B obj_size(1:1e6) #> 680 B obj_size(1:1e9) #> 680 BModify-in-place
正如我們?cè)谏厦嫠吹降?#xff0c;修改 R 對(duì)象時(shí)通常會(huì)創(chuàng)建一個(gè)副本,但有兩個(gè)例外的情況:
-
當(dāng)對(duì)象只和一個(gè)變量名綁定時(shí)會(huì)進(jìn)行特殊優(yōu)化處理,修改對(duì)象時(shí)不創(chuàng)建副本;
-
環(huán)境(Environments)對(duì)象
單綁定
如果一個(gè)對(duì)象只綁定了一個(gè)變量,修改對(duì)象不會(huì)創(chuàng)建一個(gè)副本(注意下面對(duì)象修改前后變量指向的地址不變)
v <- c(1, 2, 3) v[[3]] <- 4但是作為編寫代碼的人,在實(shí)際應(yīng)用中其實(shí)很難判斷一個(gè)對(duì)象什么時(shí)候會(huì)應(yīng)用該優(yōu)化的機(jī)制,主要原因包括兩點(diǎn):
-
與python不同,R語(yǔ)言的引用計(jì)數(shù)只包括 0 1 many。這意味著如果一個(gè)對(duì)象有兩個(gè)綁定,并且一個(gè)消失了,那么引用計(jì)數(shù)不會(huì)回到 1。反過(guò)來(lái),這意味著 R 有時(shí)會(huì)在不需要時(shí)進(jìn)行復(fù)制。
-
當(dāng)你調(diào)用絕大多數(shù)的函數(shù)時(shí),它都會(huì)對(duì)對(duì)象進(jìn)行引用(“primitive” C 編寫的函數(shù)例外)。
所以,哪怕是經(jīng)驗(yàn)豐富的R語(yǔ)言愛(ài)好者也可能很難準(zhǔn)備憑借經(jīng)驗(yàn)來(lái)判斷解釋器是否會(huì)創(chuàng)建副本,這里建議如有需要使用tracemem函數(shù)進(jìn)行追蹤調(diào)試。
我們來(lái)看一個(gè)例子,我們實(shí)現(xiàn)將一個(gè)大數(shù)據(jù)框的每一列減去其中位數(shù)的操作:
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]] }這個(gè)循環(huán)運(yùn)行速度會(huì)非常慢,因?yàn)樯婕暗酱罅康膬?nèi)存分配、副本創(chuàng)建的操作:
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)我們驚恐的發(fā)現(xiàn),循環(huán)一次竟然觸發(fā)了三次開(kāi)辟內(nèi)存新建副本的操作!!所以我們需要優(yōu)化我們的代碼,比如我們將data.frame轉(zhuǎn)換成list,性能會(huì)得到顯著提高:
y <- as.list(x) cat(tracemem(y), "\n") #> <0x7f80c5c3de20>for (i in 1:5) {y[[i]] <- y[[i]] - medians[[i]] } #> tracemem[0x7f80c5c3de20 -> 0x7f80c48de210]:Environments
環(huán)境變量(Environments)是R語(yǔ)言里面一種特殊的數(shù)據(jù)類型,該種數(shù)據(jù)類型的修改永遠(yuǎn)遵守modify in place的原則。我們舉例說(shuō)明:
e1 <- rlang::env(a = 1, b = 2, c = 3) e2 <- e1如果我們修改其中的屬性,其修改也是modify in place:
e1$c <- 4 e2$c #> [1] 4垃圾回收
我們?cè)谥暗耐莆闹薪o大家介紹過(guò)《python垃圾回收機(jī)制》,趁著這個(gè)機(jī)會(huì)也給大家分享一下R語(yǔ)言的垃圾回收機(jī)制。python的垃圾回收機(jī)制主要利用引用計(jì)數(shù) 和分代回收兩個(gè)算法來(lái)實(shí)現(xiàn),而R語(yǔ)言則利用tracing GC的方法。這意味著R語(yǔ)言會(huì)跟蹤從 global environment 中可訪問(wèn)的每一個(gè)對(duì)象,以及從這些對(duì)象中可訪問(wèn)的所有對(duì)象(即遞歸搜索列表和環(huán)境中的引用)。
每當(dāng) R 需要更多內(nèi)存來(lái)創(chuàng)建新對(duì)象時(shí),垃圾收集器 (GC) 就會(huì)自動(dòng)運(yùn)行。從用戶角度來(lái)講,基本上無(wú)法預(yù)測(cè) GC 什么時(shí)候會(huì)運(yùn)行。如果你想知道 GC 什么時(shí)候運(yùn)行,調(diào)用 `gcinfo(TRUE)`,GC 會(huì)在每次運(yùn)行時(shí)向控制臺(tái)打印一條消息。用戶可以通過(guò)調(diào)用 `gc()`來(lái)強(qiáng)制進(jìn)行垃圾收集。在必要的時(shí)候,你可以手動(dòng)調(diào)用 `gc()`快速釋放內(nèi)存給操作系統(tǒng),以便其他程序可以正常運(yùn)行,或者統(tǒng)計(jì)內(nèi)存使用情況: 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 132lobstr::mem_used() # 或則使用該函數(shù) #> 89,748,952 B需要注意的是,上面所顯示的內(nèi)存使用情況可能和操作系統(tǒng)的內(nèi)存使用情況不一致,主要有以下三個(gè)原因:
它包括由 R 創(chuàng)建但不由 R 解釋器創(chuàng)建的對(duì)象
R 和操作系統(tǒng)的統(tǒng)計(jì)結(jié)果都有一定的延遲
內(nèi)存碎片:R 計(jì)算對(duì)象占用的內(nèi)存,但由于刪除的對(duì)象可能存在空白。
Reference
Translated from advanced R
總結(jié)
以上是生活随笔為你收集整理的R语言进阶 | 变量赋值背后的机制与R语言内存优化的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: python垃圾回收 (GC) 机制
- 下一篇: 算法训练 字符串的展开