查看源代码 关键字列表和映射

现在让我们谈谈关联数据结构。关联数据结构能够将一个键与某个值关联起来。不同的语言称它们为不同的名字,如字典、哈希、关联数组等。

在 Elixir 中,我们有两个主要的关联数据结构:关键字列表和映射。

关键字列表

关键字列表是一种用于向函数传递选项的数据结构。假设您想分割一个数字字符串。我们可以使用 String.split/2

iex> String.split("1 2 3", " ")
["1", "2", "3"]

但是,如果数字之间有一个额外的空格会发生什么情况

iex> String.split("1  2  3", " ")
["1", "", "2", "", "3"]

正如您所见,现在我们的结果中出现了空字符串。幸运的是,String.split/3 函数允许将 trim 选项设置为 true

iex> String.split("1  2  3", " ", [trim: true])
["1", "2", "3"]

[trim: true] 是一个关键字列表。此外,当关键字列表是函数的最后一个参数时,我们可以跳过方括号并写成

iex> String.split("1  2  3", " ", trim: true)
["1", "2", "3"]

如上例所示,关键字列表主要用作函数的可选参数。

顾名思义,关键字列表仅仅是列表。特别是,它们是由 2 元素元组组成的列表,其中第一个元素(键)是原子,第二个元素可以是任何值。两种表示方式是相同的

iex> [{:trim, true}] == [trim: true]
true

由于关键字列表是列表,我们可以对列表使用所有可用操作。例如,我们可以使用 ++ 向关键字列表添加新值

iex> list = [a: 1, b: 2]
[a: 1, b: 2]
iex> list ++ [c: 3]
[a: 1, b: 2, c: 3]
iex> [a: 0] ++ list
[a: 0, a: 1, b: 2]

您可以使用方括号语法读取关键字列表的值。这也被称为访问语法,因为它是由 Access 模块定义的

iex> list[:a]
1
iex> list[:b]
2

如果键重复,则添加到最前面的值是获取到的值

iex> new_list = [a: 0] ++ list
[a: 0, a: 1, b: 2]
iex> new_list[:a]
0

关键字列表很重要,因为它们具有三个特殊的特性

  • 键必须是原子。
  • 键是有序的,如开发人员指定的那样。
  • 键可以出现多次。

例如,Ecto 库 利用这些特性为编写数据库查询提供了一个优雅的 DSL

query =
  from w in Weather,
    where: w.prcp > 0,
    where: w.temp < 20,
    select: w

虽然我们可以对关键字列表进行模式匹配,但在实践中并没有这样做,因为对列表进行模式匹配需要项目数量和顺序匹配

iex> [a: a] = [a: 1]
[a: 1]
iex> a
1
iex> [a: a] = [a: 1, b: 2]
** (MatchError) no match of right hand side value: [a: 1, b: 2]
iex> [b: b, a: a] = [a: 1, b: 2]
** (MatchError) no match of right hand side value: [a: 1, b: 2]

此外,鉴于关键字列表通常用作可选参数,它们在可能并非所有键都存在的情况下使用,这将使其无法与它们匹配。简而言之,不要对关键字列表进行模式匹配。

为了操作关键字列表,Elixir 提供了 Keyword 模块。请记住,关键字列表仅仅是列表,因此它们提供了与列表相同的线性性能特征:列表越长,查找键、计算项目数量等操作所需的时间就越长。如果您需要在一个键值数据结构中存储大量的键,Elixir 提供了映射,我们很快就会学习。

do 块和关键字

正如我们所见,关键字主要在语言中用于传递可选值。事实上,我们之前在本指南中使用过关键字。例如,我们已经看到了

iex> if true do
...>   "This will be seen"
...> else
...>   "This won't"
...> end
"This will be seen"

事实是,do 块仅仅是在关键字之上的语法便利。我们可以将上面的代码改写为

iex> if true, do: "This will be seen", else: "This won't"
"This will be seen"

请仔细注意这两种语法。在关键字列表格式中,我们用逗号分隔每个键值对,每个键后面跟着 :。在 do 块中,我们去掉了冒号、逗号,并将每个关键字用换行符分隔。它们之所以有用,正是因为它们在编写代码块时去掉了冗余。大多数情况下,您将使用块语法,但了解它们是等价的非常重要。

这在语言中扮演着重要的角色,因为它允许 Elixir 语法保持简洁,同时仍然具有表达能力。我们只需要很少的数据结构来表示语言,这个话题我们将在谈论 可选语法 时再讨论,并在讨论 元编程 时深入探讨。

有了这些知识,让我们谈谈映射。

映射作为键值对

无论何时您需要存储键值对,映射都是 Elixir 中的“首选”数据结构。映射使用 %{} 语法创建

iex> map = %{:a => 1, 2 => :b}
%{2 => :b, :a => 1}
iex> map[:a]
1
iex> map[2]
:b
iex> map[:c]
nil

与关键字列表相比,我们已经可以看到两个区别

  • 映射允许任何值作为键。
  • 映射的键不遵循任何顺序。

与关键字列表相比,映射在模式匹配中非常有用。当一个映射在模式中使用时,它将始终匹配给定值的子集

iex> %{} = %{:a => 1, 2 => :b}
%{2 => :b, :a => 1}
iex> %{:a => a} = %{:a => 1, 2 => :b}
%{2 => :b, :a => 1}
iex> a
1
iex> %{:c => c} = %{:a => 1, 2 => :b}
** (MatchError) no match of right hand side value: %{2 => :b, :a => 1}

如上所示,只要模式中的键存在于给定的映射中,映射就会匹配。因此,空映射匹配所有映射。

Map 模块提供了一个与 Keyword 模块非常相似的 API,具有添加、删除和更新映射键的便利函数

iex> Map.get(%{:a => 1, 2 => :b}, :a)
1
iex> Map.put(%{:a => 1, 2 => :b}, :c, 3)
%{2 => :b, :a => 1, :c => 3}
iex> Map.to_list(%{:a => 1, 2 => :b})
[{2, :b}, {:a, 1}]

预定义键的映射

在上一节中,我们使用映射作为键值数据结构,其中键可以在任何时候添加或删除。但是,通常也需要创建具有预定义键集的映射。它们的值可以更新,但从不添加或删除新键。当我们知道正在处理的数据的形状时,这很有用,如果我们得到一个不同的键,则可能意味着在其他地方发生了错误。

我们使用与上一节相同的语法定义这些映射,除了所有键必须是原子

iex> map = %{:name => "John", :age => 23}
%{name: "John", age: 23}

正如您从上面打印的结果中看到的那样,Elixir 也允许您使用与关键字列表相同的 key: value 语法编写原子键的映射。

当键是原子时,特别是在处理预定义键的映射时,我们也可以使用 map.key 语法访问它们

iex> map = %{name: "John", age: 23}
%{name: "John", age: 23}

iex> map.name
"John"
iex> map.agee
** (KeyError) key :agee not found in: %{name: "John", age: 23}

还有一种更新键的语法,如果键尚未定义,它也会引发错误

iex> %{map | name: "Mary"}
%{name: "Mary", age: 23}
iex> %{map | agee: 27}
** (KeyError) key :agee not found in: %{name: "John", age: 23}

这些操作有一个很大的好处,即如果键不存在于映射中,它们会引发错误,编译器甚至可能在可能的情况下检测到并发出警告。这使得它们在获得快速反馈并及早发现错误和拼写错误方面非常有用。这也是用于为 Elixir 的另一个功能(称为“结构体”)提供动力的语法,我们将在后面学习。

Elixir 开发人员通常更喜欢在处理映射时使用 map.key 语法和模式匹配,而不是 Map 模块中的函数,因为它们会导致一种断言式编程风格。José Valim 的这篇博客文章 提供了关于如何在 Elixir 中编写断言式代码以获得更简洁、更快的软件的见解和示例。

嵌套数据结构

通常我们会将映射放在映射中,甚至将关键字列表放在映射中,等等。Elixir 通过 put_in/2update_in/2 等宏提供了操作嵌套数据结构的便利,这些宏提供了与您在命令式语言中找到的相同的便利,同时保持了语言的不可变属性。

假设您有以下结构

iex> users = [
  john: %{name: "John", age: 27, languages: ["Erlang", "Ruby", "Elixir"]},
  mary: %{name: "Mary", age: 29, languages: ["Elixir", "F#", "Clojure"]}
]
[
  john: %{age: 27, languages: ["Erlang", "Ruby", "Elixir"], name: "John"},
  mary: %{age: 29, languages: ["Elixir", "F#", "Clojure"], name: "Mary"}
]

我们有一个用户的关键字列表,其中每个值都是一个包含姓名、年龄和每个用户喜欢的编程语言列表的映射。如果我们想访问 john 的年龄,我们可以写成

iex> users[:john].age
27

事实是,我们也可以使用相同的语法更新值

iex> users = put_in users[:john].age, 31
[
  john: %{age: 31, languages: ["Erlang", "Ruby", "Elixir"], name: "John"},
  mary: %{age: 29, languages: ["Elixir", "F#", "Clojure"], name: "Mary"}
]

update_in/2 宏类似,但允许我们传递一个控制值如何变化的函数。例如,让我们从 Mary 的语言列表中删除“Clojure”

iex> users = update_in users[:mary].languages, fn languages -> List.delete(languages, "Clojure") end
[
  john: %{age: 31, languages: ["Erlang", "Ruby", "Elixir"], name: "John"},
  mary: %{age: 29, languages: ["Elixir", "F#"], name: "Mary"}
]

关于 put_in/2update_in/2 还有更多内容要学习,包括 get_and_update_in/2,它允许我们同时提取一个值并更新数据结构。还有 put_in/3update_in/3get_and_update_in/3,它们允许动态访问数据结构。

总结

在 Elixir 中,有两种不同的数据结构用于处理键值存储。它们与 Access 模块和模式匹配一起,提供了一套丰富的工具来操作复杂、可能嵌套的數據結構。

在结束本章时,要记住的重要一点是,您应该

  • 将关键字列表用于向函数传递可选值

  • 将映射用于一般键值数据结构

  • 在处理具有预定义键集的数据时使用映射

现在让我们谈谈模块和函数。