作者
Björn-Egil Dahlberg <egil(at)Erlang.org>
狀態
最終版/17.0 實作於 OTP 17 版本
類型
標準追蹤
建立日期
2013-04-04
Erlang 版本
OTP-17.0

EEP 43:映射 (Maps) #

摘要 #

映射 (Maps) 和此 EEP 的發展歷程漫長,而且絕非一帆風順。當我們約莫兩三年前開始在 OTP 內部討論時,我對於我希望映射 (Maps) 成為什麼樣子有著非常清晰的藍圖。此 EEP 與那個願景相似,但也吸收了來自 OTP 內部和外部的許多其他想法。

這個想法是一種資料類型,一種語法感知的鍵值關聯映射,並具有模式匹配的功能。其語法類似於記錄 (records),但沒有編譯時依賴性的麻煩,並且可以使用任意的 Term 作為鍵。順序並不重要,並且可以使用雜湊陣列映射樹 (Hash-Array-Mapped-Trie) 來實現,具有良好的效能和記憶體權衡。這是一種與取代記錄 (records) 不同的方法。它旨在取代適用的記錄 (records),在其他方面則不作為取代,而是其獨特的「事物」。

從社群中,有許多關於類似映射 (Map) 資料類型的需求和一些建議。其中一個突出的建議當然是 Richard O’Keefe 的 Frames 提案。這是我見過最完整的提案,而且經過深思熟慮。它的目標是取代記錄 (records),並且該提案很好地實現了這個目標。

  • 如果 Frames 如此優秀,為什麼還要提出單獨的 EEP?
  • 歸結於目標和限制。

取代記錄 (records) 顧名思義就是取代。這就像在問「我們擁有什麼?」而不是「我們可以獲得什麼?」立即的反駁將會是「我們需要什麼?」我說是映射 (Maps)。

Frames 確實啟發並影響了映射 (Maps)。在許多方面,映射 (Maps) 也涵蓋了 Frames,但映射 (Maps) 試圖做更多的事情。最終,我相信它們是兩個不同的事物,並且有不同的目標。

此 EEP 建議為 Erlang 新增一種內建資料類型,即映射 (map),#{ Key => Value }

新的資料類型應具有語意、語法和操作,這些語意、語法和操作:

  • 提供一組從鍵 Term 到值 Term 的關聯,可以使用語言語法來建構、存取和更新
  • 可以在語言中與其他所有資料類型唯一區分
  • 對於建構、存取或更新映射 (maps) 的內容,或是在模組、程序之間或透過 Erlang 分散式傳遞映射 (maps) 時,沒有編譯時依賴性
  • 可以在語言的匹配運算式中使用
  • 在資料類型的列印和解析之間具有一對一的關聯
  • 在類型 Term 和其他 Erlang 類型之間具有明確的順序
  • 在插入和查找操作中,時間複雜度至多為 O(log N),其中 N 是鍵值關聯的數量。

其他語言中存在類似的資料類型,例如 perl 雜湊ruby 雜湊python 字典scala 映射

規格 #

映射 (map) M 由多個關聯組成,並保留從鍵 Term K1..Kn 到值 Term V1..Vn 的關聯,其中沒有兩個鍵匹配。任何 Term,複合或其他,都是可行的鍵或值。類型為 Map 的 Term 可透過 guard 測試 erlang:is_map/1 來識別。沒有對映射 (maps) 進行操作的運算符。在映射 (maps) 內,有兩個中綴運算符。關聯運算符 => 將鍵與值配對,用於建立和更新。設定值運算符 := 用於更新現有且匹配的鍵上的值。設定值運算符也用於匹配,以從鍵獲取關聯的值。

術語 #

映射 (map) 的大小是其集合中關聯的數量。

關聯是映射 (Map) 中鍵 K 到值 V 的鍵值對。

如果 true = K1 =:= K2,則兩個鍵 K1K2匹配的。

語法 #

為宣告、更新和匹配映射 (maps) 定義的語法。

建構語法 #

建構新的映射 (map) 是透過讓運算式 K 與另一個運算式 V 相關聯來完成的

#{ K => V }

新的映射 (maps) 可以透過列出每個關聯來在建構時包含多個關聯

#{ K1 => V1, .. Kn => Vn }

空映射 (map) 是透過不將任何 Term 相互關聯來建構的

#{}

映射 (map) 中的所有鍵和值都是 Term。任何運算式都會先進行評估,然後將產生的 Term 分別用作

鍵和值以 => 箭頭分隔,關聯以 , 分隔。

範例

M0 = #{},                   % empty map
M1 = #{ a => <<"hello">> }, % single association with literals
M2 = #{ 1 => 2, b => b },   % multiple associations with literals
M3 = #{ A => B },           % single association with variables
M4 = #{ {A, B} => f() }.    % compound key associated to an evaluated expression

其中,AB 是任何運算式,而 M0M4 是產生的映射 (map) Term。

如果宣告了兩個匹配的鍵,則後一個鍵將優先。

範例

1> #{ 1 => a, 1 => b }.
#{ 1 => b }
2> #{ 1.0 => a, 1 => b }.
#{ 1 => b, 1.0 => a }

評估建構鍵及其關聯值的運算式順序未定義。建構中鍵值對的語法順序無關緊要,除非在上述兩個匹配鍵的情況下。

以下是建構的簡單 BNF 文法

 <map-construct>  ::= '#{' <key-value-exprs> '}'
<key-value-exprs> ::= /* empty */
                    | <key-value-list>
 <key-value-list> ::= <key-value-assoc>
                    | <key-value-assoc> ',' <key-value-list>
<key-value-assoc> ::= <expr> '=>' <expr>
           <expr> ::= <Erlang-expression>

更新語法 #

更新映射 (map) 的語法與建構語法類似。

定義要更新的映射 (map) 的運算式放在定義要更新的鍵及其各自的值的運算式前面。

M#{ K => V }

其中 M 是類型為映射 (map) 的 Term,而 KV 是任何運算式。

如果鍵 K 與映射 (map) 中任何現有的鍵不匹配,則將建立一個從鍵 K 到值 V 的新關聯。如果鍵 K 與映射 (map) M 中的現有鍵匹配,則其關聯的值將被新值 V 取代。在這兩種情況下,評估的映射 (map) 運算式都將傳回新的映射 (map)。

如果 M 不是映射 (map) 類型,則會拋出 badmap 類型的例外狀況。

若僅更新現有值,請使用以下語法:

M#{ K := V }

其中 M 是類型為映射 (map) 的 Term,V 是一個運算式,而 K 是一個評估為 M 中現有鍵的運算式。

如果鍵 K 與映射 (map) M 中的任何現有鍵不匹配,則會在執行時觸發 badarg 類型的例外狀況。如果映射 (map) M 中存在匹配的鍵 K,則其關聯的值將被新值 V 取代,並且評估的映射 (map) 運算式會傳回新的映射 (map)。

如果 M 不是映射 (map) 類型,則會拋出 badmap 類型的例外狀況。

範例

M0 = #{},
M1 = M0#{ a => 0 },
M2 = M1#{ a => 1, b => 2 },
M3 = M2#{ "function" => fun() -> f() end },
M4 = M3#{ a := 2, b := 3 }.  % 'a' and 'b' was added in `M1` and `M2`.

其中 M0 是任何映射 (map)。由此可知,M1 .. M4 也是映射 (maps)。

更多範例

1> M = #{ 1 => a }.
#{ 1 => a }

2> M#{ 1.0 => b }.
#{ 1 => a, 1.0 => b }.

3> M#{ 1 := b }.
#{ 1 => b }

4> M#{ 1.0 := b }.
** exception error: bad argument

與建構中一樣,評估鍵和值運算式的順序未定義。更新中鍵值對的語法順序無關緊要,除非在兩個鍵匹配的情況下,在這種情況下會使用後一個值。

以下是映射 (map) 更新的簡單 BNF 文法

 <map-construct>  ::= <map-expr> '#{' <key-value-exprs> '}'
<key-value-exprs> ::= /* empty */
                    | <key-value-list>
 <key-value-list> ::= <key-value>
                    | <key-value> ',' <key-value-list>
      <key-value> ::= <key-value-assoc>
                    | <key-value-exact>
<key-value-assoc> ::= <expr> '=>' <expr>
<key-value-exact> ::= <expr> ':=' <expr>
       <map-expr> ::= <Erlang expression evaluating to a term of type map>
           <expr> ::= <Erlang expression>

存取單一值 #

為了存取映射 (maps) 中的單一值,我們使用一個反關聯

V = M#{ K }.

其中 M 是映射 (Map),而 K 是任何 Term。

如果鍵 K 與映射 (map) M 中的現有鍵匹配,則關聯的值將繫結到 V。如果鍵 K 與映射 (map) M 中的任何現有鍵不匹配,則在執行時會發生 badarg 例外狀況。

範例

M1 = #{ a => 1, c => 3 },
3 = M1#{ c }.

M2 = #{ 1.0 => a },
a = M2#{ 1 }.

匹配語法 #

從映射 (maps) 匹配鍵值關聯,並以匹配運算符為例,以以下方式完成

#{ K := V } = M

其中 M 是任何映射 (map)。鍵 K 必須是一個帶有繫結變數或常值的運算式,而 V 可以是任何帶有繫結或未繫結變數的模式。如果 V 中的變數未繫結,則它將繫結到與鍵 K 關聯的值,該值必須存在於映射 (map) M 中。如果 V 中的變數已繫結,則它必須與 M 中與 K 關聯的值匹配。

範例

M = #{ a => {1,2}},
#{ a := {1,B}} = M.

這將把變數 B 繫結到整數 2

同樣,可以匹配映射 (map) 中的多個值

#{ K1 := V1, .., Kn := Vn } = M

其中鍵 K1 .. Kn 是任何帶有常值或繫結變數的運算式。如果所有鍵都存在於映射 (map) M 中,則 V1 .. Vn 中的所有變數都將與各自鍵的關聯值匹配。

如果匹配條件不符,則匹配將失敗,並出現以下情況:

  1. 如果像範例中那樣在匹配運算符的上下文中使用的話,則會產生 badmatch 例外狀況,
  2. 或是導致在函數標頭和 case 運算式中測試下一個子句。

映射 (maps) 中的匹配只允許使用 := 作為關聯的分隔符。

在匹配中宣告鍵的順序無關緊要。

在匹配中允許重複的鍵,並將匹配與鍵相關聯的每個模式。

#{ K := V1, K := V2 } = M

將運算式與空的映射 (map) 常值進行匹配將匹配其類型,但不繫結任何變數

#{} = Expr

如果運算式 Expr 的類型為映射 (map),則此運算式將匹配,否則將因 badmatch 例外狀況而失敗。

匹配語法的文法與建構語法的文法相似。

匹配語法:函數標頭中使用常值的範例 #

函數標頭中允許將常值作為鍵進行匹配。

%% only start if not_started
handle_call(start, From, #{ state := not_started } = S) ->
...
    {reply, ok, S#{ state := start }};

%% only change if started
handle_call(change, From, #{ state := start } = S) ->
...
    {reply, ok, S#{ state := changed }};

匹配語法:頻率範例 #

更多匹配語法,計算列表中 Term 的頻率。

freq(Is)                    -> freq(Is, #{}).
freq([I|Is], #{I := C} = M) -> freq(Is, M#{ I := C + 1});
freq([I|Is], M)             -> freq(Is, M#{ I => 1 });
freq([], M)                 -> maps:to_list(M).

用於比較的 gb_trees 的等效程式碼

freq(Is)        -> freq(Is, gb_trees:empty()).
freq([I|Is], T) ->
    case gb_trees:lookup(I, T) of
        none       -> freq(Is, gb_trees:enter(I, 1), T);
        {value, V} -> freq(Is, gb_trees:enter(I, V + 1, T))
    end;
freq([], T) -> gb_trees:to_list(T).

匹配語法:檔案資訊範例 #

可以改進舊的 API 以使用映射 (map) 語法

1> {ok, #{ type := Type, mtime := Mtime }} = file:read_file_info(File).
2> io:format("type: ~p, mtime: ~p~n", [Type, Mtime]).
type: regular, mtime: {{2012,7,18},{19,59,18}}
ok
3>

映射 (map) 推導式語法 #

映射 (map) 推導式宣告

M1 = #{ E0 => E1 || K := V <- M0  }

其中 M0 是任何映射 (Map),E0E1 是任何 erlang 運算式,KV 構成要由 M0 中每個關聯匹配的模式。

對於產生器中的每個序列,都會從評估的運算式 E0 建立到評估的運算式 E1 的關聯。

如果 M0 不是映射 (Map),則會產生類型為 {bad_generator, M0} 的執行時例外狀況。

以下是映射 (map) 推導式的簡單 BNF 文法

      <comprehension> ::= '#{' <key-value-assoc> '||' <comprehension-exprs> '}'
<comprehension-exprs> ::= <comprehension-expr>
                        | <comprehension-exprs> ',' <comprehension-expr>
 <comprehension-expr> ::= <generator>
                        | <filter>
          <generator> ::= <key-value-exact> '<-' <expr>
             <filter> ::= <expr>
    <key-value-assoc> ::= <expr> '=>' <expr>
    <key-value-exact> ::= <expr> ':=' <expr>
               <expr> ::= <Erlang expression>

來自所有產生器的每個關聯(滿足篩選器)都具有一個環境,該環境由初始環境和關聯的環境組成。

範例

M0 = #{ K => V*2  || K := V <- map() },
M1 = #{ I => f(I) || I <- list() },
M2 = #{ K => V    || <<L:8,K:L/binary,V/float>> <= binary() }.

映射 (map) 產生器也可以在二進制和列表推導式中使用。

範例

B1 = << <<V:8>> || _ := V <- map() >>,
L1 = [ {K,V} || K := V <- map() ].

Dialyzer 和類型規格 #

可以為映射 (map) 直接且唯一地指定事先知道的鍵。

-spec func(Opt, M) -> #{ 'status' => S, 'c' => integer() } when
      Opt :: 'inc' | 'dec',
        M :: #{ 'status' => S, 'c' => integer() },
        S :: 'update' | 'keep'.

func(inc, #{ status := update, c := C} = M) -> M#{ c := C + 1};
func(dec, #{ status := update, c := C} = M) -> M#{ c := C - 1};
func(_,   #{ status := keep } = M)          -> M.

也可以僅透過類型指定。

-spec plist_to_map(Ls) -> #{ binary() => integer() } when
      Ls :: [{binary(), integer()}].

plist_to_map([], M) ->
    M;
plist_to_map([{K,V}|Ls], M) when is_binary(K), is_integer(V) ->
    plist_to_map(Ls, M#{ K => V });
plist_to_map([_|Ls], M) ->
    plist_to_map(Ls, M).

同樣,可以將其指定為類型。

-type map1() :: #{ binary() => integer() }.
-type map2() :: #{ <<"list1">> | <<"list2">> => [numbers()] }.

函數和語意 #

實作函數的模組目前以複數命名,mapslistsgb_treessets 等相同,但是單數 map 更短,可能更可取。

映射 (maps) 的函數和語意。最初的許多靈感來自 Richard O’Keefe 的 frames 提案。

Erlang 模組擴展 #

erlang:is_map(M :: term()) -> boolean(). #

此函式為保護函式 (guard function)。

語法等效:try #{} = M, true catch error:{badmatch,_} -> false end

如果 M 為映射 (map),則此函式回傳 true,否則回傳 false

erlang:map_size(M :: map()) -> non_neg_integer(). #

此函式為保護函式 (guard function)。

語法等效:

此函式回傳映射中鍵值對的數量。此操作時間複雜度為常數。

maps:size(M) 相同。

maps 模組 #

maps:remove(K0 :: term(), M0 :: map()) -> M1 :: map(). #

語法等效:#{ K => V || K := V <- M0, K =/= K0 }僅限列表推導式 (comprehensions)

此函式從映射 M0 中移除鍵 K0 (如果存在) 及其關聯的值,並回傳一個不含鍵 K0 的新映射 M1

maps:from_list([{K,V}||{K,V} <- maps:to_list(M0), K =/= K0]) 相同。

maps:get(K :: term(), M :: map()) -> V :: term(). #

語法等效:M#{ K }

如果映射 M 包含一個符合 K 的鍵,則回傳與鍵 K 相關聯的值 V。如果沒有值與鍵 K 相關聯,則呼叫將會失敗並產生例外。

maps:keys(M :: map()) -> [K1, .., Kn]. #

語法等效:[K || K := _ <- M]

回傳映射 M 中所有鍵的完整列表,順序任意。

[K || {K,_} <- maps:to_list(M)] 相同。

maps:find(K :: term(), M :: map()) -> {ok, V :: term()} | error. #

語法等效:try #{ K := V } = M, V catch error:{badmatch,_} -> error end

如果映射 M 包含鍵 K,則回傳包含與鍵 K 相關聯的值 V 的元組 {ok, V}。如果沒有值與鍵 K 相關聯,則此函式回傳 error

maps:fold(F :: fun((K :: term(), V :: term(), In :: term()) -> Out :: term()), A :: term(), M :: map()) -> Result :: term(). #

語法等效:

針對映射 M 中每個鍵 K 與值 V 的關聯,以任意順序呼叫 F(K, V, In)。函式 fun F/3 必須回傳一個新的累積器,該累積器會傳遞至下一個連續呼叫。maps:fold/3 回傳累積器的最終值。如果映射為空,則回傳初始累積器值 A

lists:foldl(fun({K,V}, In) -> F(K,V,In) end, A, maps:to_list(M)) 相同。

maps:from_list([{K1,V1}, .., {Kn,Vn}]) -> M :: map(). #

語法等效:#{ K1 => V1, .., Kn => Vn }

此函式接收鍵值組元組的列表,並建立一個映射。關聯可以依任何順序排列,而且關聯中的鍵和值都可以是任何類型。如果同一個鍵出現多次,則使用最後一個 (最右邊) 的值,並忽略之前的值。

maps:is_key(K :: term(), M :: map()) -> bool(). #

語法等效:try #{ K := _ } = M, true catch error:{badmatch, _} -> false end

如果映射 M 包含一個符合 K 的鍵,則回傳 true

maps:map(F :: function(), M0 :: map()) -> M1 :: map(). #

語法等效:#{ K => F(K,V) || K := V <- M }僅限列表推導式

此函式透過針對映射 M0 中每個鍵 K 與值 V 的關聯,以任意順序呼叫函式 fun F(K, V),來產生一個新的映射 M1。函式 fun F/2 必須回傳要與新映射 M1 的鍵 K 相關聯的值。

maps:from_list(lists:map(fun({K,V}) -> {K, F(K,V)} end, maps:to_list(M))) 相同。

maps:new() -> M :: map(). #

語法等效:#{}

回傳一個新的空映射。

maps:from_list([]) 相同。

maps:size(M :: map()) -> Size :: non_neg_integer(). #

語法等效:

此函式回傳映射中鍵值關聯的數量。此操作時間複雜度為常數。

erlang:map_size(M) 相同。

maps:put(K :: term(), V :: term(), M0 :: map()) -> M1 :: map(). #

語法等效:M0#{ K => V }

將鍵 K 與值 V 關聯,並將此關聯插入映射 M0。如果存在一個符合 K 的鍵,則舊的關聯值會被值 V 取代。此函式回傳一個包含新關聯的新映射 M1

maps:from_list(maps:to_list(M0) ++ [{K,V}]) 相同。

maps:to_list(M :: map()) -> [{K1,V1}, ..., {Kn,Vn}]. #

語法等效:[{K, V} || K := V <- M]

其中,會以任意順序回傳配對 [{K1,V1}, ..., {Kn,Vn}]

maps:update(K :: term(), V :: term, M0 :: map()) -> M1 :: map() #

語法等效:M0#{ K := V }

如果存在一個符合 K 的鍵,則舊的關聯值會被值 V 取代。此函式回傳一個包含新關聯值的新映射 M1

maps:from_list(maps:to_list(M0) ++ [{K,V}]) 相同。

maps:values(M :: map()) -> [V1, .., Vn]. #

語法等效:[V || _ := V <- M]

回傳映射 M 中包含的所有值的完整列表,順序任意。

[V || {_,V} <- maps:to_list(M)] 相同。

maps:without([K1, .., Kn] = Ks, M0 :: map()) -> M1 :: map(). #

語法等效:#{ K => V || K := V <- M0, not lists:member(K, Ks) }僅限列表推導式

從映射 M0 中移除鍵 K1Kn 及其關聯的值,並回傳一個新的映射 M1

maps:from_list([{K,V}||{K,V} <- maps:to_list(M0), not lists:member(K, Ks)]) 相同。

maps:merge(M0 :: map(), M1 :: map()) -> M2 :: map(). #

語法等效:

將兩個映射合併為一個單一映射。如果兩個映射中存在兩個符合的鍵,則映射 M0 中的值將會被映射 M1 中的值取代。

相等性和排序 #

相等性 #

如果 term A 和 term B 都是映射,則

  • 如果 AB 的大小不同,則 AB 不相等。
  • 否則,如果 AB 的所有對應鍵與其對應值都成對相等,則 AB 相等。
  • 否則,AB 不相等。

因此,兩個映射相等的條件是,它們必須具有相同的類型大小,而且所有對應的鍵值關聯都成對相等。

排序 #

term 的順序定義在 Erlang 規格 4.7 中,並引述如下

  • term 主要依其類型排序,順序如下

      numbers < atoms < refs < ports < PIDs < tuples < empty list < conses < binary
    

此處的規格不完整,實際 term 順序為

numbers < atoms < refs < funs < ports < pids < tuples < empty list < conses < binaries

映射資料類型在元組之後排序。

numbers < atoms < refs < funs < ports < pids < tuples < maps < empty list < conses < binaries
                                                        ----

映射首先依其大小排序,然後依其各自的鍵排序,最後依 Erlang term 順序的關聯值排序。

給定兩個大小相同的映射 M1M2,它們會進行比較,使得 M1 中每個依 Erlang term 順序的鍵,都會與 M2 的對應鍵比較。先比較所有的鍵,再比較值,直到找到差異為止。如果鍵或值不同,則各自 term 的順序 (依 Erlang term 順序) 即為映射的順序。如果鍵值組都相同,則認為映射相等。

範例

> #{ b => 2 } > #{ a => 2 }.         % b > a
true
> #{ b => 2 } > #{ a => 1, b => 2 }. % size 1 < size 2
false
> #{ b => 1 } > #{ b => 2}.          % 1 < 2
false
> #{ b => 2, c => 3 } > #{ b => 1, d => 3}.  % c > d, compared before 2 and 1
false
> #{ b => 1 } > #{ 1 => 1}.          % b > 1
true
> #{ 1.0 => a } == #{ 1 => a }.      % 1.0 == 1
true
> #{ 1.0 => a } =:= #{ 1 => a }.     % 1.0 =:= 1
false

映射會以任意順序印出鍵。

運算子優先順序 #

映射關聯運算子和設定值運算子會最後排序,在符合運算子和 catch 之後。

:
#
Unary + - bnot not
/ * div rem band and          Left associative
+ - bor bxor bsl bsr or xor   Left associative
++ --                         Right associative
== /= =< < >= > =:= =/=
andalso
orelse
= !                           Right associative
catch
=> :=

因此,映射表達式

#{ key => C = 1 + 2 }

會依以下順序求值

#{ key => ( C = ( 1 + 2 ) ) }

模式匹配 #

模式匹配是非常強大的 Erlang 工具。映射在其模式匹配中引入了一些新功能。

模式匹配:基本概念 #

我們將使用符合運算子來舉例說明。

表面上,使用映射的模式匹配與記錄相似。LHS 模式中要求的鍵將會與 RHS 關聯映射中找到的值繫結。

1> #{ a := V } = #{ a => 1 }.
#{ a => 1 }
2> 1 = V.
1

在 RHS 映射中找不到 LHS 模式中要求的鍵,將會產生例外,exception error: no match of right hand side value ...

1> #{ not_in_map := V } = #{ a => 1 }.
** exception error: no match of right hand side value #{ a => 1 }

同樣地,如果 LHS 模式中要求的鍵的值與 RHS 映射中關聯鍵的值不符合,則符合作業將會產生例外。

1> #{ a := 10 } = #{ a => 1 }.
** exception error: no match of right hand side value #{ a => 1 }

只有要求的鍵才會繫結至關聯。符合作業中映射內存在的任何未要求的鍵都會被忽略。

1> #{ a := V1, b := V2 } = #{ a => 1, b => 2, c => 3}.
#{ a => 1, b => 2, c => 3 }
2> 1 = V1.
1
3> 2 = V2.
2

在模式匹配映射時,要求的鍵的順序沒有意義。

1> #{ a := "1", b := "2" } = #{ a => "1", b => "2" }.
#{ a => "1", b => "2" }
2> #{ b := "2", a := "1" } = #{ a => "1", b => "2" }.
#{ a => "1", b => "2" }

模式匹配:進階 #

以下的範例是建構的範例,用來示範映射模式匹配的強大功能。

符合表達式的求值方式是,表達式中用作鍵的變數會在 (如果可能) 求值之前進行繫結。

例如,鍵可以透過其他鍵值關聯進行繫結。

1> #{ K := V, id := K } = M = #{ id => b, a => 1, b => 2, c => 3}.
#{ id => b, a => 1, b => 2, c => 3}
2> b = K.
b
3> 2 = V.
2

在此情況下,先求值已繫結的鍵 id,並在 M 中查閱,將變數 K 繫結。然後可以使用繫結至 bKV 繫結至 2。

繫結用作鍵的變數要求必須有一個沒有循環的繫結順序。重新排序會擴展到符合表達式中的所有 term,因此

1> { #{ X := Y }, X } = { #{ 5 => 10 }, 5 }.

XY 未繫結的情況下,會產生成功的符合,將 X 繫結至 5,將 Y 繫結至 10。

這在更新映射關聯中的特定項目時特別有用

%% Function declared in module map_example
update_values([{K, V1}|Ls], #{ K := V0 } = M) -> update_values(Ls, M#{ K := V0 + V1 });
update_values([_|Ls], M) -> update_values(Ls, M);
update_values([], M)     -> M.

第一個函式子句在這裡很重要。鍵 K 會在元組中繫結,並將用來從映射 M 中要求值 V0。然後更新映射 M,將鍵 K 與新值 V0 + V1 關聯。

%% In the Erlang shell
1> M = #{ "a" => 1, "b" => 2, "c" => 3 }.
#{ "a" => 1, "b" => 2, "c" => 3 }

2> map_example:update_values([{"b", 10}, {"c", 20}, {"d", 30 }], M).
#{ "a" => 1, "b" => 12, "c" => 23 }

請注意,由於鍵 "d" 不存在於映射 M 中,因此它無法與第一個子句符合,而且不會使用關聯 "d" => 40 來更新映射。

如果符合的 LHS 中存在循環相依性的表達式,例如

1>  #{X := Y, Y := X} = #{5 => 10, 10 => 5}.

將會產生求值器錯誤 (變數未繫結) 或編譯錯誤。

外部 Term 格式 #

有 255 個標籤可用於編碼外部二進位發佈的 term。所有標籤都定義在 external.h 中。編碼以魔術位元組 131 開頭。

R15B01 中使用的編碼標籤如下

SMALL_INTEGER_EXT        'a'     97
INTEGER_EXT              'b'     98
FLOAT_EXT                'c'     99
ATOM_EXT                 'd'    100
SMALL_ATOM_EXT           's'    115
REFERENCE_EXT            'e'    101
NEW_REFERENCE_EXT        'r'    114
PORT_EXT                 'f'    102
NEW_FLOAT_EXT            'F'     70
PID_EXT                  'g'    103
SMALL_TUPLE_EXT          'h'    104
LARGE_TUPLE_EXT          'i'    105
NIL_EXT                  'j'    106
STRING_EXT               'k'    107
LIST_EXT                 'l'    108
BINARY_EXT               'm'    109
BIT_BINARY_EXT           'M'     77
SMALL_BIG_EXT            'n'    110
LARGE_BIG_EXT            'o'    111
NEW_FUN_EXT              'p'    112
EXPORT_EXT               'q'    113
FUN_EXT                  'u'    117

DIST_HEADER              'D'     68
ATOM_CACHE_REF           'R'     82
ATOM_INTERNAL_REF2       'I'     73
ATOM_INTERNAL_REF3       'K'     75
BINARY_INTERNAL_REF      'J'     74
BIT_BINARY_INTERNAL_REF  'L'     76
COMPRESSED               'P'     80

對於映射,我們將標籤 MAP_EXT 定義為 116 (t)。

資料配置

MAP_EXT
|-----------------------------------
|  1  |    4   |        |          |
|-----------------------------------
| 116 |  Size  |  Keys  |  Values  |
|-----------------------------------

Size 指定大小描述元之後的鍵和值的數量。

未解決的問題,針對以下項目進行最佳化

  1. 編碼/解碼速度?
  2. 存取便利性?
  3. 記憶體大小?

記憶體大小應為優先考量,因為我們透過網路傳輸這些資料。我們應該促進易用性,以便其他語言可以整合到此格式。

這導致了扁平且簡單的結構。因此,編碼/解碼會產生效能上的損失。

動機 #

當我們已經有記錄 (records)字典 (dicts)gb_treesetsproplists 時,為什麼我們還需要 Map?

Map 被設想為一個易於使用、輕量但功能強大的鍵值關聯儲存。

Map 利用了 Erlang 的主要優勢之一,模式匹配,以豐富使用者體驗並提供一個強大的工具來簡化程式碼開發。在易用性方面,模式匹配使 Map 明顯優於 dict、gb_trees 或 proplists。

Map 提供將任意術語作為鍵(不僅限於原子),並將任意術語作為值,關聯在一個能夠匹配的資料類型中的可能性。

Map 並不聲稱要像 Frames 提案那樣取代記錄 (records)。相反,Map 的目標是更大的使用領域,並希望成為記錄的補充,並在適當的情況下取代它們。

Map - 兩種方法 #

  1. Map 作為具有模式匹配和用於建構、存取或更新它們的語法的關聯陣列。
  2. Map 作為記錄的替代品。

Map 最初並不是被設想為記錄的替代品,這是一個後來加入的期望要求。記錄替代方法不一定會限制任何語義,但它可能會對實作和底層結構產生一些約束。

記錄 #

在適當的情況下,記錄功能強大。

  • 由於在編譯時對鍵進行索引,因此查找速度快,O(1),並且對於小型記錄大小(約 50 個值)的儲存速度快。
  • 沒有記憶體開銷來儲存鍵,只有值和一個名稱:消耗 2 + N 個字 (words)。
  • 易於在函式頭匹配中使用。

然而,一些缺點是

  • 編譯時的依賴性,並且強制跨模組使用時包含標頭檔。
  • 只有原子可以作為鍵。
  • 鍵在執行時不可存取。
  • 無法動態存取值,也就是說,我們不能使用變數來存取值。
  • 它不是一種資料類型,無法與元組區分開來。

當比較 Map 和記錄時,Map 很容易彌補這些缺點,但是,在內建資料類型中,正向的效應並不容易複製,在內建資料類型中,值是在執行時而不是在編譯時確定的。

  • 要比直接索引陣列更快,其中索引和可能產生的值是在編譯時確定的,是很困難的。事實上,這是不可能的。
  • Map 的記憶體模型,如果效率接近記錄,可以透過本質上使用兩個元組來實現,一個用於鍵,一個用於值,如 Frames 中所示。這會影響 Map 在大量條目下更新的效能,從而限制字典方法的效能。
  • Map 在函式頭中使用匹配會一樣容易,甚至更容易。

協定建構 #

已經提出了使用 Frames 或 Map 的更簡單 JSON 表示形式的論點。使用 Frames 並因此使用原子來動態建立鍵將是一個嚴重的缺點。使用 Map 將允許使用字串二進位制來表示鍵,並且不會使全域原子池混亂。

模式匹配範例:JSON 檔案 #

變更 json 解碼。Mochiwebs mochijson 將 Json 字典解碼為以下內容

{"key": "value"} -> {struct, [{"key", "value"}]}

這可以改為

{"key": "value"} -> #{ "key" => "value"}

考慮以下來自 json.org 的 JSON 範例。

{"menu": {
  "id": "file",
  "value": "File",
  "popup": {
    "menuitem": [
      {"value": "New", "onclick": "CreateNewDoc()"},
      {"value": "Open", "onclick": "OpenDoc()"},
      {"value": "Close", "onclick": "CloseDoc()"}
    ]
  }
}}

mochijson:decode/1 目前看起來會像

{struct, [
    {"menu", {struct, [
        {"id","file"},
        {"value","File"},
        {"popup", {struct, [
            {"menuitem", {array, [
                {struct, [{"value","New"},{"onclick","CreateNewDoc()"}]},
                {struct, [{"value","Open"},{"onclick","OpenDoc()"}]},
                {struct, [{"value","Close"}, {"onclick","CloseDoc()"}]}
            ]}}
        ]}}
    ]}}
]}

mochijson:decode/1 可以看起來像

#{ "menu" => #{
    "id" => "file",
    "value" => "File",
    "popup" => #{
        "menuitem" => [
          #{ "value" => "New",   "onclick" => "CreateNewDoc()"},
          #{ "value" => "Open",  "onclick" => "OpenDoc()"},
          #{ "value" => "Close", "onclick" => "CloseDoc()"}
        ]
    }
}}

讓我們找到 "menu" -> "popup" -> "menuitem"

遍歷第一個結構有點笨拙。我們必須執行以下操作

Decoded         = mochijson:decode(Json),
{struct, Menu}  = proplists:get_value("menu", Decoded),
{struct, PopUp} = proplists:get_value("popup", Menu),
{struct, Items} = proplists:get_value("menuitem", PopUp),

使用 Map,它可能看起來像這樣

#{ "menu"     := Menu  } = mochijson:decode(Json),
#{ "popup"    := PopUp } = Menu,
#{ "menuitem" := Items } = PopUp,

甚至

Decoded = mochijson:decode(Json),
#{ "menu" := #{ "popup" := #{ "menuitem" := Items } } } = Decoded,

使用 Map 和單個值存取,它可能看起來非常簡單

Decoded = mochijson:decode(Json),
Items = Decoded#{ "menu" }#{ "popup" }#{ "menuitem "}.

開放問題 #

我們有一些使用情境仍在討論中。每個問題都會給出一個建議的答案,這些答案來自本提案中的討論。

  1. 我們需要在 Map 中儲存哪種類型的鍵?原子是否足夠?
    • 作者的觀點是,除非有壓倒性的證據表明我們可以從這種限制中獲得一些好處,否則我們應避免任何鍵限制。
    • 非原子鍵限制滿足了我們對強大 Map 機制的目標。
    • 提案:任何術語作為鍵。
  2. 我們將在 Map 中儲存多少個鍵值關聯?
    • 這個問題與語法和語義無關,而是與底層實作的選擇有關。
    • 如果我們允許使用者動態新增鍵值對,他肯定會使用它。由於這是一個強大的機制,記錄並沒有提供給我們,因此使用模式也會有所不同。這很可能產生比今天的記錄更大的 Map。這意味著我們不能將記錄的大小與 Map 的大小進行比較,因為使用情境會有所不同。
    • 提案:任意數量的鍵值對。
  3. 對於具有特定鍵集的每個 Map,我們將建立多少個 Map 實例?
    • 這個問題與我們如何使用記錄密切相關,以及 Map 是否應該模擬此行為,這不應影響語義,而只會影響實作。
    • 主要差異在於儲存結構中的記憶體開銷。由於鍵和值的記憶體開銷與任何複合術語或抽象資料類型(例如 dict 或 gb_trees)的行為相同,因此主要差異發生在將 Map 與記錄和 Frames 進行比較時。為了確保更新或檢索的最差情況下的對數效能,Map 可能會使用某種類型的樹狀結構。然後,Map 會將鍵與其值一起儲存,而 Frames 會在其值結構外部儲存鍵,並且記錄會在編譯時產生鍵索引。這表示每個實例的 Map 的記憶體開銷會高於 Frames 和記錄。
    • 提案:雙層方法,類似於二進位制。對於少量關聯(約 50 個關聯),使用扁平的緊湊的、共享鍵的方法。超出第一層限制時,使用排序樹方法並將鍵與值一起儲存。其基本原理是,當我們只有少數鍵時,更可能有多個實例。
  4. 在語法中,只允許更新 Map 中已定義的鍵嗎?
    • 這個問題源於記錄替換方法,而支持它的論點是為了減輕拼寫錯誤,例如,嘗試更新鍵 behavior,而實際上想要的是鍵 behaviour。此時會出現執行時例外,而不是獲得兩個不同的鍵。
    • 這種方法將拒絕任何類似字典的行為,例如,使用語法將已產生的程序儲存為 Map 中的鍵。
    • 提案:預設語法允許儲存任何鍵,無論是否存在,並使用特殊語法來僅設定現有鍵的值。

這些問題的答案對於我們應如何設計和實作 Map 至關重要。我們還應該記住的是,我們永遠不會完全擺脫記錄,因此 Frames 的一些論點可能是多餘的。

基本原理 #

我們應該對語法有什麼期望? #

如前所述,目前的語法尚未確定,但我們應該對它有什麼期望?

  1. 首先,它必須是明確的,這表示語法必須產生人類和機器都無法誤解的單一明確定義的行為。

    1.1 這裡我們還包括了類似語法應該具有類似行為的概念,或者至少不應該是完全不同的行為。例如,記錄 #record{ key = Value },具有 O(1) 的效能和 2 + N 個字的記憶體開銷。如果我們使用類似的語法,例如 #{ key = Value },對於 Map 的任何大小,我們也應該期望在語義和效能方面都有類似的行為。

  2. 語法必須盡可能簡短。只要不違反規則 1,就不能使用比描述特定行為所需的更多字元。

    2.2 我們希望避免語言中的冗長。冗長會將資訊從我們的視野中推開並使之模糊。這需要避免。

Map 的語法選擇 #

作者主張: => 作為「設定或更新」中的分隔符號,而 := 用於「設定現有」和「匹配」語法。

在下面的範例中,我們使用 #{ Key => Value } 來描述 Map 語義和使用案例,但這只是眾多建議中的一種。

存在多種語法提案,Frames 提案使用 <{ key ~ Value }> 語法,而另一個語法建議與記錄語法 #{ key = Value } 非常相似。

目前的變數和原子定義限制了我們可以用作分隔符號的內容,而 ~= 是我們可以使用的唯一合理的單個字元分隔符號。Richard O'Keefe 在他的 不再需要記錄(第五版) 中充分論證了這一點。

分隔符號討論 #

反對使用 = 分隔符號的論點是

  • 它缺乏與匹配的區別,考慮 #{ A = B = v }A 是否匹配 B,或者 B 是否匹配 v?哪個 = 是匹配運算,哪個 = 分隔鍵值對?
  • 它可能會被解釋為 Key「等於」Value

因此,= 違反了我們對語法有什麼期望?中的規則 #1。對此語法的解釋是含糊不清的。

反對使用 ~ 分隔符號的論點是

  • 它可能會被解釋為 Key「非」Value,因為 ~ 是 C 中的按位非運算子。
  • 它可能會被解釋為 Key「大約等於」Value,因為 ~ 類似於數學中的
  • 它缺乏排版上的區別,也就是說,它在某些字體中缺乏與 - 的區別,例如。K ~ V 可能會被解釋為 K - V,考慮 #{ K - 1 ~ 2 - V }

因此,這違反了我們對語法有什麼期望?中的規則 #1。對此語法的解釋是含糊不清的。

兩個雙字元分隔符號建議是 #{ Key := Value }#{ Key => Value},其中 := 是指派的通用分隔符號,而 => 應該讀作映射到。如果可以的話,應該避免使用雙字元分隔符號,因為它會增加原始碼的語法佔用空間。

指派分隔符號對於指派本身來說讀起來很好,但在模式匹配方面,它也與記錄一樣存在邏輯反轉的缺陷。匹配 #{ key := Value } = M 讀作將 M 匹配到 Map 模式,其中 Value 等於 key。除非我們將指派分隔符號 := 稱為與它本身無關的東西,否則讀起來並不順暢。

然而,:= 也類似於 =:=,後者表示「完全相等」,也就是匹配。這是一個有價值的含義,因為在處理數字時,===:= 之間存在差異,因此 := 可以成為匹配語法中更正確的分隔符。

如果 -> 這個分隔符不是因為會重載函數子句的含義,那它會是一個合適的選擇。

當處理二元資料時,->=> 都可能會令人困惑。考慮 #{ <<"key">> -> <<"value">> }#{ <<"key">> => <<"value">> },其中 => 似乎比 -> 更令人困惑一些。

以下是從上述感知到的理想程度排列的分隔符列表

  1. #{ K => V } - 沒有歧義,沒有重載,讀起來像是一個關聯
  2. #{ K := V } - 沒有歧義,沒有重載,讀起來像是賦值或精確匹配
  3. #{ K ~ V } - 排版上的歧義,沒有重載,含義不明確
  4. #{ K -> V } - 重載了函數子句的頭部和主體分隔符,讀起來像是一個關聯
  5. #{ K = V } - 重載了匹配運算符,讀起來像是匹配或賦值

對於現有鍵使用 := 賦值似乎是一個不錯的選擇。對於設定或更新的選擇,則是在 =>~ 之間。

兩種設定或更新語義及其語法的理由 #

這裡提出了一種在 Map 中更新值的兩種不同方式。

一種語法是,當且僅當我們想更新一個已經*存在*的鍵的值時使用,另一種是當我們想要更新 Map 中任何鍵時使用。

  • 使用 M#{ K => V } 來宣告新的鍵值對*或*更新已存在的鍵
  • 使用 M#{ K := V } 來更新已存在的鍵。
  • 使用 #{ K := V } = M 來匹配 Map。

範例 1

foo() ->
    M = #{ key1 => 1, key2 => 2 }, % M is declared with keys 'key1' and 'key2'
    bar(M).

bar(M) ->
    M#{
        key1 := "1",  %% 'key1' will be set to "1"
        key2 := "2",  %% 'key2' will be set to "2"
        key3 := "3"   %% this causes an exception since 'key3' does not exist in M
    }.

> foo().
** exception error: no match of 'key3' in map

範例 2

foo() ->
    M = #{ key1 => 1, key2 => 2 }, % M is declared with keys 'key1' and 'key2'
    bar(M).

bar(M) ->
    M#{
        key1 => "1",  %% 'key1' will be set to "1"
        key2 => "2",  %% 'key2' will be set to "2"
        key3 => "3"   %% 'key3' will be set to "3"
    }.

> foo().
#{ key1 => 1, key2 => "2", key3 => "3" }

語法足跡的影響 #

我們必須減少語法足跡對原始碼和語言的影響。

目前,將選項傳遞給函數的兩種常見方式是透過記錄或屬性列表。兩者都有一些缺點。記錄是編譯時相依的,並且是元組的語法糖。屬性列表是通用的,但在定義和操作它們時會產生大量的文字。

考慮在解析參數列表時的這個範例

args(Args) ->
     args(Args, [{analyze, false}, {suppression, false}, {target, none}]).

args(["-r" | Args], Opts) ->
    args(Args, [{analyze, true}     | proplists:delete(analyze, Opts)]);
args(["-s="++File | Args], Opts) ->
    args(Args, [{suppression, File} | proplists:delete(suppression, Opts)]);
args([Target], Opts) ->
    [{target, Target} | proplists:delete(target, Opts)].

當操作屬性列表時,文字的影響(字元數)非常大。

如果我們改用某種具有語法的 Map,那會是什麼樣子呢?

args(Args) ->
    args(Args, #{ analyze => false, suppression => false, target => none}).

args(["-r" | Args], Opts)        -> args(Args, Opts#{ analyze := true });
args(["-s="++File | Args], Opts) -> args(Args, Opts#{ suppression := File});
args([Target], Opts)             -> Opts#{ target := Target }.

我認為這樣看起來更簡潔,但這是一個非常主觀的看法。為了使用一些數據,我們可以計算字元數,我們看到屬性列表範例有 390 個字元,而 Map 範例有 306 個字元。在這個範例中,屬性列表使用了將近 30% 更多的字元。

語義和 API 函數 #

列表轉換 #

也許最合理的 maps:from_list/1 語義是讓鍵值的重要性順序從左到右,這表示使用第一個關聯,而忽略具有匹配鍵的後續值。

這與 dict:from_list/1 的行為不同。

考慮以下 dict 範例

[{a,2}] = dict:to_list(dict:from_list([{a,1}, {a,2}])).

透過讓最左邊的鍵成為最重要的鍵,我們可以簡化與列表之間的轉換。

目前的建議具有以下語義

Ls = [{a,old}],
#{ a := old } = maps:from_list([{a,new}|Ls]).

反轉會是

Ls = [{a,old}],
#{ a := new } = maps:from_list([{a,new}|Ls]).

相等性和排序 #

Erlang 規範對實作設定的限制是排序必須是完全的,也就是滿足*反對稱性*、*遞移性*和*完全性*。

  • 如果 M1 =< M2M2 =< M1M1 == M2
  • 如果 M1 =< M2M2 =< M3M1 =< M3
  • 如果 M1 =< M2M2 =< M1 (總是可比較的) 其中 M1M2M3 是任何 Map 項。

只有當我們將浮點數和整數視為型別的聯集(即數字)時,這才在 Erlang 中成立。在 Map 的情況下,true = #{ 1.0 => V } == #{ 1 => V}

  • 在少數情況下需要排序。
    • 比較,例如排序,lists:sort([M1, .., Mn])
    • 內省,例如當列印時。
  • 已排序的 Map 對底層實作施加了限制,並且雜湊方法幾乎是不可能的。
  • 底層結構不需要排序,可以在需要時產生順序。
    • M1 < M2 會導致內部排序,但會花費 O( *N1* * lg *N1* + *N2* * lg *N2* ),其中 N1 = maps:size(M1) and N2 = maps:size(M2)

存取單一值 #

我們是否需要單一存取,或者匹配是否足夠?

考慮以下情況,

V = M#{ K }

#{ K := V } = M

它還允許輕鬆存取深層結構中的關聯值。

單一值存取的語法是此提案中最不完善(和考慮過的)的功能,肯定可以使用一些輸入。

此外,點語法必須廢除。目前它用於記錄,但不會用於 Map。點表示最後一個子句中表示式列表的結束,或屬性的結束。

它不能用於區分浮點數或關聯。

範例

1> M = #{ 1.1 => a, 1 => #{ 1 => b } }.
#{ 1 => #{ 1 => b }, 1.1 => a }.

2> #M.1.1.
a | b ?

向下相容性 #

用 Map 編寫的 Erlang 程式碼只能在 Erlang/OTP R17A 及更高版本的 Erlang/OTP 上解析、載入和執行,而不能在先前的版本上執行。

在 Erlang/OTP R17A 之前編寫的 Erlang 程式碼將完全相容,也就是可以透過這些 Map 變更進行解析、載入和執行。

分發將不向下相容。

版權 #

本文檔已置於公有領域。