記憶體裡的 R 物件
大數據分析與實踐 · Lecture 2–3 補充 · 中國醫藥大學 醫技系

記憶體裡的 R 物件

R 的 vector、list、matrix、factor、data.frame 看起來像五種毫不相干的東西。從「電腦怎麼擺放 bytes」看下去,它們其實只是同一件事的五種包裝

左邊 · 程式端

你寫的那一行 R 程式碼,以及 console 印出來給你看的樣子。

╌╌╌╌►
◄╌╌╌╌
右邊 · 電腦端

同一時刻的記憶體傾印:位址、真正的 bytes、指標、屬性。

每個面板都是左右對照的。把滑鼠移到任何一邊(手機用點的),另一邊會同步亮起來,最下面的連結列會告訴你這兩件事怎麼對上。右邊的十六進位數字不是裝飾 —— 是照 IEEE 754 與 little-endian 真的算出來的。

資料本體  直接躺在記憶體裡 指標 pointer  告訴你去哪裡找 屬性 attributes  怎麼解讀這堆 bytes

01 · The Machine

CPU 只看得到位址與 bytes

RAM 就是一長排編了號的抽屜。CPU 不認得 glucosepatient_id 這些名字,它只會接到一種命令:「去位址 0x1000,把接下來 8 個 bytes 拿出來。」

右邊那張表就是記憶體傾印(hex dump)的樣子:最左是位址,中間是那幾個 byte 真正的值(十六進位),最右是 R 對它的解讀。注意資料上面還有六列 —— 那是 R 為了管理這個物件而放的 header,它跟資料一樣要佔記憶體。

SEXP · 一行程式碼的兩面
程式端 · 你寫的

        
電腦端 · 記憶體傾印

所以一個 3 個數字的向量,到底佔多少記憶體?

絕對不只 24 bytes —— 型別、長度、屬性指標、GC 資訊全都要佔位置,而且它們就緊貼在資料前面。上面那張傾印已經把 header 的六個欄位一格一格拆給你看了:

sxpinfo 型別 double + GC 標記8 attrib 屬性清單的位址,這裡是 NULL8 gengc_next / gengc_prev GC 串列16 length = 38 truelength8
header 小計48 資料 3 × 824
合計72 bytes …但 R 實際配置 80 bytes。差在哪?見下。
實測:為什麼是 80 而不是 72

在 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)48480
numeric(1)56488(1 格)
numeric(2)644816(2 格)
numeric(3)804832(進位到 4 格)
numeric(4)804832(4 格)
numeric(5)964848(進位到 6 格)
numeric(9…16)17648128(16 格)
numeric(17)18448136(超過 16 格就照實配)

兩個推論值得記住:小物件的 header 比資料還貴(3 個數字:48 bytes 的管理費 vs 24 bytes 的資料),所以 R 裡「一百萬個長度 1 的向量」會比「一個長度一百萬的向量」貴非常多;而長度一大,48 bytes 就可以忽略不計了(numeric(100) 是 848 bytes,幾乎全是資料)。

那串 00 00 00 00 00 00 24 40 是什麼

數字 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 這個名字,指向那個位址。

所以名字不在物件裡,物件也不知道自己叫什麼。一個物件可以同時有好幾個名字,也可以一個名字都沒有。<- 做的事情是「改寫名牌指向哪裡」,不是「把東西塞進一個固定的箱子」。

Assignment Walkthrough
程式端 · 一行一行往下跑

        
console
電腦端 · 名字表 + 物件
為什麼 y <- 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 是什麼

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。

Memory Inspector
程式端

        
點一個索引運算式,看它落在哪一列
電腦端
character vector 為什麼是指標

字串長度不一,塞不進固定大小的格子。所以 character vector 每一格放的是指向 R 全域字串池(CHARSXP cache)的 8-byte 指標,字串本體住在別的地方。切到 character 那一頁,右邊的 bytes 就是那些位址本身。

這也是為什麼同一個字串重複出現一萬次,記憶體不會漲一萬倍 —— 一萬格指標指向同一份字串。

04 · Coercion

混裝的下場:往「比較包容」的型別硬轉

既然一排抽屜只能有一套解讀方式,c(TRUE, 1, "a") 就不可能真的混裝。R 的選擇是:全部轉成能裝下所有人的那一種

logical  →  integer  →  double  →  character   (愈右愈包容,字串是終點站)

Coercion Lab
程式端 · 你打算放進去的

        
型別階梯 · 亮起來的是最後的共同型別
電腦端 · 實際存進去的 bytes

注意看 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 的 levelsclass、data.frame 的 names / row.names / class,全都是掛在同一條鏈上的節點 —— 你在那幾節的 header 裡會看到同一個 attrib 欄位。

要注意的是:① 和 ③ 之間沒有任何指標互指。名字和值是靠位置對齊的 —— names 的第 i 格就是資料的第 i 格的名字,如此而已。這也是為什麼 names(v) 長度必須跟 v 一樣,短了就會補 NA。

names Attribute
程式端

        
用位置找
用名字找
電腦端 · 三塊不相鄰的記憶體,用 attrib 串起來
用名字找東西不是免費的

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 倒過來讀,就是箭頭另一端的位址。

Pointer View
程式端

      
電腦端
這張圖是理解 R 最重要的一張圖

Vector 的格子裡是;list 的格子裡是地址。之後 [[ ]]$、data.frame、甚至 unlist() 為什麼會把結構壓扁,全部都從這張圖推得出來。

07 · Subsetting

[ ] 拿盒子,[[ ]] 打開盒子

很多人死背這兩個符號的差別。有了地址卡的圖就不用背了:

  • [ ] —— 把那幾張地址卡連同卡夾抽出來 → 拿到的還是一個 list。
  • [[ ]] —— 沿著地址走過去,把真正指到的東西拿回來 → 拿到的是那個物件本身。
  • $ —— 用名字找到地址卡,然後跟著走。等同 [["名字"]]
Subsetting Sandbox · 左邊換運算式,右邊看它走到哪
程式端
console 輸出
電腦端 · 這一次動到的位址
臨床資料上真的會踩到

df["glucose"] 回傳的是只有一欄的 data.frame,df[["glucose"]]df$glucose 回傳的才是數值向量。把前者丟進 mean() 會直接報錯 —— 因為你遞給它的是一個盒子,不是裡面的數字。

08 · Matrix & Array

Matrix = vector + 一句話

Matrix 不是新的資料結構。它底下仍然是一條平的 vector,R 只是多掛了一個屬性 dim,等於在旁邊貼了張便條:「這 12 個整數,請當成 3 列 × 4 行來讀。」

dim Attribute
程式端 · 你寫的與你看到的

        
print(m) —— 點任何一格
電腦端 · 48 bytes,從頭到尾沒動過

拉動 nrow 時盯著右邊:位址欄與 bytes 一個字都沒變,變的只有 dim 屬性,也就是「怎麼讀」。

09 · Factor

Factor:整數向量 + 一張對照表

血型、分期、性別這種「有限組別」的資料,如果用字串存,每一格都要比對字串,又慢又佔空間。Factor 的做法是:把類別換成整數編碼,再用 levels 屬性記住對照表。

factor() 的兩層構造
程式端 · print 出來的樣子

        
電腦端 · integer + levels
為什麼「最後分析階段再轉 factor」

因為 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:一塊只有四個指標的記憶體,加上 namesrow.names 兩個屬性,再貼上 class = "data.frame" 的標籤讓 print() 知道要畫成表格。

右邊把每一塊都展開了,由上往下三段: data.frame 本體 —— 只有四個位址,一個資料都沒有; 四條各自獨立的 vector,位址互不相鄰; 掛在 attrib 上的三個屬性(欄名、列名、class)。點左邊任一欄,右邊對應的那整塊會亮起來。

同一份資料 · 全部展開
程式端 · print(patients)

        
電腦端 · 全部攤開:① 指標 ② 四塊欄位 ③ 三個屬性

看懂右邊之後,幾件事就變得理所當然:一欄之內必須同型別(因為那是一條 vector),不同欄可以不同型別(因為那是四塊不相干的記憶體),而新增一欄df$gender <- ...)比新增一列rbind())便宜得多 —— 前者只是在指標陣列後面多加 8 bytes,後者要把四塊記憶體全部重新配置一次。

11 · The Whole Map

一條線串起來

把前面十段接起來,R 的資料結構其實只有兩個源頭,其餘都是「加屬性」加出來的:

atomic vector 一排同型資料,直接放值 10 20 30 40 list 一排指標,指向別的物件 + names named vector 名字在另一塊記憶體 + dim matrix / array 同一條 vector,換讀法 + levels + class factor 底層其實是 integer + names named list key–value,像 dict + names + row.names + class data.frame 每格指向一條等長 vector 左半邊:格子裡是「值」 右半邊:格子裡是「位址」
紫色標的就是「加了什麼屬性」—— 五種結構全都是這兩個源頭加屬性長出來的,沒有第三種東西。
結構記憶體裡放什麼同型別?什麼時候用
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

隨堂測驗

十一題。每題選完會立刻給解釋,答錯不扣分,但請先自己在腦中畫一次「左邊程式碼 ↔ 右邊記憶體」的對照再選。

尚未作答 · 0 / 11