作者
Björn-Egil Dahlberg <egil(at)Erlang.org>
状态
最终/17.0 已在 OTP 版本 17 中实现
类型
标准跟踪
创建于
2013年4月4日
Erlang 版本
OTP-17.0

EEP 43:映射 #

摘要 #

映射以及本 EEP 的历程漫长,绝非一帆风顺。大约两三年前,我们首次在 OTP 内部讨论映射时,我对映射的期望有一个清晰的蓝图。本 EEP 与最初的设想类似,但它也吸收了来自 OTP 内外的许多其他想法。

最初的想法是一种数据类型,一种语法感知的键值关联映射,并带有模式匹配。其语法类似于记录,但没有编译时依赖的麻烦,并且可以使用任意项作为键。顺序并不重要,并且可以使用具有良好性能和内存权衡的哈希数组映射树来实现。这是一种不同于替换记录的方法。它的目的是在合适的地方替换记录,在其他方面不是替代品,而是它自己的东西

来自社区的许多人希望拥有类似映射的数据类型,并提出了一些建议。其中一个突出的建议当然是来自 Richard O'Keefe 的 Frames 提案。它是我见过的最完整的提案,并且考虑得非常周到。它的目标是作为记录的替代品,该提案很好地实现了这个目标。

  • 如果 Frames 如此出色,为什么还要单独提出一个 EEP?
  • 这归结于目标和约束。

记录的替代品只是一个替代品。这就像问“我们有什么?”而不是“我们能得到什么?”。立即的反驳是“我们需要什么?” 我说映射。

Frames 确实启发并影响了映射。在许多方面,映射也包含了 Frames,但映射试图做更多的事情。最终,我认为它们是两个不同的事物,并且具有不同的目标。

本 EEP 建议为 Erlang 添加一个新的内置数据类型,即映射,#{ Key => Value }

新的数据类型应具有以下语义、语法和操作:

  • 提供一个从键项到值项的关联集,可以使用语言语法构造、访问和更新
  • 可以与该语言中的所有其他数据类型唯一地区分
  • 对于构造、访问或更新映射的内容,或者在模块、进程之间或通过 Erlang 分发传递映射,没有编译时依赖
  • 可以在该语言的匹配表达式中使用
  • 具有数据类型打印和解析之间的一对一关联
  • 在该类型的项和其他 Erlang 类型之间具有明确定义的顺序
  • 在插入和查找操作中,最多具有 O(log N) 的时间复杂度,其中 N 是键值关联的数量。

其他语言中存在类似的数据类型,例如 perl 哈希ruby 哈希python 字典scala 映射

规范 #

映射 M 由若干关联组成,并保持从键项 K1..Kn 到值项 V1..Vn 的关联,其中没有两个键匹配。任何项(复合项或其他项)都是可行的键或值。映射类型的项通过 guard 测试 erlang:is_map/1 来识别。没有对映射进行操作的运算符。在映射中,有两个中缀运算符。关联运算符 => 将键与值配对,用于创建和更新。设置值运算符 := 用于更新已存在且匹配的键的值。设置值运算符也用于匹配以从键获取关联的值。

术语 #

映射的大小是其集中关联的数量。

关联是映射中键 K 到值 V 的键值对。

如果 true = K1 =:= K2,则两个键 K1K2匹配的

语法 #

用于声明、更新和匹配映射的已定义语法。

构造语法 #

构造新映射是通过将表达式 K 与另一个表达式 V 关联来完成的

#{ K => V }

新映射在构造时可以通过列出每个关联来包括多个关联

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

通过不将任何项相互关联来构造空映射

#{}

映射中的所有键和值都是项。首先对任何表达式进行求值,然后将结果项分别用作

键和值由 => 箭头分隔,关联由 , 分隔。

示例

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 是生成的映射项。

如果声明了两个匹配的键,则后一个键将优先。

示例

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>

更新语法 #

更新映射具有与构造它类似的语法。

定义要更新的映射的表达式放在定义要更新的键及其各自值的表达式之前。

M#{ K => V }

其中 M 是映射类型的项,KV 是任何表达式。

如果键 K 与映射中任何现有键都不匹配,则将创建从键 K 到值 V 的新关联。如果键 K 与映射 M 中的现有键匹配,则其关联的值将被新值 V 替换。在这两种情况下,求值的映射表达式都将返回新映射。

如果 M 不是映射类型,则会抛出 badmap 类型的异常。

要仅更新现有值,请使用以下语法,

M#{ K := V }

其中 M 是映射类型的项,V 是一个表达式,K 是一个求值为 M 中现有键的表达式。

如果键 K 与映射 M 中任何现有键都不匹配,则会在运行时触发 badarg 类型的异常。如果匹配的键 K 存在于映射 M 中,则其关联的值将被新值 V 替换,并且求值的映射表达式返回新映射。

如果 M 不是映射类型,则会抛出 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 是任何映射。由此可见,M1 .. M4 也是映射。

更多示例

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

与构造中一样,键和值表达式的求值顺序未定义。更新中键值对的语法顺序无关紧要,除非在两个键匹配的情况下,在这种情况下使用后一个值。

以下是映射更新的简单 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>

访问单个值 #

对于访问映射中的单个值,让我们使用解关联

V = M#{ K }.

其中 M 是映射,K 是任何项。

如果键 K 与映射 M 中的现有键匹配,则关联的值将绑定到 V。如果键 K 与映射 M 中的任何现有键都不匹配,则在运行时会发生 badarg 异常。

示例

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

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

匹配语法 #

从映射中匹配键值关联,以匹配运算符为例,按以下方式完成

#{ K := V } = M

其中 M 是任何映射。键 K 必须是具有绑定变量或文字的表达式,V 可以是具有绑定或未绑定变量的任何模式。如果 V 中的变量未绑定,则它将绑定到与键 K 关联的值,该值必须存在于映射 M 中。如果 V 中的变量已绑定,则它必须与 M 中与 K 关联的值匹配。

示例

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

这会将变量 B 绑定到整数 2

类似地,可以匹配映射中的多个值

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

其中键 K1 .. Kn 是任何带有文字或绑定变量的表达式。如果所有键都存在于映射 M 中,则 V1 .. Vn 中的所有变量都将与其各自键的关联值匹配。

如果未满足匹配条件,则匹配将失败,结果是:

  1. 如果像示例中那样在匹配运算符的上下文中使用,则会抛出 badmatch 异常,
  2. 或者在函数头和 case 表达式中测试下一个子句。

映射中的匹配仅允许将 := 用作关联的分隔符。

在匹配中声明键的顺序无关紧要。

匹配中允许重复的键,并且将匹配与键关联的每个模式。

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

将表达式与空映射文字进行匹配将匹配其类型,但不会绑定任何变量

#{} = Expr

如果表达式 Expr 的类型为映射,则此表达式将匹配,否则它将因异常 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 }};

匹配语法:频率示例 #

更多匹配语法,计算列表中项的频率。

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 可以改进为使用映射语法

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>

映射推导语法 #

映射推导声明

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

其中 M0 是任意 Map,E0E1 是任意 Erlang 表达式,KV 构成 M0 中每个关联的匹配模式。

对于生成器中的每个序列,都会创建一个从计算后的表达式 E0 到计算后的表达式 E1 的关联。

如果 M0 不是 Map,则会生成类型为 {bad_generator, M0} 的运行时异常。

下面是映射推导的简单 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() }.

映射生成器也可以用于二进制和列表推导中。

示例

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()] }.

函数和语义 #

实现函数的模块目前以复数形式命名为 maps,与 listsgb_treessets 等相同,但单数形式 map 更短,可能更理想。

用于 Map 的函数和语义。最初很多灵感来自 Richard O’Keefe 的框架提案。

Erlang 模块扩展 #

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

此函数是一个保护函数。

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

如果 M 是 map,则该函数返回 true,否则返回 false

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

此函数是一个保护函数。

语法等价:

该函数返回 map 中键值对的数量。此操作以恒定时间发生。

maps:size(M) 相同。

maps 模块 #

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

语法等价:#{ K => V || K := V <- M0, K =/= K0 }仅适用于推导

该函数从 map M0 中删除键 K0(如果存在)及其关联的值,并返回一个不包含键 K0 的新 map M1

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

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

语法等价:M#{ K }

如果 map M 包含与 K 匹配 的键,则返回与键 K 关联的值 V。如果键 K 没有关联的值,则调用将失败并出现异常。

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

语法等价:[K || K := _ <- M]

返回 map 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

如果 map M 包含键 K,则返回一个元组 {ok, V},其中值 V 与键 K 关联。如果键 K 没有关联的值,则该函数将返回 error

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

语法等价:

对于 map M 中的每个键 K 到值 V 的关联,以任意顺序调用 F(K, V, In)。函数 fun F/3 必须返回一个新的累加器,该累加器将传递给下一个连续调用。maps:fold/3 返回累加器的最终值。如果 map 为空,则返回初始累加器值 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 }

该函数接受键值元组元素的列表并构建一个 map。关联的顺序可以是任意的,并且关联中的键和值都可以是任何项。如果同一个键出现多次,则使用后者(最右边)的值,并且忽略先前的值。

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

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

如果 map M 包含与 K 匹配 的键,则返回 true

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

语法等价:#{ K => F(K,V) || K := V <- M }仅适用于推导

该函数通过对 map M0 中的每个键 K 到值 V 的关联,以任意顺序调用函数 fun F(K, V) 来生成新的 map M1。函数 fun F/2 必须返回与新 map M1 的键 K 关联的值。

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

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

语法等价:#{}

返回一个新的空 map。

maps:from_list([]) 相同。

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

语法等价:

该函数返回 map 中键值关联的数量。此操作以恒定时间发生。

erlang:map_size(M) 相同。

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

语法等价:M0#{ K => V }

将键 K 与值 V 关联,并将关联插入到 map M0 中。如果存在与 K 匹配 的键,则旧的关联值将被值 V 替换。该函数返回一个包含新关联的新 map 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 替换。该函数返回一个包含新关联值的新 map M1

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

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

语法等价:[V || _ := V <- M]

返回 map 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) }仅适用于推导

从 map M0 中删除键 K1Kn 及其关联的值,并返回一个新的 map 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(). #

语法等价:

将两个 map 合并为一个 map。如果两个 map 中存在两个匹配的键,则 map M0 中的值将被 map M1 中的值取代。

相等性和排序 #

相等性 #

如果项 A 和项 B 都是 map,

  • 如果 AB 的大小不同,则 AB 不相等。
  • 否则,如果 AB 的所有对应键都与其对应的值成对相等,则 AB 相等。
  • 否则,AB 不相等。

因此,当且仅当两个 map 具有相同的类型大小,并且所有对应的键值关联成对相等时,它们才相等。

排序 #

项顺序在 Erlang 规范 4.7 中定义,并引用如下

  • 这些项主要根据其类型排序,顺序如下

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

此处的规范不完整,实际的项顺序是

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

Map 数据类型排在元组之后

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

然后,Map 首先按大小排序,然后按各自的键排序,最后按 Erlang 项顺序排序相关的值。

给定两个大小相同的 map M1M2,它们进行比较,以便 M1 中每个键(按 Erlang 项顺序排列)与 M2 的相应键进行比较。首先比较所有键,然后比较值,直到找到差异。如果键或值不同,则各自的项顺序(按 Erlang 项顺序排列)即为 map 的顺序。如果没有键值对不同,则认为 map 相等。

示例

> #{ 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

Map 以任意顺序打印键。

运算符优先级 #

map 关联运算符和 set-value 运算符的优先级最低,排在匹配运算符和 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
=> :=

因此,map 表达式

#{ key => C = 1 + 2 }

将按以下顺序求值

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

模式匹配 #

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

模式匹配:基础 #

我们将使用匹配运算符进行举例。

Map 的模式匹配在表面上与记录相似。在 LHS 模式中请求的键将与在 RHS 关联 map 中找到的值绑定。

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

如果在 RHS map 中未找到 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 map 中键的关联值不匹配,则匹配会产生异常。

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

只有请求的键才会绑定到关联。匹配的 map 中存在的任何未请求的键都将被忽略。

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

请求的键的顺序在模式匹配 map 时没有意义。

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" }

模式匹配:继续 #

下面的示例是一个构造的示例,用于说明 map 模式匹配的强大功能。

对匹配表达式求值,以便在求值之前绑定表达式中用作键的变量(如果可能)。

例如,键可以通过其他键值关联绑定。

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。

将变量绑定用作键需要绑定顺序是无环的。重新排序会扩展到匹配表达式中的所有项,因此

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}.

将导致求值器错误(变量未绑定)或编译错误。

外部项格式 #

有 255 个标签可用于编码外部二进制分发的项。所有标签都在 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. 内存大小?

内存大小应该是首要考虑的,因为我们需要通过网络发送这些数据。我们应该提高易于访问性,以便其他语言可以集成到此格式中。

这导致了扁平而简单的结构。因此,编码/解码会带来性能损失。

动机 #

当已经有记录字典gb_treesetsproplists 时,为什么还需要映射?

映射被设想为一种易于使用、轻量级但功能强大的键值关联存储。

映射利用了 Erlang 的主要优势之一,模式匹配,来丰富用户体验并提供一个强大的工具来简化代码开发。在可用性方面,模式匹配使映射比字典、gb_trees 或 proplists 更具优势。

映射提供了将任意项作为键(不仅仅是原子),并将任意项作为值,存储在可匹配的数据类型中的可能性。

映射并不像 frames 提案那样声称要取代记录。相反,映射的目标是更大的使用领域,并希望成为记录的补充,并在合适的情况下取代它们。

映射 - 两种方法 #

  1. 将映射视为一个具有模式匹配和用于构造、访问或更新它们的语法的关联数组。
  2. 将映射作为记录的替代品。

映射最初并未被设想为记录的替代品,这是一个后来添加的期望要求。记录替代方法不一定限制任何语义,但可能会对实现和底层结构施加一些约束。

记录 #

在适当的情况下,记录功能强大

  • 由于键的编译时索引,查找速度快,为 O(1),并且对于小型记录大小(约 50 个值),存储速度快,
  • 没有存储键的内存开销,只有值和一个名称:消耗 2 + N 个字,
  • 易于在函数头匹配中使用。

然而,一些缺点是

  • 编译时依赖关系,并强制包含头文件以进行模块间使用,
  • 只能使用原子作为键,
  • 键在运行时不可访问,
  • 无法动态访问值,即我们不能使用变量来访问值,
  • 它不是数据类型,无法与元组区分开来。

在将映射与记录进行比较时,映射很容易弥补这些缺点,但是,在内置数据类型中,在运行时而不是在编译时确定值,很难复制积极的效果。

  • 比直接索引数组更快是很难的,在直接索引数组中,索引和可能的结果值都是在编译时确定的。事实上,这是不可能的。
  • 通过本质上使用两个元组(一个用于键,一个用于值,如 Frames 中演示的那样),可以实现映射的内存模型,其效率接近记录。这将影响具有大量条目的映射的更新性能,从而限制字典方法的功能。
  • 使用函数头中的匹配,映射将同样容易或更容易使用。

协议构造 #

有人提出了使用 frames 或映射的更简单的 JSON 表示形式的论点。使用 frames 并因此使用原子来动态创建键将是一个严重的缺点。使用映射将允许使用字符串二进制文件来表示键,并且不会使全局原子池混乱。

模式匹配示例:JSON 文件 #

更改 json-解码。Mochiweb 的 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),

使用映射,它可能看起来像这样

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

或者甚至

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

使用映射和单值访问,它可以变得非常简单

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

开放问题 #

我们有一些使用场景仍然存在争议。针对每个问题给出了一个建议的答案,该答案源于本提案中的讨论。

  1. 我们需要在映射中存储哪种类型的键?原子就足够了吗?
    • 作者认为,除非有压倒性的证据表明我们可以从这样的限制中获得某些好处,否则我们应该避免任何键限制。
    • 非原子键限制满足了我们对强大的映射机制的目标。
    • 提案:任何项都可以作为键。
  2. 我们将在映射中存储多少个键值关联?
    • 这个问题与语法和语义的关系不大,而与底层实现的选择有关。
    • 如果我们允许用户动态添加键值对,他肯定会使用它。由于这是一个强大的机制,记录无法提供给我们,因此使用模式也会有所不同。这很可能会产生比现在记录更大的映射。这意味着我们不能将记录大小与映射大小进行比较,因为使用场景会有所不同。
    • 提案:任意数量的键值对。
  3. 我们将为每个具有特定键集的映射创建多少个映射实例?
    • 这个问题与我们如何使用记录以及映射是否应该模拟此行为密切相关,并且这对语义没有影响,仅对实现有影响。
    • 主要区别在于存储结构中的内存开销。由于键和值的内存开销与任何复合项或抽象数据类型(即字典或 gb_trees)中的行为相同,因此,主要区别在于将映射与记录和 frames 进行比较时。为了确保更新或检索的最坏情况对数性能,映射可能会使用某种树结构。然后,映射会将键与其值一起存储,而 frames 将键存储在其值结构之外,并且记录会在编译时生成键索引。这表明每个实例的映射的内存开销将高于 Frames 和记录。
    • 提案:两层方法,类似于二进制文件。对于少量关联(约 50 个关联),使用扁平紧凑的键共享方法。对于超出第一层限制的,使用排序树方法并存储带有值的键。基本原理是,少量键的多个实例的可能性更大。
  4. 仅允许在语法中更新映射中已定义的键?
    • 这个问题源于记录替换方法,其论点是为了减少拼写错误,例如尝试更新键 behavior,而实际上想要更新键 behaviour。此时,不是得到两个不同的键,而是会发生运行时异常。
    • 此方法将拒绝任何类似字典的行为,例如使用语法在映射中存储生成的进程作为键。
    • 提案:默认语法允许存储任何键(无论是否存在),并使用特殊语法仅设置现有键的值。

这些问题的答案对于我们应该如何设计和实现映射至关重要。我们还应该记住的是,我们永远无法完全摆脱记录,因此某些 frames 的论点可能是多余的。

基本原理 #

我们应该对语法有什么期望? #

如前所述,当前语法并非一成不变,但我们应该对其有何期望?

  1. 首先,它必须是明确的,这意味着语法必须产生单一、明确的行为,不能被人或机器误解。

    1.1 在这里,我们也包括了类似语法应该具有类似行为的概念,或者至少不应该具有完全不同的行为。例如,记录 #record{ key = Value } 具有 O(1) 的性能和 2 + N 个字的内存开销。如果我们使用类似的语法,即 #{ key = Value },我们也应该期望类似的行为,包括语义和性能,对于映射的任何和所有大小都是如此。

  2. 语法必须尽可能短。只要不违反规则 1,它就不应该使用比描述特定行为所需更多的字符。

    2.2 我们希望避免语言的冗长。冗长会把信息从我们的视野中推开,并使之模糊不清。这需要避免。

映射的语法选择 #

作者认为: => 作为“设置或更新”中的分隔符, := 作为“设置现有”和“匹配”语法中的分隔符。

在下面的示例中,我们使用 #{ Key => Value } 来描述映射的语义和用例,但这只是众多建议之一。

存在几种语法建议,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 匹配到映射模式,其中 Value 等于 key”。除非我们将赋值分隔符 := 称为它不应该被称呼的东西,否则这并不容易理解。

然而,:= 也类似于 =:=,后者表示“完全相等”,即匹配。这是一个有意义的含义,因为我们在处理数字时 ===:= 之间存在差异,因此 := 可能是更正确的匹配语法分隔符。

如果不是因为它会重载函数子句的含义,那么分隔符 -> 将是一个合适的选择。

在处理二进制数据时,->=> 都可能令人困惑。考虑 #{ <<"key">> -> <<"value">> }#{ <<"key">> => <<"value">> },其中 => 似乎比 -> 更令人困惑。

根据上述感知到的期望值列出分隔符:

  1. #{ K => V } - 没有歧义,没有重载,读作关联。
  2. #{ K := V } - 没有歧义,没有重载,读作赋值或精确匹配。
  3. #{ K ~ V } - 排版歧义,没有重载,没有明确的含义。
  4. #{ K -> V } - 重载函数子句头和体分隔符,读作关联。
  5. #{ K = V } - 重载匹配运算符,读作匹配或赋值。

对现有键使用 := 赋值似乎是一个不错的选择。对于设置或更新的选择是在 =>~ 之间。

关于两种设置或更新语义及其语法的案例 #

提出了在映射中更新值的两种不同方式的案例。

一种语法是,当我们只想更新已存在的键的值时,另一种是当我们想用任何键更新映射时。

  • 使用 M#{ K => V } 来声明新的键值对,更新已存在的键。
  • 使用 M#{ K := V } 来更新已存在的键。
  • 使用 #{ K := V } = M 来匹配映射。

示例 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)].

当操作属性列表时,文本影响,即字符数,非常大。

如果我们改用带有语法的某种映射,它会是什么样子?

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 个字符,而映射示例有 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 =< M1,则 M1 == M2
  • 如果 M1 =< M2M2 =< M3,则 M1 =< M3
  • 如果 M1 =< M2M2 =< M1(始终可比较),其中 M1M2M3 是任何 Map 项。

只有当我们把浮点数和整数视为类型联合(即数字)时,这在 Erlang 中才成立。在映射的情况下,true = #{ 1.0 => V } == #{ 1 => V}

  • 在少数情况下需要排序。
    • 比较,例如排序,lists:sort([M1, .., Mn])
    • 内省,例如在打印时。
  • 有序映射对底层实现施加了限制,并且哈希方法几乎是不可能的。
  • 底层结构不需要排序,可以在需要时产生顺序。
    • M1 < M2 将导致内部排序,但会花费 O( N1 * lg N1 + N2 * lg N2 ),其中 N1 = maps:size(M1) and N2 = maps:size(M2)

访问单个值 #

我们需要进行单次访问还是匹配就足够了?

考虑以下情况:

V = M#{ K }

比以下情况短:

#{ K := V } = M

它还允许轻松访问深层结构中的关联值。

单值访问的语法是此提案中最不完善(和考虑)的功能,当然可以借鉴一些意见。

此外,必须废除点语法。目前,它用于记录,但不会用于映射。点表示最后一个子句中表达式列表的结尾,或属性的结尾。

它不能用于区分浮点数或关联。

示例

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

2> #M.1.1.
a | b ?

向后兼容性 #

使用映射编写的 Erlang 代码只能在 Erlang/OTP R17A 及更高版本的 Erlang/OTP 上解析、加载和执行,而不能在以前的版本上执行。

在 Erlang/OTP R17A 之前编写的 Erlang 代码将完全兼容,即可以使用这些映射更改进行解析、加载和执行。

分发将不向后兼容。

版权 #

本文档已置于公共领域。