檢視原始碼 映射 (Maps)

這份關於有效使用映射的指南,首先簡要說明了記錄 (records) 或映射之間的選擇,接著提供三個章節,針對如何將映射作為記錄、字典和集合的替代方案給予具體(但簡短)的建議。其餘章節則深入探討映射的實作方式、映射的語法,以及最後 maps 模組中的函式。

本章節使用的術語

  • 最多有 32 個元素的映射,將非正式地稱為小型映射
  • 超過 32 個元素的映射,將非正式地稱為大型映射

映射還是記錄?

如果遵循本章節中的建議,相較於使用小型映射代替記錄,記錄的效能預期會相似。因此,記錄和映射之間的選擇應基於資料結構的期望屬性,而不是效能。

相較於映射,記錄的優點是:

  • 如果記錄欄位的名稱拼寫錯誤,則會出現編譯錯誤。如果映射的鍵拼寫錯誤,編譯器不會給出任何警告,並且程式在執行時會以某種方式失敗。
  • 記錄會比映射使用稍微少的記憶體,並且在大多數情況下,效能預期會比映射稍微好一些。

相較於映射,記錄的缺點是,如果向記錄添加新的欄位,則必須重新編譯所有使用該記錄的程式碼。因此,建議僅在可以輕鬆一次重新編譯的程式碼單元內使用記錄,例如在單個應用程式或單個模組內。

使用映射作為記錄的替代方案

  • 請使用映射語法,而不是 maps 模組中的函式。

  • 避免在映射中擁有超過 32 個元素。一旦映射中超過 32 個元素,它將需要更多記憶體,並且鍵將無法再與映射的其他實例共享。

  • 建立新的映射時,請始終使用所有將要使用的鍵來建立它。為了最大化鍵的共享(從而最小化記憶體使用),請建立一個使用映射語法建構映射的單一函式,並始終使用它。

  • 請始終使用 := 運算子來更新映射(也就是說,要求具有該鍵的元素已經存在)。:= 運算子稍微有效率一些,並且有助於捕捉鍵的拼寫錯誤。

  • 盡可能一次匹配多個映射元素。

  • 盡可能一次更新多個映射元素。

  • 避免使用預設值和 maps:get/3 函式。如果有預設值,不同映射實例之間的鍵共享效果會降低,並且無法一次匹配具有預設值的多個元素。

  • 為了避免處理可能缺少某些鍵的映射,maps:merge/2 可以有效地添加多個預設值。例如:

    DefaultMap = #{shoe_size => 42, editor => emacs},
    MapWithDefaultsApplied = maps:merge(DefaultMap, OtherMap)

使用映射作為字典

將映射用作字典,意味著以下使用模式:

  • 鍵通常是在編譯時未知的變數。
  • 映射中可以有任意數量的元素。
  • 通常,一次只查找或更新一個元素。

鑑於這種使用模式,使用映射語法和 maps 模組之間的效能差異通常很小。因此,使用哪種方法主要取決於個人喜好。

映射通常是最有效的字典資料結構,但也有一些例外:

  • 如果需要經常將字典轉換為排序的列表,或從排序的列表轉換為字典,則使用 gb_trees 可能是一個更好的選擇。
  • 如果所有鍵都是非負整數,則 array 模組可能是一個更好的選擇。

使用映射作為集合

從 OTP 24 開始,sets 模組有一個選項,可以使用映射來表示集合。例如:

1> sets:new([{version,2}]).
#{}
2> sets:from_list([x,y,z], [{version,2}]).
#{x => [],y => [],z => []}

由映射支援的 sets 通常是最有效的集合表示法,但也有一些可能的例外:

  • ordsets:intersection/2 可能比 sets:intersection/2 更有效率。如果頻繁使用交集運算,並且避免在集合中對單個元素執行運算(例如 is_element/2),則 ordsets 可能比 sets 更好。
  • 如果頻繁使用交集運算,並且也必須高效地執行在集合中對單個元素進行的運算(例如 is_element/2),則 gb_sets 可能比 sets 更好。
  • 如果集合的元素是相對緊湊範圍內的整數,則可以將該集合表示為一個整數,其中每個位元代表集合中的一個元素。聯集運算使用 bor 執行,交集運算使用 band 執行。

映射的實作方式

在內部,映射根據映射中的元素數量,具有兩種不同的表示方式。當映射增長超過 32 個元素時,或縮小到 32 個元素或更少時,表示方式會改變。

  • 最多有 32 個元素的映射具有緊湊的表示方式,使其適合作為記錄的替代方案。
  • 超過 32 個元素的映射表示為樹狀結構,無論有多少元素,都可以有效地搜尋和更新。

小型映射的實作方式

小型映射在執行階段系統內部看起來像這樣:

0123N
FLATMAPN值 1...值 N

表格:小型映射的表示方式

  • FLATMAP - 小型映射的標籤(在執行階段系統的原始碼中稱為扁平映射)。

  • N - 映射中的元素數量。

  • - 包含映射鍵的元組:{Key1,...,KeyN}。鍵是經過排序的。

  • 值 1 - 對應於鍵元組中第一個鍵的值。

  • 值 N - 對應於鍵元組中最後一個鍵的值。

例如,讓我們看看映射 #{a => foo, z => bar} 如何表示:

01234
FLATMAP2{a,z}foobar

表格:#{a => foo, z => bar}

讓我們更新映射:M#{q => baz}。現在映射看起來像這樣:

012345
FLATMAP3{a,q,z}foofoobar

baz

表格:#{a => foo, q => baz, z => bar}

012345
FLATMAP3{a,q,z}foofoo最後,更改一個元素的值:M#{z := bird}。現在映射看起來像這樣:

bird

表格:#{a => foo, q => baz, z => bird}

new() ->
    #{a => default, b => default, c => default}.

當更新現有鍵的值時,不會更新鍵元組,從而允許鍵元組與其他具有相同鍵的映射實例共享。實際上,只要小心,鍵元組可以在所有具有相同鍵的映射之間共享。要安排這樣做,請定義一個傳回映射的函式。例如:

    (SOME_MODULE:new())#{a := 42}.

像這樣定義時,鍵元組 {a,b,c} 將是一個全域字面值。為確保在建立映射實例時共享鍵元組,請始終呼叫 new() 並修改傳回的映射:

將映射語法與小型映射一起使用特別有效率。只要鍵在編譯時已知,映射就會一次更新,使得更新映射的時間基本上是恆定的,而與更新的鍵數量無關。匹配也是如此。(當鍵是變數時,一個或多個鍵可能是相同的,因此需要從左到右依序執行操作。)

小型映射的記憶體大小是所有鍵和值的大小加上 5 個字組。有關記憶體大小的更多資訊,請參閱記憶體

大型映射的實作方式

超過 32 個元素的映射實作為 Hash array mapped trie (HAMT)。無論映射中有多少元素,都可以有效地搜尋和更新大型映射。

與小型映射相比,在大型映射上使用映射語法匹配或更新多個元素所能獲得的效能提升較少。執行時間大致與匹配或更新的元素數量成正比。

大型映射的儲存開銷高於小型映射。對於大型映射,除了鍵和值之外的額外字組數量大致與元素數量成正比。根據 記憶體 中的公式,對於具有 33 個元素的映射,開銷至少為 53 個堆積字組(相較之下,對於小型映射,無論元素數量多少,開銷都為 5 個額外字組)。

當更新大型映射時,更新後的映射和原始映射將共享 HAMT 的共同部分,但共享永遠不會像小型映射的鍵元組的最佳可能共享那樣有效。

因此,如果使用映射代替記錄,並且預計會建立許多映射實例,則從記憶體角度來看,避免使用大型映射(例如,將相關的映射元素分組到子映射中以減少元素數量)會更有效率。

使用映射語法

使用映射語法通常比使用 maps 模組中的對應函式稍微有效率。

  • 映射語法的效率提升在以下只能使用映射語法實現的操作中更加明顯:
  • 匹配多個字面值鍵
  • 更新多個字面值鍵

向映射添加多個字面值鍵

例如:

Map = Map1#{x := X, y := Y, z := Z}

執行

Map2 = maps:update(x, X, Map1),
Map3 = maps:update(y, Y, Map2),
Map = maps:update(z, Z, Map3)

不要執行

如果映射是小型映射,則第一個範例的執行速度大約快三倍。

Map = Map1#{Key1 := X, Key2 := Y, Key3 := Z}

請注意,對於變數鍵,元素會從左到右依序更新。例如,給定以下使用變數鍵的更新:

Map2 = Map1#{Key1 := X},
Map3 = Map2#{Key2 := Y},
Map = Map3#{Key3 := Z}

編譯器會像這樣重寫它,以確保更新是從左到右應用的:

如果已知鍵存在於映射中,則對於小型映射,使用 := 運算子會比使用 => 運算子稍微有效率一些。

使用 maps 模組中的函式

  • 以下是一些關於 maps 模組中大多數函式的注意事項。對於每個函式,都會說明實作語言(C 或 Erlang)。我們提到語言的原因是,它暗示了函式的效率如何。

  • 然而,有可能擊敗 Erlang 中實作的 maps 模組函式,因為它們的實作方式通常會嘗試讓效能在所有可能的輸入下都合理。

    例如,maps:map/2 會迭代遍歷 map 的所有元素,呼叫映射函數,將更新後的 map 元素收集到一個列表中,最後使用 maps:from_list/1 將列表轉換回 map。如果已知 map 中最多只有百分之一的值會改變,那麼只更新已變更的值可能會更有效率。

注意

本節提供的實作細節未來可能會變更。

maps:filter/2

maps:filter/2 是在 Erlang 中實作的。它使用 maps:from_list/1 建立一個新的 map。如果已知只有少數值會被移除,那麼避免使用 maps:filter/2,並編寫一個會使用 maps:remove/3 來移除不需要的值的函數可能會更有效率。

maps:filtermap/2

maps:filtermap/2 是在 Erlang 中實作的。它使用 maps:from_list/1 建立一個新的 map。請參閱 maps:map/2maps:filter/2 的注意事項,以取得如何實作更有效率版本的提示。

maps:find/2

maps:find/2 是在 C 中實作的。

使用 map 匹配語法而不是 maps:find/2 會稍微更有效率,因為這樣可以避免建立 {ok,Value} 元組。

maps:get/2

作為最佳化,編譯器會將對 maps:get/2 的呼叫重寫為對 guard BIF map_get/2 的呼叫。呼叫 guard BIF 比呼叫其他 BIF 更有效率,使得效能與使用 map 匹配語法相似。

如果 map 很小且鍵是在編譯時已知的常數,則使用 map 匹配語法會比多次呼叫 maps:get/2 更有效率。

maps:get/3

作為最佳化,編譯器會將對 maps:get/3 的呼叫重寫為類似以下的 Erlang 程式碼

Result = case Map of
             #{Key := Value} -> Value;
             #{} -> Default
         end

這相當有效率,但如果使用小 map 來替代使用記錄,通常最好不要依賴預設值,因為它會阻止鍵的共享,最終可能使用比您從不在 map 中儲存預設值所節省的更多的記憶體。

如果仍然需要預設值,請考慮將預設值放入 map 中,並將該 map 與另一個 map 合併,而不是多次呼叫 maps:get/3

DefaultMap = #{Key1 => Value2, Key2 => Value2, ..., KeyN => ValueN},
MapWithDefaultsApplied = maps:merge(DefaultMap, OtherMap)

這有助於在預設 map 和您套用預設值的 map 之間共享鍵,只要預設 map 包含所有將會使用的鍵,而不僅僅是具有預設值的鍵。這是否比多次呼叫 maps:get/3 更快,取決於 map 的大小和預設值的數量。

變更

在 OTP 26.0 之前,maps:get/3 是透過呼叫函數來實作,而不是將其重寫為 Erlang 表達式。現在它速度稍快,但不再能被追蹤。

maps:intersect/2, maps:intersect_with/3

maps:intersect/2maps:intersect_with/3 是在 Erlang 中實作的。它們都使用 maps:from_list/1 建立新的 map。

注意

map 通常是實作集合的最有效方式,但例外情況是交集運算,其中在 ordsets 上使用的 ordsets:intersection/2 可能比在以 map 實作的集合上使用 maps:intersect/2 更有效率。

maps:from_list/1

maps:from_list/1 是在 C 中實作的。

maps:from_keys/2

maps:from_keys/2 是在 C 中實作的。

maps:is_key/2

作為最佳化,編譯器會將對 maps:is_key/2 的呼叫重寫為對 guard BIF is_map_key/2 的呼叫。呼叫 guard BIF 比呼叫其他 BIF 更有效率,使得效能與使用 map 匹配語法相似。

maps:iterator/1

maps:iterator/1 是在 C 和 Erlang 中有效率地實作的。

maps:keys/1

maps:keys/1 是在 C 中實作的。如果需要對結果列表進行排序,請使用 lists:sort/1 對結果進行排序。

maps:map/2

maps:map/2 是在 Erlang 中實作的。它使用 maps:from_list/1 建立新的 map。如果已知只有少數值會被更新,那麼避免使用 maps:map/2,並編寫一個會呼叫 maps:update/3 來僅更新已變更值的函數可能會更有效率。

maps:merge/2

maps:merge/2 是在 C 中實作的。對於小 map,如果該參數 map 包含所有鍵,則鍵元組可以與任何參數 map 共用。如果可能,優先使用文字鍵元組。

變更

maps:merge/2 共享鍵元組的功能是在 OTP 26.0 中引入的。舊版本總是在呼叫者的堆疊上建立一個新的鍵元組。

maps:merge_with/3

maps:merge_with/3 是在 Erlang 中實作的。它會更新並傳回兩個 map 中較大的那個。

maps:new/0

編譯器會將對 maps:new/0 的呼叫重寫為使用語法 #{} 來建立一個空的 map。

maps:next/1

maps:next/1 是在 C 和 Erlang 中有效率地實作的。

maps:put/3

maps:put/3 是在 C 中實作的。

如果已知鍵已經存在於 map 中,則 maps:update/3maps:put/3 稍微有效率。

如果鍵是在編譯時已知的常數,則使用帶有 => 運算符的 map 更新語法比多次呼叫 maps:put/3 更有效率,尤其對於小 map 而言。

maps:remove/2

maps:remove/2 是在 C 中實作的。

maps:size/1

作為最佳化,編譯器會將對 maps:size/1 的呼叫重寫為對 guard BIF map_size/1 的呼叫。呼叫 guard BIF 比呼叫其他 BIF 更有效率。

maps:take/2

maps:take/2 是在 C 中實作的。

maps:to_list/1

maps:to_list/1 是在 C 和 Erlang 中有效率地實作的。如果需要對結果列表進行排序,請使用 lists:sort/1 對結果進行排序。

注意

map 通常比 gb_trees 更有效能,但如果需要頻繁地轉換為排序列表和從排序列表轉換,則 gb_trees 可能是更好的選擇。

maps:update/3

maps:update/3 是在 C 中實作的。

如果鍵是在編譯時已知的常數,則使用帶有 := 運算符的 map 更新語法比多次呼叫 maps:update/3 更有效率,尤其對於小 map 而言。

maps:values/1

maps:values/1 是在 C 中實作的。

maps:with/2

maps:with/2 是在 Erlang 中實作的。它使用 maps:from_list/1 建立新的 map。

maps:without/2

maps:without/2 是在 Erlang 中實作的。它會傳回輸入 map 的修改後的副本。