列表
Erlang 中的列表是通過鏈表實現的,表示列表的 Eterm 就是這個鏈表的起點。列表 Eterm 中除去 2 位標簽 01 之外,剩下的高 62 位表示指向列表中第一個元素的指針的高 62 位。我們在生成一個列表的時候,會采用這樣的語法:L = [Head | Tail],Head 表示要添加到頭部的單個元素,Tail 表示另一個列表。這種 Head 和 Tail 的組合稱為一個 Cons 單元。在函數式語言里面,獲得 Head 的操作稱為 CAR,獲得 Tail 的操作稱為 CDR。結合我們對 Eterm 的理解,可以看出在 Erlang 的 Eterm 架構下,Head 可以是一個任意類型的 Eterm,Tail 則是一個列表類型的 Eterm。當 Tail 為 NIL 的時候(即表示為 []),這個列表稱為是 well-formed 列表。指向列表第一個元素的 Eterm 一般放在進程的棧中。下面舉個例子,假設我們有這樣一個列表:L = [1,2,ok,done],現在暫時用這個簡單的列表來作為示例,這個列表在進程內的示意圖如下所示:
在構造一個新的列表的時候,如果新的列表引用了其他列表,那么引用了其他列表的元素本身就是一個 Cons 單元格。例如,我們有下面兩個列表:
1 L1 = [1, 2, 3]. 2 L2 = [L1, L1, L1].
L2 中存在 3 個對 L1 的引用,Erlang 可以很聰明地復用 L1,內存中實際上只有一份對 L1 的拷貝,如下圖所示:
為了簡潔,這個圖相比前一個圖簡化了,不再嚴格表示棧和堆的相對位置,不再嚴格表示 Eterm 的標簽。從圖中可以看到,列表 L2 中的 3 個 cons 單元格都引用了 L1。通過未文檔化的 BIF erts_debug:size() 可以獲得一個對象占用的字數:
83> L1 = [1,2,3]. [1,2,3] 84> erts_debug:size(L1). 6 85> L2 = [L1,L1,L1]. [[1,2,3],[1,2,3],[1,2,3]] 86> erts_debug:size(L2). 12
L1 占用 6 個字,因為有 3 個 cons 單元格,每個占用 2 個字。L2 其實應該也占用 6 個字,因為 L2 本身占用的也是 3 個 cons 單元格。但是 erts_debug:size() 算法計算的是整個對象樹的大小[注3],所以還要加上 L1 占用的 6 個字,一共是 12 個字。erts_debug:size() 在統計對象大小的時候會記住子對象是否已經被引用過,所以能反映出對象樹的真實大小。通過另一個未文檔化的 BIF 調用 erts_debug:flat_size() 可以得到對象平坦化后的大小:
88> erts_debug:flat_size(L1). 6 89> erts_debug:flat_size(L2). 24
Erlang 這種共享對象的方式想必是極好的,最直接的好處就是節省了內存。但是這種對象共享在一種情況下會被破壞,那就是跨進程的時候。由於進程之間是不能共享對象的,所以像列表這種復合對象跨進程傳遞的時候,例如當做參數傳入 spawn 創建新進程的時候,以及通過消息發送給其他進城的時候,對象共享會被破壞。在上面的例子中,如果要把 L2 發送給另一個進程,為了嚴格執行進程間不共享數據的原則,L2 必須被深度拷貝到新的進程的堆中。在深度拷貝的時候,在新的進程的堆中會創建 3 份 L1 的拷貝,成了下面這樣:
看看 L2 被發送到其他進程之后 erts_debug:size() 得到的值:
112> P1 = spawn(fun() -> receive L -> io:format("~w~n", [erts_debug:size(L)]) end end). <0.198.0> 113> P1 ! L2. 24 [[1,2,3],[1,2,3],[1,2,3]]
shell 進程將 L2 發送到 P1 之后,P1 請求 io 服務器打印出收到的 L 大小為 24,也就是 L2 平坦化之后的大小。由於 Eterm 跨進程傳輸的時候會被平坦化,所以在某些情況下如果被發送的列表中引用了好幾次某個特別大的對象,那么平坦化之后會占用大量內存,甚至把 Erlang 虛擬機搞掛掉。2012 年 Erlang Workshop 上有一篇論文 [1] 就記錄了這么一個案例,並且提出了一種跨進程也能保持共享對象樹形結構的優化方案。這個方案也屬於 D2.3,可能會引入未來版本的 Erlang 虛擬機。
了解了列表的結構和實現方式之后,我們就可以更高效地使用列表這種數據結構。Erlang 標准庫中 lists 模塊下提供了很多針對列表的操作。在選擇 api 完成我們的需求的時候,應該在了解數據結構內部實現的基礎上考慮,如果我自己實現這個 api,怎么樣才是最高效的(比如說復制的次數最少),一般情況下,庫中選擇的實現方案就是自己能想到的最高效的方案。比如說兩個列表拼接的操作:append(L1, L2),最簡單的實現方法就是直接 L1 ++ L2 了。那么這種做法是否高效呢?很高效!因為 ++ 操作是一個 BIF,在 Erlang 內部是通過 C 語言實現的:復制 L1,然后將 L1 最后一個 cons 單元格的 CDR 修改為指向 L2。這種實現方式是最高效的,因為將復制的必要性降到最低。L1 是必須復制的,因為不復制的話最后一個 cons 單元格應該指向 L2 還是 NIL 呢?由於 ++ 操作要復制 L1,所以要注意如果 L1 很長,那么復制的開銷也是很大的。
由於列表的操作是 Erlang 中非常頻繁的操作,所以有很多不方便用純 Erlang 實現或實現效率低的操作都被做成 BIF 了,在虛擬機的層面直接操作列表的效率很高,避免了大量虛擬機指令的分發和解釋執行。比如 length/1、++、--、member/2 和 reverse/2 等。不過涉及到列表的時候依然要注意,盡管有些操作效率很高,但這只是針對實現上不需要經過 Erlang 虛擬機的多重解釋執行而言的,時間復雜度依然和列表長度呈線性關系,而函數式語言的程序寫起來可能算法復雜度不如幾個 for 循環嵌套那么直觀,所以不要一不小心就寫出 n 的好幾次方的糟糕算法。
以上就是列表的實現,下面我們來看 Boxed 對象的表示和實現。
[注3] erts_debug:size() BIF 調用並不會陷入 Erlang 虛擬機從虛擬機中直接獲得對象大小,而是根據 Erlang 虛擬機對數據結構的表示形式推算出來的。
[1] N. Papaspyrou and K. Sagonas. On preserving term sharing in the erlang virtual machine. In Proceedings of the eleventh ACM SIGPLAN workshop on Erlang workshop, Erlang ’12, pages 11–20, New York, NY, USA, 2012. ACM.