更新:文中示例代碼直接從Joe的新版 Erlang 書中摘抄而來,其中模式匹配的代碼有錯誤,現已糾正。應該用 := 匹配字段,而不是 => 。
即將發布的 Erlang 17 最大變化之一包括新的數據結構 map 的引入。其他很多動態語言,都在語言層面原生地支持映射的數據結構,因此在寫程序的時候隨手需要表示一個類似對象結構這樣的映射數據非常方便。Erlang 原來也有一個類似的結構,record,不過用起來不太方便,語法比較丑陋,“key”只能是原子,而且整個結構在定義了之后是固定的。當然,在標准庫中提供了一個 dict 模塊,但是這個模塊不是語言層面原生提供的,所以使用略麻煩,比如說不能直接進行模式匹配。
現在 Erlang 終於有 map 了,在語言層面提供了支持,用起來就很方面。下面通過 Joe 老頭新版 Erlang 書中的例子簡單說明一下 map 的用法。
首先是創建:
1 F1 = #{ a => 1, b => 2 }. 2 3 Facts = #{ {wife,fred} => "Sue", {age, fred} => 45, 4 {daughter,fred} => "Mary", 5 {likes, jim} => [...]}.
需要 map 的時候隨手拿來,不需要事先定義好 map 的結構。而且 key 可以是任意 Eterm,比如 F1 中的原子,F2 中的元組。當然也可以不同類型的 key 的混合。
然后是修改:
1 F3 = F1#{ c => xx }.
始終不要忘了 Erlang 變量都是 immutable 的,所以修改操作實際上會創建新的 map。這里就創建了 F3。之前 F1 中沒有 c 字段,=> 操作符的意思表示新創建字段 c,然后把 c 映射到 xx。如果 F1 中原本有 c 字段了,那么這個操作會更新 c 字段的值。另外還有一個 := 操作:
1 F4 = F1#{ c := 3}.
:= 操作表示更新一個已有的 key。這句話會拋異常,因為 F1 中沒有 c 這個鍵。
然后就是模式匹配了:
1 Henry8 = #{ class => king, born => 1491, died => 1547 }. 2 #{ born := B } = Henry8.
第 1 行將 Henry8 綁定至新創建的 map,第 2 行左側和右側匹配,將匹配的 born 鍵的值綁定至變量 B。很方便吧,再也不用麻煩地調用 dict:fetch/2 這樣的函數了。
綜上,總結 map 是一個支持字段動態變化的映射數據結構。要注意的是,map 中的 keys 是保持固定順序的,所以每次輸出的時候 keys 的順序是固定且確定的。下面我們來看一下 map 數據結構在虛擬機的內部實現,以了解 map 結構的局限性。
在 erl_map.h 頭文件中可以看到 ascii art 形式的 map 內部結構圖:
THING 表示 map 的 Eterm 值。map 是一個 boxed 對象,boxed 對象的一般格式參見 http://www.cnblogs.com/zhengsyao/p/erlang_eterm_implementation_4_boxed.html,當然 map 有自己的新的 tag。然后下面一個字表示 map 的大小,即包含的 kv 對個數。接下來是一個指向一個元組的指針,這個元組中按固定順序保存了所有的 key 的 Eterm。然后接下來的 n 個 Eterm 就表示 n 個值的 Eterm。
從這個結構我們可以看出 map 存儲的特點:線性存儲。所有的 key 依次在 tuple 中,value 也依次保存在連續內存空間中。所以查找一個 key 的操作相當於在 key 元組中線性搜索到目標 key 的索引,然后用這個索引得到對應值的 Eterm,參見 beam_emu.c 中的 get_map_element() 函數。
根據之前的描述,更新操作分為兩種:=>操作符表示的更新和 := 操作符表示的更新。
先說 := 的更新,這個比較簡單,因為 key 都沒變。beam_emu.c 中的 update_map_exact() 函數進行這種更新。更新操作創建一個新的 map 是不可避免的。因此上圖中的數據結構要創建一個新的。其中所有的 value 除了被更新的那些都復制一遍。由於 key 都沒變,所以表示 key 的元組可以復用,keys 指針指向原來的元組即可。
=> 更新復雜一些,因為會改變 map 的結構,這個操作實現在 beam_emu.c 中的 update_map_assoc() 函數中。這個操作也很直觀,首先按最壞情況,即更新操作中所有的 key 都不是原來 map 中的 key 的情況,創建足夠的空間能完整保存兩個 kv 集合,內存布局如下所示:
也就是新創建了一個元組,然后把這個元組放在新創建的 map 上面了。boxed tuple pointer 就是指向這個 key 元組的指針。然后就是依次比較 key 並填充這個數據結構了。由於編譯器保證了老 key 序列和新 key 序列是有序的,所以這個操作復雜度是 O(num_of_oldkey + num_of_newkey)。
為了支持 map 的這些操作,編譯器可以將所有 map 操作歸結為以下 5 個操作:
- 154: put_map_assoc/5
- 155: put_map_exact/5
- 156: is_map/2
- 157: has_map_field/3
- 158: get_map_element/4
上面這 5 個條目實際上就是 5 條新的 beam 指令。put_map_assoc 對應的是 => 更新,參數中包含所有更新的 key 和 value。put_map_exact 對應的是 := 更新。is_map 是類型判斷,和 is_list is_tuple 之類的是一樣的,可以放在 guard 里的。has_map_field 判斷是否有字段,get_map_element 獲得字段映射的值。實際上我們在 Erlang 里面寫的那些狂拽炫酷的模式匹配,經過編譯器的處理之后,就是一堆這個存在不存在那個有沒有這個是不是等於那個的指令。
以上這些實際上只是一個起點,語言層面的變化還會引起其他層面的變化,比如最核心的是標准庫(stdlib)、類型分析器(dialyzer、hipe)、編譯器(compiler)、調試器(debugger)、對應的 BIF支持以及 NIF 支持等,然后就是幾個IDE都迅速跟進了,Erlide 和 Idea 的 Erlang 插件都支持,再就是各種書籍教程了。