後記:映射

關於本章

喔嗨。好久不見了。這是一個附加章節,尚未收錄在《學 Erlang 讓你受益良多!》的印刷版中。為什麼會有這個章節呢?是因為印刷版包含大量錯誤資訊嗎?我希望不是(不過您可以參考勘誤表來看看它錯了多少)。

本章的原因是,Erlang 的資料類型在 R17 版本中又增加了一點,而為了納入這個新類型而重新調整整個內容會很麻煩。R17 版本包含許多變更,是幾年來最大的一次。事實上,R17 版本應該稱為 17.0 版本,因為 Erlang/OTP 當時開始將他們的 R<主版本號>B<次版本號> 版本控制方案更改為不同的版本控制方案

無論如何,就像之前大多數語言更新一樣,我在網站上這裡那裡添加了一些註釋和內容,以保持所有內容都是最新的。然而,新增的資料類型本身就值得一個完整的章節。

A weird road-runner representing EEP-43

EEP,EEP!

將映射加入語言的路途漫長而曲折。多年來,Erlang 社群中不斷有人抱怨記錄和鍵/值資料結構的許多問題

最後出現了兩個競爭提案。第一個是Richard O'Keefe 的 frames,這是一個關於如何取代記錄的深入思考提案,以及 Joe Armstrong 的 Structs(O'Keefe 的提案簡要描述了它們,並將它們與 frames 進行了比較)。

後來,OTP 團隊提出了Erlang 增強提案 43 (EEP-43),他們自己對「類似」資料類型的提案。然而,就像社群的抱怨一樣,這兩個提案處理的是完全不同的問題:映射旨在成為非常靈活的雜湊映射,以取代 dictgb_trees,而 frames 旨在取代記錄。映射在理想情況下也能在某些用例中取代記錄,但不是全部,同時保留記錄的正面特性(我們將在墨西哥僵局中看到)。

映射是 OTP 團隊選擇實作的提案,而 frames 仍以它們自己的提案的形式存在於某處。

映射的提案很全面,涵蓋了許多情況,並強調了在嘗試讓它們適應語言時的許多設計問題,因此,它們的實作將會是漸進式的。規範在映射應該是什麼中描述,而臨時實作在早期版本的短腿中描述。

映射應該是什麼

模組

映射是一種資料類型,其意圖類似於 dict 資料結構,並且已經給予一個具有類似介面和語義的模組。支援以下操作

它還包含一些良好的舊函數式標準,例如 fold/3map/2,它們的功能類似於 lists 模組中的函數。然後是一組實用函數,例如 size/1is_map/1 (也是一個 guard!)、from_list/1to_list/1keys/1values/1,以及一些集合函數,例如 is_key/2merge/2,用於測試成員資格和融合映射。

這並不是很令人興奮,使用者想要更多!

語法

儘管承諾了速度上的提升,但映射最令人期待的方面是它們的原生語法。以下是與其等效模組呼叫相比的不同操作

映射模組 映射語法
maps:new/1 #{}
maps:put/3 Map#{Key => Val}
maps:update/3 Map#{Key := Val}
maps:get/2 Map#{Key}
maps:find/2 #{Key := Val} = Map

請記住,並非所有這些語法都已實作。幸運的是,對於映射使用者來說,模式匹配選項比這更廣泛。可以一次從映射中匹配多個項目

1> Pets = #{"dog" => "winston", "fish" => "mrs.blub"}.
#{"dog" => "winston","fish" => "mrs.blub"}
2> #{"fish" := CatName, "dog" := DogName} = Pets.
#{"dog" => "winston","fish" => "mrs.blub"}

在這裡,可以一次獲取任意數量的項目內容,而無需考慮鍵的順序。您會注意到,元素使用 => 設定,並使用 := 匹配。:= 運算子也可以用於更新映射中現有的

3> Pets#{"dog" := "chester"}.
#{"dog" => "chester","fish" => "mrs.blub"}
4> Pets#{dog := "chester"}.
** exception error: bad argument
     in function  maps:update/3
        called as maps:update(dog,"chester",#{"dog" => "winston","fish" => "mrs.blub"})
     in call from erl_eval:'-expr/5-fun-0-'/2 (erl_eval.erl, line 257)
     in call from lists:foldl/3 (lists.erl, line 1248)

規範中還有更多的匹配,儘管它在 17.0 中還不可用

5> #{"favorite" := Animal, Animal := Name} = Pets#{"favorite" := "dog"}.
#{"dog" => "winston","favorite" => "dog","fish" => "mrs.blub"}
6> Name.
"winston"

在同一個模式中,可以使用已知鍵的值來定義一個變數 (Animal),它可以作為另一個鍵使用,然後使用該另一個鍵來匹配所需的值 (Name)。這種模式的限制是不能有循環。例如,匹配 #{X := Y, Y := X} = Map 無法完成,因為需要知道 Y 才能匹配 X,並且需要知道 X 才能綁定 Y。您也不能按值匹配鍵 (#{X := val} = Map),因為可能有多個鍵具有相同的值。

注意: 存取單個值的語法 (Map#{Key}) 在 EEP 中有說明,但在未來實作後可能會更改,並且可能會完全放棄,轉而採用不同的解決方案。

還有一些有趣但無關的事情是隨著映射一起新增的。如果您還記得在從實際開始中,我們介紹了列表理解

7> Weather = [{toronto, rain}, {montreal, storms}, {london, fog},   
7>            {paris, sun}, {boston, fog}, {vancouver, snow}].
[{toronto,rain},
 {montreal,storms},
 {london,fog},
 {paris,sun},
 {boston,fog},
 {vancouver,snow}]
8> FoggyPlaces = [X || {X, fog} <- Weather].
[london,boston]

一旦可以使用,也可以使用映射理解來完成相同的事情

9> Weather = #{toronto => rain, montreal => storms, london => fog,
9>             paris => sun, boston => fog, vancouver => snow}.
#{boston => fog,
  london => fog,
  montreal => storms,
  paris => sun,
  toronto => rain,
  vancouver => snow}
10> FoggyPlaces = [X || X := fog <- Weather].
[london,boston]

在這裡,X := fog <- Weather 表示一個映射產生器,其形式為 Key := Val <- Map。它可以像列表產生器和二進位產生器一樣被組合和取代。映射理解也可以產生新的映射

11> #{X => foggy || X <- [london,boston]}.
#{boston => foggy, london => foggy}

或從 maps 模組本身實作映射操作

map(F, Map) ->
    #{K => F(V) || K := V <- Map}.

這就是大部分內容!看到這種新的資料類型加入 Erlang 大家庭真是令人耳目一新,希望使用者會喜歡它。

A bee looking over a map

詳細資訊

詳細資訊並沒有那麼複雜,只是一些細節。在語言中包含映射會影響一些事情。EEP-43 詳細說明了潛在的變更,其中許多變更仍然不明確,例如分散式 Erlang 協議、運算子優先級、向後相容性以及對 Dialyzer 擴充的建議(目前還不清楚支援是否會像 EEP 建議的那樣廣泛)。

許多變更都是在不需要使用者過度思考的情況下提出的。然而,有一個是不可避免的,那就是排序。先前在書中,排序順序被定義為

number < atom < reference < fun < port < pid < tuple < list < bit string

映射現在也適用於此

number < atom < reference < fun < port < pid < tuple < map < list < bit string

大於元組且小於列表。有趣的是,映射可以根據它們的鍵和值相互比較

2> lists:sort([#{ 1 => 2, 3 => 4}, #{2 => 1}, #{2 => 0, 1 => 4}]).
[#{2 => 1},#{1 => 4,2 => 0},#{1 => 2,3 => 4}]

排序的執行方式與列表和元組類似:首先按大小排序,然後按包含的元素排序。在映射的情況下,這些元素是按鍵的排序順序排列,並使用值本身來打破平局。

不要喝太多酷樂
您可能會注意到,雖然我們無法使用鍵 1 來更新映射的鍵 1.0,但它們可以比較為相等!這是 Erlang 長期存在的問題之一。雖然讓所有數字不時進行相等比較非常方便,例如,在排序列表時,使得 1.0 不大於 9121,但這在模式匹配時會產生混淆的預期。

例如,雖然我們可以預期 1 等於 1.0(儘管不是嚴格相等,如 =:=),但我們不能期望透過執行 1 = 1.0 來進行模式匹配。

在映射的情況下,這表示 Map1 == Map2 並不是 Map1 = Map2 的同義詞。因為 Erlang 映射尊重 Erlang 排序順序,所以像 #{1.0 => true} 這樣的映射將與 #{1 => true} 比較為相等,但是您將無法將它們彼此進行匹配。

請注意,儘管本節內容是根據 EEP-43 編寫的,但實際的實作可能有所落後!

早期版本的短腿

A Corgi with a cape, flying

Erlang 17.x 和 18.0 附帶的 maps 實作是完整的,但僅限於 maps 模組的範圍內。主要差異來自語法。只有最小的子集可用:

其餘的,包括存取單個值 (Map#{key}),無論是在匹配還是宣告中,都還沒有實作。map 推導式和 Dialyzer 支援也是如此。事實上,語法最終可能會改變,並與 EEP 不同。

Maps 的速度也仍然比大多數 Erlang 開發人員和 OTP 團隊希望的慢。儘管如此,進展仍在持續進行中,而且應該不需要太久 — 特別是與將 map 加入到語言中之前所花的時間相比 — 它們就會變得更好。

這個早期版本仍然足以讓您熟悉 map,更重要的是,了解何時何地使用它們。

墨西哥僵局

每個人都對優先需要原生字典還是更好的記錄取代方案有自己的看法。當 map 被宣布時,許多 Erlang 開發人員幾乎都認為當 map 加入到語言時,它們會解決他們想要解決的問題。

因此,關於如何在 Erlang 中使用 map 存在一些混淆。

Maps vs. Records vs. Dicts

直接說明,maps 是 dicts 的替代品,而不是 records。這可能會讓人困惑。在本章的開頭,我指出了一些常見的抱怨,結果發現許多關於 records 的抱怨都可以透過 maps 解決

Maps 解決的問題 Records 中的問題 Dicts 中的問題
Record 名稱很繁瑣  
沒有好的原生鍵/值儲存  
更快的鍵/值儲存   18.x 及更高版本
使用原生型別更容易轉換
更強大的模式匹配  
Records 的升級 可能  
可以在沒有 include 的情況下跨模組使用  
squid aiming two guns mexican-standoff-like, while eating a taco

分數非常接近。map 更快這一點不一定是真的,但最佳化應該會將它們提升到更好的水準。OTP 團隊正在遵守舊的口號:先讓它工作,然後讓它美觀,只有在需要時才讓它快速。他們首先要解決的是語意和正確性。

對於 records 的升級被標記為「可能」,這與 code_change 功能有關。許多使用者對於在更改版本時必須公開轉換 records 感到惱火,這與我們對 pq_player.erl 及其升級程式碼所做的事情類似。而 map 可以讓我們根據需要在條目中新增欄位並繼續執行。反駁的論點是,搞砸的升級會使 records 提早崩潰(這是好事!),而搞砸的升級可能會使 map 仍然存在,導致資料損壞,直到為時已晚才顯示出來。

那麼,為什麼我們應該將 map 用作 dicts 而不是 records 呢?為此,需要第二個表格。這個表格是關於語意,以及哪個資料結構或資料類型適用於哪個功能:

操作 Records Maps Dict
不可變
任何型別的鍵
可與 maps/folds 一起使用
內容對其他模組不透明
有模組可以使用它
支援模式匹配
所有鍵在編譯時已知
可以與其他實例合併
測試鍵是否存在
按鍵提取值
每個鍵的 Dialyzer 型別檢查*
從/到清單的轉換
每個元素的預設值
執行時的獨立資料類型
快速直接索引存取

* EEP 建議使編譯時已知的鍵可以使用此功能,但尚未確定何時或是否會發生。

這個圖表清楚地表明,儘管 map 和 records 在語法上相似,但 dicts 和 map 在語意上比 map 和 records 更接近。因此,使用 map 取代 records 類似於試圖用雜湊 map 取代像 C 語言中的「structs」。這並非不可能,但這並不表示這是一個好主意,因為它們通常有不同的用途。

在編譯時知道鍵可以為存取特定值帶來快速存取(比動態存取更快)、額外的安全性(提早崩潰而不是損壞狀態)和更容易的型別檢查等優勢。這些使得 records 非常適合用於程序的內部狀態,儘管編寫更詳細的 code_change 函式有時會帶來負擔。

另一方面,當 Erlang 使用者會使用 records 來表示複雜的巢狀鍵/值資料結構(與物件導向語言中的物件奇怪地相似),並且這些結構會頻繁地跨越模組邊界時,map 將會提供很大的幫助。Records 是不適合這項工作的工具。

簡而言之,records 確實讓人感到格格不入且麻煩的地方可以被 map 取代,但大多數 record 的使用方式不應該被取代。

不要喝太多迷魂湯
通常很誘人地將複雜的巢狀資料結構帶入,並將它們用作一個大的透明物件,在函式或程序中傳遞,然後使用模式匹配來提取您需要的內容。

但是,請記住,在訊息傳遞中執行這些操作是有代價的:Erlang 資料會在程序之間複製,而這樣做可能會很昂貴。

同樣地,在函式中傳遞大型透明物件也應謹慎。建立合理的介面 (或 API) 已經很困難;如果您將自己與內部資料表示法結合在一起,則實作的靈活性可能會大大喪失。

最好仔細考慮您的介面和基於訊息的協定,並限制共享的資訊和責任量。Maps 是 dicts 的替代品,而不是取代適當的設計。

預計也會有效能影響。Richard O'Keefe 在他的提案中提到了這一點:

您不能使 dict 適合框架預期的類似 record 的用途,而不會使它對現有用途不利。

來自 OTP 團隊的 EEP 也提到了類似的事情:

當將 map 與 records 比較時,map 很容易補救缺點,但是[原文如此]積極影響不容易在內建資料類型中複製,因為內建資料類型的值是在執行時而不是在編譯時確定的。

  • 比直接索引陣列更快是困難的,因為陣列的索引以及可能產生的值都是在編譯時確定的。事實上,這是辦不到的。
  • 可以透過使用兩個元組來實現 map 的記憶體模型,其中一個用於鍵,一個用於值,如框架中所示,從而使效率接近 records。這會影響具有大量條目的 map 的更新效能,從而限制了字典方法的能力。

對於您的程序迴圈的核心,當您知道所有應該存在的鍵時,從效能角度來看,records 將是一個明智的選擇。

Maps vs. Proplists

map 可能會勝過 proplists 的一個地方。Proplist 是一個相當簡單的資料結構,非常適合傳遞給模組的選項。

inet:setopts/2 呼叫可以接受形式為 [{active, true}, binary] 的選項清單,而檔案處理函式可以接受諸如 [read, write, append, {encoding, utf8}] 之類的引數清單。這兩種選項清單都可以使用 proplists 模組讀取,並且諸如 write 之類的詞彙將會被展開,就像它們被寫成 {write, true} 一樣。

a scale measuring proplists vs. maps

map,藉由它們的單鍵存取(無論何時實作),將代表定義屬性的類似方式。例如,[{active, true}] 可以用 map 表示為 #{active => true}。這同樣繁瑣,但它會使讀取選項變得簡單得多,因為您不需要進行模組呼叫(感謝 Opts#{active})。

某種程度上可以預期,主要為配對的選項清單將會被 map 取代。另一方面,從使用者的角度來看,諸如 readwriteappend 之類的文字選項將使用 proplists 仍然會更好。

鑑於目前大多數需要選項的函式都使用 proplists,為了保持一致性,繼續這樣做可能會很有趣。另一方面,當選項主要為配對時,使用 proplists 模組可能會很快變得繁瑣。最終,程式庫的作者將必須決定實作的內部清晰性還是通用生態系統的一致性。或者,作者可以決定同時支援這兩種方法,以便為棄用 proplists 提供一個優雅的途徑,如果他們對此有任何想法。

另一方面,過去將 proplists 作為函式傳回值的函式可能應該切換到 map。那裡的遺留問題不多,而且這樣做應該會讓使用者更容易使用。

注意:map 可以使用一個巧妙的技巧來輕鬆地一次設定許多預設值。雖然 proplists 需要使用 proplists:get_value(Key, List, Default),但 map 可以使用其 merge/2 函式。

根據規範,merge/2 會將兩個 map 合併在一起,如果兩個鍵相同,則第二個 map 的值將會優先。這允許呼叫 maps:merge(Default, YourMap) 並取得所需的值。例如,maps:merge(#{key => default, other => ok}, #{other => 42}) 將會產生 map #{key => default, other => 42}

這提供了一個極其方便的方式來手動設定預設值,然後您可以使用結果 map,而無需擔心缺少鍵。

本書如何為 map 修訂

我想新增這個章節,因為我目前沒有時間全面更新本書,以便在此時回溯 map。

但是,對於本書的大部分內容,變化不大。主要是將對 dict 模組和 gb_trees 的呼叫(在不常需要最小/最大值的情況下)替換為內嵌的 map 語法。本書中使用的 records 幾乎沒有任何變動,因為這是為了語意的緣故。

一旦 map 穩定且完全實作後,我可能會重新檢視一些模組,但同時,考慮到 map 的部分實作,許多範例以這種方式編寫是不切實際的。