附录:映射

关于本章

哦,你好。好久不见了。这是一个附加章节,尚未包含在《学点 Erlang,功德无量!》的印刷版中。为什么会有这个章节?印刷版是糟糕信息的堆砌吗?我希望不是(不过你可以查看勘误表,看看它有多么错误)。

编写本章的原因是,Erlang 数据类型动物园在 R17 版本中又增加了一些新的东西,重新编写整个东西来包含新类型会非常麻烦。R17 版本包含了许多变化,是几年来最大的一次版本更新。实际上,R17 版本应该叫做 17.0 版本,因为 Erlang/OTP 开始将其 R<Major>B<Minor> 版本方案改为不同的版本方案

无论如何,就像语言之前发生的大多数更新一样,我已经通过网站添加了一些注释和一些内容,以确保所有内容保持最新。但是,新添加的数据类型值得单独开一章来介绍。

A weird road-runner representing EEP-43

EEP!EEP!

将映射添加到语言中的道路漫长而曲折。多年来,人们一直在 Erlang 社区中针对记录和键值数据结构在多个方面抱怨。

最终出现了两个竞争提案。第一个是Richard O'Keefe 的框架,一个非常深入的关于如何替换记录的提案,以及 Joe Armstrong 的结构(O'Keefe 的提案简要介绍了它们并将其与框架进行了比较)。

后来,OTP 团队提出了Erlang 增强提案 43 (EEP-43),他们自己关于“类似”数据类型的提案。然而,就像社区的抱怨一样,这两个提案解决的完全是不同的问题:映射旨在作为非常灵活的哈希映射来替换 dictgb_trees,而框架旨在替换记录。理想情况下,映射也能够在某些用例中替换记录,但不能替换所有用例,同时保留记录的正面特征(正如我们将在三方对峙中看到的)。

映射是 OTP 团队选择实施的一个,而框架仍然以自己的提案存在,在某个地方。

映射的提案很全面,涵盖了许多用例,并强调了尝试让它们适合语言时遇到的许多设计问题,因此,它们的实现将是逐渐的。规范在映射将是什么中进行了描述,而临时实现则在早期版本的短腿中进行了描述。

映射将是什么

模块

映射是一种与 dict 数据结构在意图上类似的数据类型,并且被赋予了一个具有类似接口和语义的模块。支持以下操作

它还包含 fold/3map/2 这样的良好功能标准,它们的工作方式类似于 lists 模块中的函数。然后有一组实用程序函数,例如 size/1is_map/1(也是一个保护!)、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}]

排序的方式与列表和元组类似:首先按大小排序,然后按包含的元素排序。对于映射,这些元素是按键的排序顺序排列的,并使用值本身来打破平局。

不要喝太多酷乐 aid
您可能会注意到,虽然我们无法使用键 1 更新映射的键 1.0,但它们可以以这种方式进行比较,并被认为相等!这是 Erlang 的一个长期存在的弊病。虽然在某些时候让所有数字比较相等非常方便,例如,在排序列表时,以便 1.0 不大于 9121,但这在进行模式匹配时会产生令人困惑的期望。

例如,虽然我们可以期望 1 等于 1.0(尽管不完全相等,就像 =:= 一样),但我们不能期望通过执行 1 = 1.0 来进行模式匹配。

对于映射来说,这意味着 Map1 == Map2Map1 = Map2 不是同义词。因为 Erlang 映射尊重 Erlang 的排序顺序,所以像 #{1.0 => true} 这样的映射将与 #{1 => true} 进行比较,但您将无法将它们相互匹配。

小心,因为虽然本节的内容是基于 EEP-43 撰写的,但实际的实现可能落后于此!

早期版本的短腿

A Corgi with a cape, flying

与 Erlang 17.x 和 18.0 一起提供的映射实现是完整的,但仅限于映射模块的范围内。主要区别在于语法。只有最小的子集可用

其他内容,包括访问单个值 (Map#{key}),无论是在匹配中还是在声明中,都还没有实现。映射推导和 Dialyzer 支持也是如此。实际上,语法可能会改变,最终与 EEP 不同。

映射的性能仍然比大多数 Erlang 开发人员和 OTP 团队希望的要慢。不过,进展正在进行中,它不应该花费很长时间——尤其是在映射首次添加到语言中的时间相比——它们会变得更好很多。

这个早期版本仍然足以让您熟悉映射,更重要的是,了解何时何地使用它们。

三方对峙

每个人都有自己的观点,认为原生字典或更好的记录替换应该优先考虑。当地图被宣布时,许多 Erlang 开发人员都假设,当地图加入语言时,它们将解决他们想要解决的问题。

因此,关于如何在 Erlang 中使用地图存在一些混淆。

地图 vs. 记录 vs. 字典

直接说出来,地图是字典的替代品,而不是记录。这可能会让人困惑。在本章的开头,我列出了常见的抱怨,事实证明,许多关于记录的抱怨可以通过地图解决。

地图解决的问题 记录中的问题 字典中的问题
记录名称很笨拙  
没有好的原生 k/v 存储  
更快的 k/v 存储   18.x 及更高版本
使用原生类型更容易转换
更强大的模式匹配  
记录升级 也许  
可在不包含的情况下跨模块使用  
squid aiming two guns mexican-standoff-like, while eating a taco

分数非常接近。地图更快并不一定是真的,但优化应该能将它们提升到更好的水平。OTP 团队正在遵循旧的口号:首先让它工作,然后让它漂亮,只有在需要的时候才让它快。他们首先要解决语义和正确性问题。

对于记录升级被标记为“也许”的原因,这与 `code_change` 功能有关。许多用户对在更改版本时不得不公开转换记录感到厌烦,这类似于我们对 pq_player.erl 及其升级代码所做的事情。相反,地图将允许我们根据需要添加字段到条目中并继续进行。对此的反驳是,记录会因升级失败而尽早崩溃(这很好!),而地图的升级失败可能会导致类似于数据损坏的问题,直到太晚才出现。

那么为什么我们应该将地图用作字典而不是记录呢?为此,需要第二个表格。这个表格是关于语义,以及功能应用于哪个数据结构或数据类型。

操作 记录 地图 字典
不可变
任何类型的键
可用于地图/折叠
内容对其他模块不透明
有模块可以用来使用它
支持模式匹配
所有键在编译时已知
可以与其他实例合并
测试键是否存在
通过键提取值
每个键 Dialyzer 类型检查*
从列表转换到列表
每个元素的默认值
运行时的独立数据类型
快速的直接索引访问

* EEP 建议在编译时已知键的情况下使此功能成为可能,但尚未确定何时或是否会实现。

此图表清楚地表明,尽管地图和记录的语法相似,但从语义角度来看,字典和地图比地图与记录更接近。因此,使用地图来替换记录类似于试图用哈希地图替换像 C 这样的语言中的“结构”。这并非不可能,但这并不意味着这是一个好主意,因为它们通常有不同的用途。

键在编译时已知,这带来了快速访问特定值的优势(比动态访问速度更快)、额外的安全性(尽早崩溃而不是损坏状态)以及更轻松的类型检查。这些使得记录绝对适合进程的内部状态,尽管偶尔需要编写更详细的 `code_change` 函数。

另一方面,当 Erlang 用户使用记录来表示复杂嵌套的键/值数据结构(奇怪地类似于面向对象语言中的对象)时,这些数据结构会经常跨模块边界,地图将提供很大帮助。记录是错误的工具。

简而言之,记录确实感觉不合适且笨拙的地方可以用地图替换,但大多数记录使用不应该以这种方式替换。

不要喝太多酷爱
人们往往会将复杂的嵌套数据结构引入聚会,并将它们用作一个大的透明对象,在函数或进程之间传递,然后只使用模式匹配来提取所需的内容。

但是,请记住,在消息传递中执行这些操作是有代价的:Erlang 数据会跨进程复制,以这种方式工作可能很昂贵。

同样,在函数之间传递大型透明对象应谨慎操作。构建一个明智的接口(或 API)本身就很难;如果你把自己束缚在你的内部数据表示形式上,实现的灵活性可能会大幅下降。

最好认真考虑你的接口和基于消息的协议,并限制共享的信息量和责任。地图是字典的替代品,而不是适当设计的替代品。

还应该预期会对性能产生影响。Richard O'Keefe 在他的提议中提到了这一点。

你不能让字典适合框架旨在用于的类似记录的用途,而不会让它不适合其现有用途。

OTP 团队的 EEP 也提到了类似的东西。

与记录相比,地图的缺点很容易弥补,但[sic]积极的影响并不容易在内置数据类型中复制,因为值是在运行时而不是在编译时确定的。

  • 要比直接索引数组更快,其中索引和可能的结果值是在编译时确定的,这是很困难的。事实上,这是不可能的。
  • 通过本质上使用两个元组,一个用于键,另一个用于值,如框架中所示,可以实现接近记录效率的地图内存模型。这会影响具有大量条目的地图的更新性能,从而限制字典方法的能力。

对于你的进程循环的核心,当你了解所有应该存在的键时,从性能角度来看,记录将是一个明智的选择。

地图 vs. 属性列表

地图可能胜过的一件事是属性列表。属性列表是一个相当简单的结构,非常适合传递给模块的选项。

例如,`inet:setopts/2` 调用可以接受以下形式的选项列表:`[{active, true}, binary]`,文件处理函数可以接受诸如 `[read, write, append, {encoding, utf8}]` 之类的参数列表。这两个选项列表都可以使用 `proplists` 模块读取,并且诸如 `write` 之类的项将被扩展,就好像它们被写成 `{write, true}` 一样。

a scale measuring proplists vs. maps

地图,凭借其单键访问(无论何时实现),将提供一种类似的方式来定义属性。例如,`[{active, true}]` 可以用地图表示为 `#{active => true}`。这同样繁琐,但它会使读取选项变得更加简单,因为你将不需要进行模块调用(感谢 `Opts#{active}`)。

可以预料,大多数是配对的选项列表将被地图替换。另一方面,从用户的角度来看,像 `read`、`write` 或 `append` 这样的文字选项将使用属性列表更漂亮。

鉴于现在大多数需要选项的函数使用属性列表,为了保持一致性,继续这种方式可能很有趣。另一方面,当选项主要是配对时,使用 `proplists` 模块很快就会变得很繁琐。最终,库的作者必须在实现的内部清晰度或通用生态系统一致性之间做出选择。或者,作者可以决定同时支持两种方式,以便提供一条通往属性列表弃用的平滑路径,如果他们这么认为的话。

另一方面,以前将属性列表作为函数返回值传递的函数可能应该切换到地图。这里几乎没有遗留问题,并且这种方式对用户来说更易用。

注意:地图可以使用一个巧妙的技巧轻松地一次设置多个默认值。而属性列表需要使用 `proplists:get_value(Key, List, Default)`,地图可以使用它们的 `merge/2` 函数。

根据规范,`merge/2` 将合并两个地图,如果两个键相同,则第二个地图的值将优先。这允许调用 `maps:merge(Default, YourMap)` 并获得所需的值。例如,`maps:merge(#{key => default, other => ok}, #{other => 42})` 将得到地图 `#{key => default, other => 42}`。

这提供了一种非常方便的方式来手动分配默认值,然后你可以直接使用结果地图,而无需担心缺少键。

这本书将如何为地图修改

我想添加这一节,因为我目前没有时间将整本书更新,以将地图重新整合进来。

但是,对于本书的大部分内容,几乎不会有任何改变。它主要是在使用 `dict` 模块和 `gb_trees`(在不需要最小值/最大值的情况下)时,用内联地图语法替换它们。为了语义的缘故,本书中使用的几乎所有记录都不会改变。

一旦地图稳定并完全实现,我可能会重新审视一些模块,但在目前,由于地图实现不完整,许多示例无法以这种方式编写。