記憶體裡的 R 物件
R 的 vector、list、matrix、factor、data.frame 看起來像五種毫不相干的東西。從「電腦怎麼擺放 bytes」看下去,它們其實只是同一件事的五種包裝。
你寫的那一行 R 程式碼,以及 console 印出來給你看的樣子。
◄╌╌╌╌
同一時刻的記憶體傾印:位址、真正的 bytes、指標、屬性。
每個面板都是左右對照的。把滑鼠移到任何一邊(手機用點的),另一邊會同步亮起來,最下面的連結列會告訴你這兩件事怎麼對上。右邊的十六進位數字不是裝飾 —— 是照 IEEE 754 與 little-endian 真的算出來的。
01 · The Machine
CPU 只看得到位址與 bytes
RAM 就是一長排編了號的抽屜。CPU 不認得 glucose、patient_id 這些名字,它只會接到一種命令:「去位址 0x1000,把接下來 8 個 bytes 拿出來。」
右邊那張表就是記憶體傾印(hex dump)的樣子:最左是位址,中間是那幾個 byte 真正的值(十六進位),最右是 R 對它的解讀。注意資料上面還有六列 —— 那是 R 為了管理這個物件而放的 header,它跟資料一樣要佔記憶體。
所以一個 3 個數字的向量,到底佔多少記憶體?
絕對不只 24 bytes —— 型別、長度、屬性指標、GC 資訊全都要佔位置,而且它們就緊貼在資料前面。上面那張傾印已經把 header 的六個欄位一格一格拆給你看了:
header 小計48 資料 3 × 824
合計72 bytes …但 R 實際配置 80 bytes。差在哪?見下。
在 R 4.3.3(64 位元)裡實際量一下:
object.size(numeric(0)) # 48 bytes ← 這就是純 header object.size(c(10, 20, 30)) # 80 bytes
差的那 8 bytes 是因為 R 不會剛好只配 24 bytes:小向量是從固定的「尺寸級距」裡拿的,以 8 bytes 為一格,級距是 1、2、4、6、8、16 格。3 個 double 需要 3 格,只好進位到 4 格 = 32 bytes,所以 48 + 32 = 80。
| 物件 | object.size | = header | + 實際配置的資料 |
|---|---|---|---|
| numeric(0) | 48 | 48 | 0 |
| numeric(1) | 56 | 48 | 8(1 格) |
| numeric(2) | 64 | 48 | 16(2 格) |
| numeric(3) | 80 | 48 | 32(進位到 4 格) |
| numeric(4) | 80 | 48 | 32(4 格) |
| numeric(5) | 96 | 48 | 48(進位到 6 格) |
| numeric(9…16) | 176 | 48 | 128(16 格) |
| numeric(17) | 184 | 48 | 136(超過 16 格就照實配) |
兩個推論值得記住:小物件的 header 比資料還貴(3 個數字:48 bytes 的管理費 vs 24 bytes 的資料),所以 R 裡「一百萬個長度 1 的向量」會比「一個長度一百萬的向量」貴非常多;而長度一大,48 bytes 就可以忽略不計了(numeric(100) 是 848 bytes,幾乎全是資料)。
數字 10 在 R 裡是雙精度浮點數,照 IEEE 754 binary64 編碼是 0x4024000000000000。x86 與 Apple silicon 都是 little-endian(低位組在前),所以寫進記憶體時順序整個反過來,變成 00 00 00 00 00 00 24 40。
同一堆 bytes,說明書寫 double 就是 10,說明書寫 character 就會變成一段無意義的指標。型別不是資料的一部分,是解讀方式。
02 · Variable & Assignment
變數不是箱子,是貼在位址上的名牌
課堂上我們說 <- 是「裝箱」。那是很好的第一印象,但電腦裡發生的其實是分開的兩件事:
- R 先在記憶體某處造出一個物件(一段 header + 資料),這個物件有自己的位址。
- R 再到目前的 environment(一張「名字 → 位址」的對照表)裡登記:
x這個名字,指向那個位址。
所以名字不在物件裡,物件也不知道自己叫什麼。一個物件可以同時有好幾個名字,也可以一個名字都沒有。<- 做的事情是「改寫名牌指向哪裡」,不是「把東西塞進一個固定的箱子」。
第 2 步只是在名字表裡多寫一列,指向同一個位址。所以把一個 500 MB 的 data.frame 指派給另一個變數是免費的 —— 直到你去改它為止。
第 3 步才是重點:R 發現有兩個名字指著那塊記憶體,於是複製一份到新位址再改,好讓 x 看到的東西不變。這就是 copy-on-modify,也是 tracemem() 在幫你監看的事。
幾個順帶釐清的小地方
- 名字表是雜湊的。environment 是 R 裡少數真的用 hash table 的結構,所以用變數名字找東西很快。等一下你會看到,
v["banana"]那種「用名字索引向量」完全是另一回事 —— 那是線性搜尋。 =與<-不完全一樣。在最外層兩者都能賦值,但在函式呼叫的括號裡,f(x = 3)的=是「指定參數」,不會在你的環境裡留下x。寫<-可以避免這種混淆(RStudio 快捷鍵Alt+-)。- 沒有名字指著的物件會被回收(見下面的 GC)。第 4 步之後,原本那塊 double[3] 沒有任何名牌指向它。
GC = garbage collection,垃圾回收。「垃圾」有明確定義:從名字表出發,再也走不到的物件。R 會不定時從所有 environment 的名字開始往外走,走得到的標記為「還活著」,剩下沒被走到的,那段記憶體就釋放掉給下一個物件用。
所以第 4 步裡 0x1000 那塊之所以是垃圾,不是因為「x 變了」,而是因為現在沒有任何名字能走到它。只要還有一個名字指著(例如你先做過 z <- x),它就會繼續活著。
這是 R、Python、Java 這類語言跟 C 最大的差別之一:你不用、也不能自己釋放記憶體。C 要自己 malloc / free,忘了 free 就記憶體洩漏、free 兩次就當機;R 幫你處理掉了,代價是 GC 執行的那個瞬間程式會稍微停一下。gc() 可以手動叫它跑一次,但幾乎沒有必要 —— R 自己會在需要記憶體時觸發。
常見誤會:rm(x) 不是刪除資料,它只是把名字表裡 x 那一列拿掉。資料要等到 GC 發現沒人指著它,才真的消失。
03 · Atomic Vector
Vector:一排同規格的抽屜
因為每一格大小都一樣,CPU 要找第 i 個元素不需要一格一格數,直接算就好:位址 = 起點 + (i − 1) × 每格 bytes
切換型別時注意左邊位址欄的跨距:double 每次跳 8,integer 與 logical 每次只跳 4。
字串長度不一,塞不進固定大小的格子。所以 character vector 每一格放的是指向 R 全域字串池(CHARSXP cache)的 8-byte 指標,字串本體住在別的地方。切到 character 那一頁,右邊的 bytes 就是那些位址本身。
這也是為什麼同一個字串重複出現一萬次,記憶體不會漲一萬倍 —— 一萬格指標指向同一份字串。
04 · Coercion
混裝的下場:往「比較包容」的型別硬轉
既然一排抽屜只能有一套解讀方式,c(TRUE, 1, "a") 就不可能真的混裝。R 的選擇是:全部轉成能裝下所有人的那一種。
logical → integer → double → character (愈右愈包容,字串是終點站)
注意看 bytes 怎麼變:TRUE 在 logical 是 01 00 00 00,被推到 double 就變成 00 00 00 00 00 00 F0 3F(也就是 1.0),再被推到 character 就只剩一個指向 "TRUE" 的指標。同一個概念,三種完全不同的 bytes。
05 · Named Vector
Named vector:資料一格沒動,旁邊多掛一條名牌
向量可以有名字,但名字不住在抽屜裡。R 把它放在一條完全獨立的 character vector,掛在 attr(, "names") 上 —— 右邊你會看到三塊位址差很遠的記憶體。
那兩塊記憶體是怎麼連起來的?
靠的是物件 header 裡的 attrib 欄位 —— 每個 R 物件的 header 都有一格專門放「屬性清單的位址」。沒有屬性時它是 NULL;一旦你設了 names,R 會做三件事:
- 另外配置一條 character vector 存名字(右邊第 ③ 塊)。
- 建一個屬性節點(pairlist,右邊第 ② 塊):
TAG記著「這個屬性叫 names」,VALUE記著那條 vector 的位址,NEXT指向下一個屬性。 - 把資料物件 header 的
attrib欄位指向這個節點。
所以整條鏈是:資料物件 → attrib → 屬性節點 → names 向量。names(v) 這個函式做的就是走完這條鏈。右邊面板裡滑過 attrib 那一列可以看到整條路徑。
因為節點有 NEXT,屬性是可以一個接一個串下去的。後面 matrix 的 dim、factor 的 levels 與 class、data.frame 的 names / row.names / class,全都是掛在同一條鏈上的節點 —— 你在那幾節的 header 裡會看到同一個 attrib 欄位。
要注意的是:① 和 ③ 之間沒有任何指標互指。名字和值是靠位置對齊的 —— names 的第 i 格就是資料的第 i 格的名字,如此而已。這也是為什麼 names(v) 長度必須跟 v 一樣,短了就會補 NA。
v[2] 是一次乘法就到位。v["banana"] 則要先在 names 那條 character vector 裡逐格比對字串,找到是第幾格,才回頭去算資料本體的位址 —— 這是線性搜尋(O(n)),不是雜湊。
所以 R 的 named vector 跟 Python 的 dict 並不一樣:dict 背後是 hash table,查名字接近 O(1);R 的 named vector 與 list 用名字查找都是從頭掃到尾。資料量小時無感,但在迴圈裡用名字反覆索引十萬筆就會很有感。(R 裡真正用雜湊的是 environment。)
object.size(c(1, 2, 3)) # 80 bytes object.size(c(apple = 1, banana = 2, peach = 3)) # 440 bytes
多出來的 360 bytes 就是右邊 ② ③ 兩塊:屬性節點、names 那條 character vector(它自己也有 48 bytes 的 header),加上字串池裡三個 CHARSXP(每個字串本身又是一個完整的 R 物件,各 56 bytes)。三個名字比三個數字貴了四倍。
(小提醒:object.size() 會把字串池裡共用的 CHARSXP 也算進來,所以對 character 資料它傾向高估;R 4.3.3 實測。)
三件跟著就懂的事
names(v)[2] <- "lemon"只改寫右邊那條 character vector,資料本體那塊記憶體完全沒被碰到。names(v) <- NULL(或unname(v))把屬性拔掉,整塊 names 記憶體被回收,物件退回最單純的 atomic vector —— 上面第四個按鈕就是在演這件事。- 名字可以重複,R 不會阻止你;用名字索引時只會回傳第一個對到的。這是資料清理時很容易中招的地方。
06 · List
List:一排地址卡
一位病人有 id(字串)、血糖(數值)、是否空腹(邏輯值)、三次回診數值(向量)。這些東西大小不一、型別不一,塞不進同一排抽屜。
R 的解法很聰明:那就不要放資料,改放位址。右邊那塊記憶體每一列都是 8 bytes 的指標 —— 你可以直接把那串 hex 倒過來讀,就是箭頭另一端的位址。
Vector 的格子裡是貨;list 的格子裡是地址。之後 [[ ]]、$、data.frame、甚至 unlist() 為什麼會把結構壓扁,全部都從這張圖推得出來。
07 · Subsetting
[ ] 拿盒子,[[ ]] 打開盒子
很多人死背這兩個符號的差別。有了地址卡的圖就不用背了:
[ ]—— 把那幾張地址卡連同卡夾抽出來 → 拿到的還是一個 list。[[ ]]—— 沿著地址走過去,把真正指到的東西拿回來 → 拿到的是那個物件本身。$—— 用名字找到地址卡,然後跟著走。等同[["名字"]]。
df["glucose"] 回傳的是只有一欄的 data.frame,df[["glucose"]] 與 df$glucose 回傳的才是數值向量。把前者丟進 mean() 會直接報錯 —— 因為你遞給它的是一個盒子,不是裡面的數字。
08 · Matrix & Array
Matrix = vector + 一句話
Matrix 不是新的資料結構。它底下仍然是一條平的 vector,R 只是多掛了一個屬性 dim,等於在旁邊貼了張便條:「這 12 個整數,請當成 3 列 × 4 行來讀。」
拉動 nrow 時盯著右邊:位址欄與 bytes 一個字都沒變,變的只有 dim 屬性,也就是「怎麼讀」。
09 · Factor
Factor:整數向量 + 一張對照表
血型、分期、性別這種「有限組別」的資料,如果用字串存,每一格都要比對字串,又慢又佔空間。Factor 的做法是:把類別換成整數編碼,再用 levels 屬性記住對照表。
因為 as.numeric(f) 拿到的是編碼,不是原本的數字。factor(c(28, 34, 30)) 轉回數值會得到 1、3、2,不是 28、34、30。要拿回原值必須繞一圈:as.numeric(as.character(f))。
10 · data.frame
data.frame = 等長 vector 組成的 named list
這是整堂課的收斂點。data.frame 印出來像一張表格,但它不是矩陣 —— 矩陣要求整張表同型別,而檢驗報告裡 id 是字串、血糖是數值、空腹是邏輯值。
它真正的身分是一個 named list:一塊只有四個指標的記憶體,加上 names、row.names 兩個屬性,再貼上 class = "data.frame" 的標籤讓 print() 知道要畫成表格。
右邊把每一塊都展開了,由上往下三段:① data.frame 本體 —— 只有四個位址,一個資料都沒有;② 四條各自獨立的 vector,位址互不相鄰;③ 掛在 attrib 上的三個屬性(欄名、列名、class)。點左邊任一欄,右邊對應的那整塊會亮起來。
看懂右邊之後,幾件事就變得理所當然:一欄之內必須同型別(因為那是一條 vector),不同欄可以不同型別(因為那是四塊不相干的記憶體),而新增一欄(df$gender <- ...)比新增一列(rbind())便宜得多 —— 前者只是在指標陣列後面多加 8 bytes,後者要把四塊記憶體全部重新配置一次。
11 · The Whole Map
一條線串起來
把前面十段接起來,R 的資料結構其實只有兩個源頭,其餘都是「加屬性」加出來的:
| 結構 | 記憶體裡放什麼 | 同型別? | 什麼時候用 |
|---|---|---|---|
vector | 資料本體 | 必須 | 一串同性質的值 |
named vector | 資料本體 + names 那塊 | 必須 | 查表、對照碼、小型 key–value |
matrix / array | 資料本體 + dim | 必須 | 矩陣運算、影像、格狀數據 |
factor | 整數編碼 + levels | 必須 | 血型、分期、性別等有限組別 |
list | 一排指標 | 不必 | 什麼都能裝的收納箱 |
data.frame | 一排指標(各指向一條等長 vector) | 欄內必須 | 真實資料表:一列一筆、一欄一變數 |
最後補一個常被誤解的觀念:copy-on-modify
課堂上常說「R 的向量不可改變,一改就搬家」。用 tracemem() 看的確常常如此,但更精確的說法是 copy-on-modify:R 會數有幾個名字指著同一塊記憶體,
- 如果只有一個名字指著它,R 3.5 之後通常會就地修改,位址不變;
- 如果有兩個以上(例如你先做過
y <- x),修改時才複製一份到新位址,好讓另一個名字看到的東西不變。
這解釋了一個很實際的現象:在迴圈裡用 df$new[i] <- ... 逐格塞值有時飛快、有時慢到不可思議 —— 差別就在當下有幾個名字指著那塊記憶體。
12 · Self-check
隨堂測驗
十一題。每題選完會立刻給解釋,答錯不扣分,但請先自己在腦中畫一次「左邊程式碼 ↔ 右邊記憶體」的對照再選。