第 4 章 数据抽象(Data abstractions)
本章涵盖内容:
- 使用模块进行抽象
- 处理层级数据
- 使用协议实现多态
本章讨论如何构建更高层次的数据结构。在任何复杂系统中,都会需要一些抽象,例如 Money、Date、Employee 和 OrderItem。这些都是高层抽象的教科书级例子,通常不会由语言直接支持,而是写在内置类型之上。
在 Elixir 中,这类抽象通过纯粹、无状态的模块实现。本章中,你会学习如何创建并使用自己的抽象。
在典型面向对象语言中,基础抽象构建块是类和对象。例如,可能存在一个 String 类,它实现各种字符串操作。每个字符串都是该类的一个实例,可以通过调用方法来操作,就像下面的 Ruby 片段所示:
"a string".upcase
Elixir 通常不使用这种方式。作为函数式语言,Elixir 鼓励把数据与代码解耦。你使用模块而不是类;模块是一组函数。你不会在对象上调用方法,而是显式调用模块函数,并通过参数提供输入数据。下面的片段展示了 Elixir 将字符串转为大写的方式:
String.upcase("a string")
与面向对象语言的另一个重要差异是数据不可变。要修改数据,必须调用某个函数,并把它的结果放进变量;原始数据会保持不变。下面的例子展示了这个技巧:
iex(1)> list = []
[]
iex(2)> list = List.insert_at(list, -1, :a)
[:a]
iex(3)> list = List.insert_at(list, -1, :b)
[:a, :b]
iex(4)> list = List.insert_at(list, -1, :c)
[:a, :b, :c]
在这些例子中,你不断保留最后一次操作的结果,并把它传给下一次操作。
两个 Elixir 片段中值得注意的重要一点是:模块被用作数据类型之上的抽象。当需要处理字符串时,你会使用 String 模块。当需要处理列表时,你会使用 List 模块。
String 和 List 是专用于特定数据类型的模块示例。它们用纯 Elixir 实现,其函数依赖输入数据的预定义格式。String 函数期望第一个参数是 binary 字符串,而 List 函数期望列表。
此外,修改函数,也就是转换数据的函数,会返回同一类型的数据。函数 String.upcase/1 返回 binary 字符串,而 List.insert_at/3 返回列表。
最后,模块还包含查询函数,用于从数据中返回某些信息,例如 String.length/1 和 List.first/1。这类函数仍然期望抽象实例作为第一个参数,但它们会返回另一种类型的信息。
Elixir 中抽象的基本原则可以总结如下:
- 模块负责抽象某种行为。
- 模块函数通常期望抽象实例作为第一个参数。
- 修改函数返回抽象的修改版本。
- 查询函数返回其他类型的数据。
基于这些原则,创建自己的高层抽象会相当直接,下一节就会看到。
4.1 使用模块进行抽象(Abstracting with modules)
列表和字符串是较低层的类型,而高层抽象建立在前面列出的原则之上。事实上,你在第 2 章已经见过高层抽象的例子。例如,MapSet 模块实现了集合。MapSet 用纯 Elixir 实现,可以作为在 Elixir 中设计抽象的良好模板。
看看一个使用 MapSet 的例子:
iex(1)> days =
...> MapSet.new()
...> |> MapSet.put(:monday)
...> |> MapSet.put(:tuesday)
iex(2)> MapSet.member?(days, :monday)
true
这种方式基本遵循前面列出的原则。代码通过管道操作符把操作串联在一起,因此略微简化。之所以可行,是因为 MapSet 模块中的所有函数都把集合当作第一个参数。这类函数对管道友好,可以用 |> 操作符串联。
注意 new/0 函数,它会创建一个新的抽象实例。这个函数本身没有什么特殊之处,也可以命名为别的名字。它唯一的目的就是创建一个可供你操作的新数据结构。
因为 MapSet 是一个抽象,所以作为该模块客户端的你,并不关心它的内部工作方式或数据结构。你调用 MapSet 函数,保留得到的结果,并把该结果传回同一个模块中的函数。
注意 你可能会认为
MapSet这样的抽象类似用户定义类型。虽然它们有很多相似之处,但基于模块的抽象并不是第 2 章解释过的那类真正数据类型。相反,它们通过组合内置数据类型实现。例如,MapSet实例也是一个映射,这可以通过调用is_map(MapSet.new())验证。
有了这个模板,来尝试构建一个简单抽象。
4.1.1 基本抽象(Basic abstraction)
本节示例是一个简单待办列表。这个问题确实不算惊艳,但它足够复杂,可以给你一些可操作内容,同时又不会过于复杂。这能让你专注于技巧,而不用花太多时间理解问题本身。
待办列表的基本版本将支持以下特性:
- 创建新的待办列表
- 向列表添加新条目
- 查询列表
下面是期望的使用示例:
$ iex simple_todo.ex
iex(1)> todo_list =
...> TodoList.new()
...> |> TodoList.add_entry(~D[2023-12-19], "Dentist")
...> |> TodoList.add_entry(~D[2023-12-20], "Shopping")
...> |> TodoList.add_entry(~D[2023-12-19], "Movies")
iex(2)> TodoList.entries(todo_list, ~D[2023-12-19])
["Movies", "Dentist"]
iex(3)> TodoList.entries(todo_list, ~D[2023-12-18])
[]
这相当直观。你通过调用 TodoList.new/0 创建实例,然后添加一些条目。最后执行一些查询。表达式 ~D[2023-12-19] 如 2.4.11 节所解释,会创建一个由 Date 模块提供支持的日期,即 2023 年 12 月 19 日。
随着本章推进,你会增加额外特性并稍微修改接口。本书后续还会继续添加特性,到最后,你会拥有一个完全可工作的分布式 Web 服务器,能够管理大量待办列表。
目前,先从这个简单接口开始。首先,必须决定内部数据表示。从前面的片段可以看到,主要用例是查找某一天的所有条目。因此,使用映射似乎是一个合理的初始方法。你会使用日期作为键,值则是给定日期的条目列表。基于这一点,new/0 函数的实现很直接。
代码清单 4.1 初始化待办列表(simple_todo.ex)
defmodule TodoList do
def new(), do: %{}
# ...
end
接下来,必须实现 add_entry/3 函数。该函数期望一个待办列表,也就是你知道的映射,并必须把条目添加到给定键(日期)下的列表中。当然,可能还不存在该日期的条目,所以也要覆盖这种情况。事实证明,只要调用一次 Map.update/4 就能完成。
代码清单 4.2 添加条目(simple_todo.ex)
defmodule TodoList do
# ...
def add_entry(todo_list, date, title) do
Map.update(
todo_list,
date,
[title],
fn titles -> [title | titles] end
)
end
# ...
end
Map.update/4 函数接收映射、键、初始值和更新函数。如果给定键不存在值,就使用初始值;否则调用更新函数。该函数接收现有值,并返回该键的新值。在这个例子中,你把新条目推到列表顶部。你可能还记得第 2 章提到过,列表在把新元素推到顶部时效率最高。因此,这里选择了快速插入操作,但牺牲了顺序,后添加的条目会位于列表中较早条目之前。
最后,需要实现 entries/2 函数。它会返回给定日期的所有条目;如果该日期不存在任务,则返回空列表。这相当直接,如下面的代码清单所示。
代码清单 4.3 查询待办列表(simple_todo.ex)
defmodule TodoList do
# ...
def entries(todo_list, date) do
Map.get(todo_list, date, [])
end
end
你从 todo_list 中获取给定日期的值,而 todo_list 必须是映射。Map.get/3 的第三个参数是默认值,在映射中不存在给定键时返回。
4.1.2 组合抽象(Composing abstractions)
没有什么能阻止你在一个抽象之上创建另一个抽象。在我们最初的待办列表实现中,有机会把部分代码移到一个单独抽象中。
看看你对映射的操作方式:允许一个键下存储多个值,并获取该键的所有值。这段代码可以移到单独抽象中。我们把它称为 MultiDict,下面的代码清单给出实现。
代码清单 4.4 实现 MultiDict 抽象(todo_multi_dict.ex)
defmodule MultiDict do
def new(), do: %{}
def add(dict, key, value) do
Map.update(dict, key, [value], &[value | &1])
end
def get(dict, key) do
Map.get(dict, key, [])
end
end
这或多或少是初始待办列表实现的复制粘贴。名字稍微改了一些,并且使用捕获操作符缩短了更新 lambda 定义:&[value | &1]。
有了这个抽象,TodoList 模块会简单得多。
代码清单 4.5 基于 MultiDict 的 TodoList(todo_multi_dict.ex)
defmodule TodoList do
def new(), do: MultiDict.new()
def add_entry(todo_list, date, title) do
MultiDict.add(todo_list, date, title)
end
def entries(todo_list, date) do
MultiDict.get(todo_list, date)
end
end
这是经典的关注点分离:把一项独立职责抽取到单独抽象中,然后在其上创建另一个抽象。现在,独立的 MultiDict 抽象已经可以在代码的其他地方使用了。此外,可以继续给 TodoList 增加待办列表特有的额外函数,而这些函数并不属于 MultiDict。
这个重构的重点是说明代码组织并没有和面向对象方法有根本差异。你使用不同工具创建抽象(模块和函数,而不是类和方法),但总体思路相同。
4.1.3 使用映射组织数据(Structuring data with maps)
TodoList 现在支持基本特性。你可以向结构中插入条目,也可以获取给定日期的所有条目,但接口有点笨拙。添加新条目时,必须把每个字段作为单独参数指定:
TodoList.add_entry(todo_list, ~D[2023-12-19], "Dentist")
如果想给条目增加另一个属性,例如时间,就必须修改函数签名,而这又会破坏所有客户端。此外,还必须修改实现中传播这份数据的每个位置。这个问题的明显解决方案,是以某种方式把所有条目字段组合成一个单一抽象。
正如 2.4.6 节所解释,在 Elixir 中最常见的方式是使用映射,并把字段名存储为原子类型的键。下面的片段展示了如何创建和使用条目实例:
iex(1)> entry = %{date: ~D[2023-12-19], title: "Dentist"}
iex(2)> entry.date
~D[2023-12-19]
iex(3)> entry.title
"Dentist"
可以立即调整代码,用映射表示条目。事实证明,这个修改极其简单。只需要把 TodoList.add_entry 函数改成接收两个参数:一个待办列表实例,以及一个描述条目的映射。新版本如下。
代码清单 4.6 使用映射表示条目(todo_entry_map.ex)
defmodule TodoList do
# ...
def add_entry(todo_list, entry) do
MultiDict.add(todo_list, entry.date, entry)
end
# ...
end
很简单!你假设 entry 是一个映射,并用它的 date 字段作为键,把它添加到 MultiDict 中。
看看实际效果。要添加新条目,客户端现在必须提供一个映射:
iex(1)> todo_list =
...> TodoList.new()
...> |> TodoList.add_entry(%{date: ~D[2023-12-19], title: "Dentist"})
客户端代码显然更冗长,因为它必须提供字段名。不过,因为条目现在组织在映射中,数据检索得到了改善。TodoList.entries/2 函数现在返回完整条目,而不仅是标题:
iex(2)> TodoList.entries(todo_list, ~D[2023-12-19])
[%{date: ~D[2023-12-19], title: "Dentist"}]
当前 TodoList 实现依赖映射。这意味着运行时无法区分映射和 TodoList 实例。在某些情况下,你可能想定义并强制执行更精确的结构定义。为此,Elixir 提供了名为 struct 的特性。
4.1.4 使用 struct 抽象(Abstracting with structs)
假设你需要在程序中处理分数。分数是整体的一部分,形式为 a/b,其中 a 和 b 是称为分子和分母的整数。单独传递这两个值既嘈杂又容易出错。因此,引入一个小抽象来帮助处理分数是有意义的。下面的片段展示了这种抽象可以如何使用:
$ iex fraction.ex
iex(1)> Fraction.new(1, 2)
...> |> Fraction.add(Fraction.new(1, 4))
...> |> Fraction.value()
0.75
这里,你把二分之一(1/2)和四分之一(1/4)相加,并返回所得分数的数值。分数通过 Fraction.new/2 创建,然后传给各种知道如何处理它的其他函数。
如何实现它?有很多方法,例如依赖普通元组或使用映射。此外,Elixir 提供了一种名为 struct 的设施,允许预先指定抽象结构,并把它绑定到一个模块。每个模块只能定义一个 struct,之后可以用它创建新实例并进行模式匹配。
分数有明确结构,所以可以用 struct 指定并强制数据形状。来看看实际做法。
要定义 struct,可以使用 defstruct 宏(https://hexdocs.pm/elixir/Kernel.html#defstruct/1)。
代码清单 4.7 定义结构(fraction.ex)
defmodule Fraction do
defstruct a: nil, b: nil
# ...
end
传给 defstruct 的关键字列表定义了 struct 的字段及其初始值。现在可以用这个特殊语法实例化 struct:
iex(1)> one_half = %Fraction{a: 1, b: 2}
%Fraction{a: 1, b: 2}
注意,struct 带有定义它的模块名。struct 和模块之间关系紧密。struct 只能存在于模块中,而单个模块只能定义一个 struct。
在内部,struct 是一种特殊映射。因此,可以像映射一样访问各个字段:
iex(2)> one_half.a
1
iex(3)> one_half.b
2
struct 的好处是可以对它进行模式匹配:
iex(4)> %Fraction{a: a, b: b} = one_half
%Fraction{a: 1, b: 2}
iex(5)> a
1
iex(6)> b
2
这使得断言某个变量确实是一个 struct 成为可能:
iex(6)> %Fraction{} = one_half
%Fraction{a: 1, b: 2}
iex(7)> %Fraction{} = %{a: 1, b: 2}
** (MatchError) no match of right hand side value: %{a: 1, b: 2}
这里,你使用 %Fraction{} 模式匹配任意 Fraction struct,不管其内容如何。struct 的模式匹配方式与映射非常相似。这意味着在模式匹配中,只需要指定感兴趣的字段,忽略所有其他字段。
更新 struct 的方式也类似映射:
iex(8)> one_quarter = %Fraction{one_half | b: 4}
%Fraction{a: 1, b: 4}
这段代码基于原始 struct 实例(one_half)创建一个新 struct 实例,并把字段 b 的值改为 4。
struct 的形状在编译时定义。因此,Elixir 编译器可以捕获某些错误。例如,假设我们把字段名拼错:
iex(9)> %Fraction{a: 1, d: 2}
** (KeyError) key :d not found
struct 没有指定字段 :d,因此错误会被报告。相比之下,如果使用常规映射,这段代码会成功。不过,程序会在较远的地方以不明显的原因失败,让错误更难调试。
值得注意的是,这个错误会在编译时报告。如果在源文件中犯同样错误,代码甚至无法编译。
掌握这些知识后,给 Fraction 抽象添加一些功能。首先,需要提供创建函数。
代码清单 4.8 实例化分数(fraction.ex)
defmodule Fraction do
# ...
def new(a, b) do
%Fraction{a: a, b: b}
end
# ...
end
这只是围绕 %Fraction{} 语法的简单包装。它让客户端代码更清晰,也减少了与 struct 使用事实的耦合。
接下来,实现 Fraction.value/1 函数,它返回分数的浮点表示。
代码清单 4.9 计算分数值(fraction.ex)
defmodule Fraction do
# ...
def value(%Fraction{a: a, b: b}) do
a / b
end
# ...
end
value/1 函数匹配一个分数,把它的字段取入单独变量,并用它们计算最终结果。模式匹配的好处是输入类型会被强制检查。如果传入任何不是分数实例的东西,就会得到匹配错误。
也可以不把字段分解到变量,而是使用点号:
def value(fraction) do
fraction.a / fraction.b
end
这个版本可以说更清晰,但反过来说,它接受任何映射,而不仅是 Fraction struct,这可能导致微妙 bug。例如,假设存在一个拥有相同字段的 Rectangle struct。你可能意外地把这样的 struct 传给这个函数,函数不会失败,而是返回某个毫无意义的结果。
最后一件事是实现 add 函数。
代码清单 4.10 两个分数相加(fraction.ex)
defmodule Fraction do
# ...
def add(%Fraction{a: a1, b: b1}, %Fraction{a: a2, b: b2}) do
new(
a1 * b2 + a2 * b1,
b2 * b1
)
end
# ...
end
现在可以测试分数:
iex(1)> Fraction.new(1, 2)
...> |> Fraction.add(Fraction.new(1, 4))
...> |> Fraction.value()
0.75
代码按预期工作。通过用 struct 表示分数,可以提供类型定义,列出所有字段及其默认值。此外,还可以区分 struct 实例和任何其他数据类型。这允许你在函数参数中放置 %Fraction{} 匹配,从而断言自己只接受分数实例。
struct 与映射
你应该始终记住,struct 只是映射,因此它们在性能和内存使用方面具有相同特征。但 struct 实例会受到特殊对待。某些可以对映射做的事情不能对 struct 做。例如,不能在 struct 上调用 Enum 函数:
iex(1)> one_half = Fraction.new(1, 2)
iex(2)> Enum.to_list(one_half)
** (Protocol.UndefinedError) protocol Enumerable not implemented for
%Fraction{a: 1, b: 2}
记住,struct 是函数式抽象,因此应该根据定义它的模块实现来表现。对 Fraction 抽象来说,你必须定义 Fraction 是否是 enumerable;如果是,还要定义以何种方式。如果没有这样做,Fraction 就不是 enumerable,因此不能对它调用 Enum 函数。
相比之下,普通映射是 enumerable,所以可以把它转换成列表:
iex(3)> Enum.to_list(%{a: 1, b: 2})
[a: 1, b: 2]
另一方面,因为 struct 是映射,所以直接调用 Map 函数可以工作:
iex(4)> Map.to_list(one_half)
[__struct__: Fraction, a: 1, b: 2]
注意 __struct__: Fraction 这部分。这个键值对会自动包含在每个 struct 中。它帮助 Elixir 区分 struct 和普通映射,并在多态泛型代码中执行正确的运行时分发。稍后讨论协议时,你会学到更多相关内容。
__struct__ 字段对模式匹配有一个重要后果。struct 模式不能匹配普通映射:
iex(5)> %Fraction{a: a, b: b} = %{a: 1, b: 2}
** (MatchError) no match of right hand side value: %{a: 1, b: 2}
不过,普通映射模式可以匹配 struct:
iex(5)> %{a: a, b: b} = %Fraction{a: 1, b: 2}
%Fraction{a: 1, b: 2}
iex(6)> a
1
iex(7)> b
2
这是由映射模式匹配的工作方式导致的。记住,模式中的所有字段都必须存在于被匹配项中。当把映射匹配到 struct 模式时,这一点不成立,因为 %Fraction{} 包含 __struct__ 字段,而被匹配映射中没有这个字段。
反过来则可以,因为你把 struct 匹配到 %{a: a, b: b} 模式。由于这些字段都存在于 Fraction struct 中,所以匹配成功。
records
除了映射和 struct,还有另一种组织数据的方式:record。这个特性让你可以使用元组,同时仍然能按名称访问各个元素。record 可以使用 Record 模块中的 defrecord 和 defrecordp 宏定义(https://hexdocs.pm/elixir/Record.html)。
因为本质上是元组,record 应该比映射更快,尽管差异在大局中通常并不显著。反过来,它的使用更冗长,并且无法动态地按名称访问字段。
record 的存在主要是历史原因。在映射被使用之前,record 是组织数据的主要工具之一。事实上,Erlang 生态系统中的许多库都使用 record 作为接口。如果需要接入某个使用该库中定义 record 的 Erlang 库,就必须把该 record 导入 Elixir,并定义为 record。这可以结合 defrecord 宏使用 Record.extract/2 函数完成。这个惯用法并不经常需要,因此这里不会演示 record。不过,把这条信息放在脑后,在需要时再研究它,可能会很有用。
4.1.5 数据透明性(Data transparency)
到目前为止设计的模块都是抽象,因为客户端不知道它们的实现细节。例如,作为客户端,你调用 Fraction.new/2 创建抽象实例,然后把该实例发送给同一模块中的其他函数。
不过,整个数据结构始终可见。作为客户端,即使库开发者并不希望这样,你也可以获取单独的分数值。
重要的是要意识到,Elixir 中的数据始终是透明的。客户端可以读取 struct(以及任何其他数据类型)中的任何信息,并且没有简单方式阻止这一点。从这个意义上说,封装的工作方式不同于典型面向对象语言。在 Elixir 中,模块负责抽象数据,并提供操作和查询该数据的函数,但数据永远不会被隐藏。
在 shell 会话中验证一下:
$ iex todo_entry_map.ex
iex(1)> todo_list =
...> TodoList.new()
...> |> TodoList.add_entry(%{date: ~D[2023-12-19], title: "Dentist"})
%{~D[2023-12-19] => [%{date: ~D[2023-12-19], title: "Dentist"}]}
看看返回值,就能看到待办列表的整个结构。从输出中可以立即看出,待办列表由映射支持,也能发现单独条目如何保存的细节。
再看另一个例子。MapSet 实例也是一个抽象,由 MapSet 模块和对应 struct 提供支持。乍看之下,这并不明显:
iex(1)> mapset = MapSet.new([:monday, :tuesday])
MapSet.new([:monday, :tuesday])
注意表达式结果如何以特殊方式打印,使用了 MapSet.new(...) 输出。这是由于 Elixir 中的 inspection 机制:每当结果在 shell 中打印时,函数 Kernel.inspect/1 会被调用,把结构转换成 inspected 字符串。对于构建的每个抽象,你都可以覆盖默认行为,并提供自己的 inspected 格式。这正是 MapSet 所做的;稍后讨论协议时,你会学习如何为类型做到这一点。
有时,你可能想看到纯数据结构,而不是这种经过装饰的输出。当调试、分析或逆向工程代码时,这会很有用。为此,可以给 inspect 函数提供一个特殊选项:
iex(2)> IO.puts(inspect(mapset, structs: false))
%{__struct__: MapSet, map: %{monday: [], tuesday: []}, version: 2}
现在输出揭示了日期的完整结构,你可以“看穿”MapSet 抽象。这证明 Elixir 中不能完全强制数据隐私。回忆第 2 章,唯一的复杂类型是元组、列表和映射。任何其他抽象,例如 MapSet 或你自己的 TodoList,最终都会构建在这些类型之上。
数据透明性的好处是数据可以轻松检查,这对调试很有用。不过,作为抽象的客户端,即使内部表示对你可见,也不应该依赖它。不应该对内部结构进行模式匹配,也不应该尝试提取或修改它的各个部分,因为正确的抽象(例如 MapSet)并不保证数据会是什么形状。唯一保证是:如果你把同一模块已经返回给你的正确结构化实例发送给该模块函数,这些函数会工作。
有时,模块会公开记录其内部结构的某些部分。日期和时间模块就是很好的例子,例如 Date、Time 和 DateTime。查看文档时,会看到明确提到对应数据表示为一种结构,使用 year、month、hour 等字段。在这种情况下,数据结构是公开记录的,你可以自由依赖它。
与数据检查相关,还有最后一个需要知道的东西:IO.inspect/1 函数。这个函数会把结构的 inspected 表示打印到屏幕,并返回结构本身。调试一段代码时,它尤其有用。看看下面的例子:
iex(1)> Fraction.new(1, 4)
...> |> Fraction.add(Fraction.new(1, 4))
...> |> Fraction.add(Fraction.new(1, 2))
...> |> Fraction.value()
1.0
这段代码依赖管道操作符执行一系列分数操作。假设你想在每一步之后检查整个结构。可以轻松地在每一行后插入 IO.inspect/1 调用:
iex(2)> Fraction.new(1, 4)
...> |> IO.inspect()
...> |> Fraction.add(Fraction.new(1, 4))
...> |> IO.inspect()
...> |> Fraction.add(Fraction.new(1, 2))
...> |> IO.inspect()
...> |> Fraction.value()
%Fraction{a: 1, b: 4}
%Fraction{a: 8, b: 16}
%Fraction{a: 32, b: 32}
1.0
这能工作,是因为 IO.inspect/1 会打印数据结构,然后不变地返回同一数据结构。也可以看看 dbg 宏(https://hexdocs.pm/elixir/Kernel.html#dbg/2),它有点类似,但提供更多调试功能。
现在,关于函数式抽象的基础理论已经结束,但你会通过扩展待办列表继续练习。
4.2 处理层级数据(Working with hierarchical data)
本节中,你会扩展 TodoList 抽象,提供基本 CRUD 支持。使用 add_entry/2 和 entries/2 函数,你已经分别解决了 C 和 R 部分。现在,需要增加更新和删除条目的支持。
为此,必须能够唯一标识待办列表中的每个条目,所以会先给每个条目增加唯一 ID 值。
4.2.1 生成 ID(Generating IDs)
向列表添加新条目时,会自动生成其 ID 值,并用递增整数作为 ID。要实现这一点,需要做几件事:
- 把待办列表表示为 struct。需要这样做,是因为待办列表现在必须保存两项信息:条目集合,以及下一个条目的 ID 值。
- 使用条目的 ID 作为键。到目前为止,存储条目集合时一直使用条目日期作为键。现在改用条目 ID。这会让快速插入、更新和删除单个条目成为可能。现在每个键都只有一个值,所以不再需要
MultiDict抽象。
开始实现。下面的代码清单包含模块和 struct 定义。
代码清单 4.11 TodoList struct(todo_crud.ex)
defmodule TodoList do
defstruct next_id: 1, entries: %{}
def new(), do: %TodoList{}
# ...
end
待办列表现在会表示为带有两个字段的 struct。字段 next_id 包含新条目添加到结构中时将被分配的 ID 值。字段 entries 是条目集合。如前所述,现在使用映射,键是条目 ID 值。
定义 struct 时,会立即指定 next_id 和 entries 字段的默认值。因此,创建新实例时不必提供这些字段。new/0 函数创建并返回该 struct 的一个实例。
接下来,重新实现 add_entry/2 函数。它必须做更多工作:
- 设置正在添加条目的 ID。
- 把新条目添加到集合。
- 递增
next_id字段。
代码如下。
代码清单 4.12 为新条目自动生成 ID 值(todo_crud.ex)
defmodule TodoList do
# ...
def add_entry(todo_list, entry) do
entry = Map.put(entry, :id, todo_list.next_id)
new_entries = Map.put(
todo_list.entries,
todo_list.next_id,
entry
)
%TodoList{todo_list |
entries: new_entries,
next_id: todo_list.next_id + 1
}
end
# ...
end
这里发生了很多事,所以逐步拆开看。
在函数体中,你首先用 next_id 字段中存储的值更新条目的 id 值。注意,你使用 Map.put/3 更新条目映射。输入映射可能不包含 id 字段,所以不能使用标准的 %{entry | id: next_id} 技巧,后者只有在映射中已存在 id 字段时才有效。条目更新完成后,把它添加到 entries 集合,并把结果保存在 new_entries 变量中。
最后,必须更新 TodoList struct 实例,把它的 entries 字段设为 new_entries 集合,并递增 next_id 字段。实质上,你在 struct 中做了一次复杂修改,修改了多个字段以及输入条目(因为设置了它的 id 字段)。
对外部调用者而言,整个操作会是原子的。要么所有事情都发生,要么在出现错误时什么都不会发生。这是不可变性的结果。添加条目的效果只有在 add_entry/2 函数完成并且其结果被放入变量时,才会对其他代码可见。如果出了问题并抛出错误,任何转换效果都不会可见。
也值得重复第 2 章提到过的一点:新的待办列表(add_entry/2 函数返回的那个)会尽可能与输入待办列表共享内存。
add_entry/2 函数完成后,需要调整 entries/2 函数。这会更复杂,因为你改变了内部结构。之前,你保存的是日期到条目的映射。现在,条目以 id 作为键存储,所以必须遍历所有条目,并返回那些落在给定日期的条目。
代码清单 4.13 过滤给定日期的条目(todo_crud.ex)
defmodule TodoList do
# ...
def entries(todo_list, date) do
todo_list.entries
|> Map.values()
|> Enum.filter(fn entry -> entry.date == date end)
end
# ...
end
这个函数首先使用 Map.values/1 从 entries 映射中取出条目。然后,使用 Enum.filter/2 只取出落在给定日期的条目。
可以检查新版本待办列表是否工作:
$ iex todo_crud.ex
iex(1)> todo_list =
...> TodoList.new()
...> |> TodoList.add_entry(%{date: ~D[2023-12-19], title: "Dentist"})
...> |> TodoList.add_entry(%{date: ~D[2023-12-20], title: "Shopping"})
...> |> TodoList.add_entry(%{date: ~D[2023-12-19], title: "Movies"})
iex(2)> TodoList.entries(todo_list, ~D[2023-12-19])
[
%{date: ~D[2023-12-19], id: 1, title: "Dentist"},
%{date: ~D[2023-12-19], id: 3, title: "Movies"}
]
它按预期工作,并且可以看到每个条目的 ID 值。还要注意,TodoList 模块接口与之前版本相同。你做了许多内部修改,改变了数据表示,几乎重写了整个模块。然而,由于保持了函数接口不变,模块客户端不需要修改。
这并不是什么革命性东西,而是把行为包装在合适接口之后的经典好处。不过,它展示了在使用无状态模块和不可变数据时,如何构建并推理高层类型。
4.2.2 更新条目(Updating entries)
现在条目有了 ID 值,可以增加额外修改操作。实现 update_entry 操作,它可用于修改待办列表中的单个条目。
这个函数会接收条目 ID 和一个 updater lambda,后者会被调用来更新条目。它的工作方式类似 Map.update。lambda 会接收原始条目,并返回其修改版本。为了保持简单,如果给定 ID 的条目不存在,该函数不会抛出错误。
下面的片段说明用法。这里,你修改 ID 值为 1 的条目的日期:
iex(1)> TodoList.update_entry(
...> todo_list,
...> 1,
...> &Map.put(&1, :date, ~D[2023-12-20])
...> )
实现见下面代码清单。
代码清单 4.14 更新条目(todo_crud.ex)
defmodule TodoList do
# ...
def update_entry(todo_list, entry_id, updater_fun) do
case Map.fetch(todo_list.entries, entry_id) do
:error ->
todo_list
{:ok, old_entry} ->
new_entry = updater_fun.(old_entry)
new_entries = Map.put(todo_list.entries, new_entry.id, new_entry)
%TodoList{todo_list | entries: new_entries}
end
end
# ...
end
拆开看看这里发生了什么。首先,使用 Map.fetch/2 查找给定 ID 的条目。如果条目不存在,该函数会返回 :error;否则返回 {:ok, value}。
第一种情况中,如果条目不存在,就返回列表的原始版本。否则,必须调用 updater lambda 得到修改后的条目。然后,把修改后的条目存入 entries 集合。最后,把修改后的 entries 集合存入 TodoList 实例,并返回该实例。
4.2.3 不可变层级更新(Immutable hierarchical updates)
你可能没有注意到,但在前一个例子中,你执行了一次不可变层级结构的深度更新。拆开看看调用 TodoList.update_entry(todo_list, id, updater_lambda) 时发生了什么:
- 把目标条目取入单独变量。
- 调用 updater,它返回该条目的修改版本。
- 调用
Map.put,把修改后的条目放入entries集合。 - 返回待办列表的新版本,其中包含新的
entries集合。
注意,第 2、3 和 4 步是你转换数据的地方。每一步都会创建一个新变量,其中包含转换后的数据。在每个后续步骤中,你取出这份数据并更新其容器,同样通过创建其转换版本完成。
这就是处理不可变数据结构的方式。如果有层级数据,就不能直接修改位于树深处的某一部分。相反,必须沿树向下走到需要修改的特定部分,然后转换它以及它的所有祖先。结果是整个模型的副本,在这里就是待办列表。如前所述,新旧两个版本会尽可能共享内存。
提供的辅助工具
虽然前面展示的技巧可行,但对更深层级来说可能会变得笨重。记住,要更新层级深处的元素,必须走到该元素,然后更新它的所有父级。为了简化这一点,Elixir 支持更优雅的深层级更新。
看一个基本例子。假设待办列表表示为一个简单映射,其中键是 ID,值是由字段组成的普通映射。创建这样一个待办列表实例:
iex(1)> todo_list = %{
...> 1 => %{date: ~D[2023-12-19], title: "Dentist"},
...> 2 => %{date: ~D[2023-12-20], title: "Shopping"},
...> 3 => %{date: ~D[2023-12-19], title: "Movies"}
...> }
现在,假设你改变主意,想去剧院而不是看电影。可以使用 Kernel.put_in/2 宏优雅地修改原始结构:
iex(2)> put_in(todo_list[3].title, "Theater")
%{
1 => %{date: ~D[2023-12-19], title: "Dentist"},
2 => %{date: ~D[2023-12-20], title: "Shopping"},
3 => %{date: ~D[2023-12-19], title: "Theater"}
}
这里发生了什么?在内部,put_in/2 做了类似我们前面做的事。它递归走到所需元素,转换它,然后更新所有父级。注意,这仍然是不可变操作,也就是说原始结构保持不变,必须把结果赋给变量。
为了执行递归遍历,put_in/2 需要接收源数据以及目标元素路径。在前面的例子中,源数据以 todo_list 提供,路径指定为 [3].title。宏 put_in/2 会沿该路径向下走,并在返回时重建新层级。
还值得注意的是,Elixir 以 get_in/2、update_in/2 和 get_and_update_in/2 宏的形式提供了类似的数据检索和更新替代方案。它们是宏,这意味着你提供的路径会在编译时求值,不能动态构建。
如果需要在运行时构造路径,也有等价函数接收数据和路径作为单独参数。例如,Elixir 还包含 put_in/3 宏,可以这样使用:
iex(3)> path = [3, :title]
iex(4)> put_in(todo_list, path, "Theater")
put_in 这样的函数和宏依赖 Access 模块,该模块允许你处理键值结构,例如映射。也可以让自己的抽象与 Access 一起工作。你需要实现 Access 契约要求的一组函数,然后 put_in 及相关宏和函数就会知道如何处理你的抽象。更多细节请参考官方 Access 文档:https://hexdocs.pm/elixir/Access.html。
练习:删除条目
你的 TodoList 模块几乎完成了。你已经实现了 create(add_entry/2)、retrieve(entries/2)和 update(update_entry/3)操作。最后要实现的是 delete_entry/2 操作。这很直接,留作练习。如果卡住了,解答位于源文件 todo_crud.ex 中。
4.2.4 迭代更新(Iterative updates)
到目前为止,你一直在手动执行更新,一次一个。现在,是时候实现迭代更新了。想象你有一个描述条目的原始列表:
$ iex todo_builder.ex
iex(1)> entries = [
...> %{date: ~D[2023-12-19], title: "Dentist"},
...> %{date: ~D[2023-12-20], title: "Shopping"},
...> %{date: ~D[2023-12-19], title: "Movies"}
...> ]
现在,你想创建一个包含所有这些条目的待办列表实例:
iex(2)> todo_list = TodoList.new(entries)
显然,new/1 函数会迭代构建待办列表。如何实现这样的函数?事实证明,这很简单。
代码清单 4.15 迭代构建待办列表(todo_builder.ex)
defmodule TodoList do
# ...
def new(entries \\ []) do
Enum.reduce(
entries,
%TodoList{},
fn entry, todo_list_acc ->
add_entry(todo_list_acc, entry)
end
)
end
# ...
end
为了迭代构建待办列表,你依赖 Enum.reduce/3。回忆第 3 章,reduce 用于把某个 enumerable 转换成其他任何东西。在这里,你把原始 Entry 实例列表转换成 TodoList struct 实例。因此,调用 Enum.reduce/3,把输入列表作为第一个参数,把新的结构实例作为第二个参数(初始累加器值),并传入每一步调用的 lambda。
该 lambda 会对输入列表中的每个条目调用。它的任务是把条目添加到当前累加器(TodoList struct),并返回新的累加器值。为此,lambda 会委托给已经存在的 add_entry/2 函数,并反转参数顺序。之所以需要反转参数,是因为 Enum.reduce/3 调用 lambda 时,会传入被迭代元素(条目)和累加器(TodoList struct)。相比之下,add_entry 接收的是 struct 和条目。
注意,可以借助捕获操作符让 lambda 定义更紧凑:
def new(entries \\ []) do
Enum.reduce(
entries,
%TodoList{},
&add_entry(&2, &1)
)
end
使用这个版本还是前一个版本,完全取决于个人口味。
4.2.5 练习:从文件导入(Exercise: Importing from a file)
现在,是时候稍微练习一下了。在这个练习中,你会从逗号分隔文件创建一个 TodoList 实例。
假设当前文件夹中有一个 todos.csv 文件。文件中的每一行描述一个待办条目:
2023-12-19,Dentist
2023-12-20,Shopping
2023-12-19,Movies
你的任务是创建一个额外模块 TodoList.CsvImporter,它可以用于从文件内容创建 TodoList 实例:
iex(1)> todo_list = TodoList.CsvImporter.import("todos.csv")
为了简化任务,假设文件始终可用且格式正确。也假设逗号字符不会出现在条目标题中。
这通常不难做,但可能需要一些摸索和实验。下面是一些会引导你走向正确方向的提示。
首先,创建一个具有如下布局的单文件:
defmodule TodoList do
# ...
end
defmodule TodoList.CsvImporter do
# ...
end
始终小步工作。实现计算的一部分,然后使用 IO.inspect/1 把结果打印到屏幕。我再怎么强调这一点也不为过。这个任务需要一些数据管道处理。小步工作能让你逐步前进,并验证自己在正确方向上。
你应该执行的大致步骤如下:
- 打开文件并遍历它,从每一行移除
\n。提示:使用File.stream!/1、Stream.map/2和String.trim_trailing/2。第 3 章讨论流时,在过滤长度超过 80 个字符的行的例子中做过这个操作。 - 使用
Stream.map,把上一步得到的每一行转换成待办列表条目。- 使用
String.split/2把行转换成[date_string, title]列表。 - 使用
Date.from_iso8601!把日期字符串转换成日期(https://hexdocs.pm/elixir/Date.html#from_iso8601!/2)。 - 创建待办列表条目,即形状为
%{date: date, title: title}的映射。
- 使用
第 2 步的输出是由待办条目组成的 enumerable。把这个 enumerable 传给最近实现的 TodoList.new/1 函数。
在这些步骤中,每一步都会接收一个 enumerable 作为输入,转换其中的每个元素,并把得到的 enumerable 传给下一步。最后一步中,得到的 enumerable 会传给已经实现的 TodoList.new/1,从而创建待办列表。
如果小步工作,就更不容易迷路。例如,可以先打开文件并把每一行打印到屏幕。然后,尝试从每一行移除尾随换行符并打印它们,依此类推。
转换每一步数据时,可以使用 Enum 函数,也可以使用 Stream 模块中的函数。开始时可能更简单的方式是使用 Enum 模块中的急切函数,让整件事先工作起来。然后,尝试把尽可能多的 Enum 函数替换为对应的 Stream 函数。回忆第 3 章,Stream 函数是惰性且可组合的,这可以减少操作所需的中间内存量。如果迷路了,解答位于 todo_import.ex 文件中。
与此同时,我们对高层抽象的探索也快结束了。最后一个要简要讨论的话题,是 Elixir 实现多态的方式。
4.3 使用协议实现多态(Polymorphism with protocols)
多态是运行时根据输入数据性质决定执行哪段代码。在 Elixir 中,做到这一点的基本方式(但不是唯一方式)是使用称为 protocol 的语言特性。
在讨论协议之前,先看看它们的实际效果。你已经见过多态代码。例如,整个 Enum 模块都是泛型代码,可以作用于任何 enumerable,如下面的片段所示:
Enum.each([1, 2, 3], &IO.inspect/1)
Enum.each(1..3, &IO.inspect/1)
Enum.each(%{a: 1, b: 2}, &IO.inspect/1)
注意,你使用同一个 Enum.each/2 函数,把不同数据结构发送给它:列表、范围和映射。Enum.each/2 如何知道怎样遍历每个结构?它不知道。Enum.each/2 中的代码是泛型的,并依赖一个契约。这个契约称为协议,必须为每个想与 Enum 函数一起使用的数据类型实现。接下来,学习如何定义并使用协议。
4.3.1 协议基础(Protocol basics)
协议是一个模块,其中声明函数但不实现它们。可以把它粗略视为面向对象接口的等价物。泛型逻辑依赖协议并调用其函数。然后,可以为不同数据类型提供该协议的具体实现。
看一个例子。Elixir 标准库提供了协议 String.Chars,用于把数据转换为 binary 字符串。该协议在 Elixir 源码中的定义如下:
defprotocol String.Chars do
def to_string(term)
end
这类似模块定义,一个显著区别是函数只声明而不实现。
注意函数的第一个参数(term)。运行时,该参数的类型决定调用哪个实现。看看实际效果。Elixir 已经为原子、数字以及其他一些数据类型实现了该协议,所以可以执行下面的调用:
iex(1)> String.Chars.to_string(1)
"1"
iex(2)> String.Chars.to_string(:an_atom)
"an_atom"
如果协议没有为给定数据类型实现,就会抛出错误:
iex(3)> String.Chars.to_string(TodoList.new())
** (Protocol.UndefinedError) protocol String.Chars not implemented
通常,不需要直接调用协议函数。更常见的是,有泛型代码依赖该协议。对于 String.Chars 来说,这就是自动导入的函数 Kernel.to_string/1:
iex(4)> to_string(1)
"1"
iex(5)> to_string(:an_atom)
"an_atom"
iex(6)> to_string(TodoList.new())
** (Protocol.UndefinedError) protocol String.Chars not implemented
如你所见,to_string/1 的行为与 String.Chars.to_string/1 完全相同。这是因为 Kernel.to_string/1 委托给 String.Chars 实现。
此外,可以把任何实现了 String.Chars 的东西发送给 IO.puts/1:
iex(7)> IO.puts(1)
1
iex(8)> IO.puts(:an_atom)
an_atom
iex(9)> IO.puts(TodoList.new())
** (Protocol.UndefinedError) protocol String.Chars not implemented
如你所见,TodoList 实例不可打印,因为没有为对应类型实现 String.Chars。
4.3.2 实现协议(Implementing a protocol)
如何为特定类型实现协议?再次参考 Elixir 源码。下面的片段为整数实现 String.Chars:
defimpl String.Chars, for: Integer do
def to_string(term) do
Integer.to_string(term)
end
end
实现从调用 defimpl 宏开始。然后指定要实现哪个协议以及对应数据类型。最后,do/end 块包含每个协议函数的实现。在这个例子中,实现委托给现有标准库函数 Integer.to_string/1。
for: Type 部分值得解释。类型是一个原子,可以是以下别名之一:Tuple、Atom、List、Map、BitString、Integer、Float、Function、PID、Port 或 Reference。这些值对应 Elixir 内置类型。
此外,也允许使用别名 Any,从而可以指定 fallback 实现。如果协议没有为给定类型定义,就会抛出错误,除非协议定义中指定 fallback 到 Any 且存在 Any 实现。细节请参考协议文档:https://hexdocs.pm/elixir/Protocol.html。
最后,也是最重要的,类型可以是任何其他任意别名,但不能是常规简单原子:
defimpl String.Chars, for: SomeAlias do
...
end
如果协议函数的第一个参数是对应模块中定义的 struct,就会调用这个实现。例如,可以如下为 TodoList 实现 String.Chars:
iex(1)> defimpl String.Chars, for: TodoList do
...> def to_string(_) do
...> "#TodoList"
...> end
...> end
现在,可以把待办列表实例传给 IO.puts/1:
iex(2)> IO.puts(TodoList.new())
#TodoList
需要注意,协议实现不需要是任何模块的一部分。这带来强大的后果:即使不能修改某个类型的源代码,也可以为该类型实现协议。可以把协议实现放在自己代码的任何位置,运行时都能利用它。
4.3.3 内置协议(Built-in protocols)
Elixir 附带一些预定义协议。完整参考最好查阅在线文档(https://hexdocs.pm/elixir),但这里提一些最重要的协议。
你已经见过 String.Chars,它指定了把数据转换成 binary 字符串的契约。还有 List.Chars 协议,它把输入数据转换成字符字符串,也就是字符列表。
如果想控制结构在调试输出(通过 inspect 函数)中如何打印,可以实现 Inspect 协议。
可以说最重要的协议是 Enumerable。通过实现它,可以让你的数据结构成为 enumerable。这意味着可以免费使用 Enum 和 Stream 模块中的所有函数!这可能是协议有用性的最佳展示。Enum 和 Stream 都是泛型模块,提供许多有用函数;只要实现 Enumerable 协议,它们就可以作用于你的自定义数据结构。
与枚举密切相关的是 Collectable 协议。回忆第 3 章,collectable 结构是可以反复添加元素的结构。collectable 可以与推导式一起使用来收集结果,也可以与 Enum.into/2 一起使用,把一个结构(enumerable)中的元素转移到另一个结构(collectable)中。
当然,你也可以定义自己的协议,并为任何可用数据结构实现它们,无论是自己的还是别人的。更多信息请查看 Kernel.defprotocol/2 文档。
collectable 待办列表
看一个更复杂的例子。你会让待办列表成为 collectable,以便可以把它用作推导式目标。这是一个稍微高级一点的例子,所以如果第一次没有理解每个细节,不必担心。
要让该抽象成为 collectable,必须实现对应协议:
defimpl Collectable, for: TodoList do
def into(original) do
{original, &into_callback/2}
end
defp into_callback(todo_list, {:cont, entry}) do
TodoList.add_entry(todo_list, entry)
end
defp into_callback(todo_list, :done), do: todo_list
defp into_callback(_todo_list, :halt), do: :ok
end
导出的函数 into/1 会被泛型代码调用,例如推导式。这里,你提供的实现会返回 appender lambda。随后,泛型代码会反复调用这个 appender lambda,把每个元素追加到数据结构中。
appender 函数接收一个待办列表和一个指令提示。如果收到 {:cont, entry},必须添加一个新条目。如果收到 :done,就返回列表;此时它已经包含所有追加元素。最后,:halt 表示操作已取消,返回值会被忽略。
看看实际效果。把前面的代码复制粘贴到 shell 中,然后尝试下面的内容:
iex(1)> entries = [
...> %{date: ~D[2023-12-19], title: "Dentist"},
...> %{date: ~D[2023-12-20], title: "Shopping"},
...> %{date: ~D[2023-12-19], title: "Movies"}
...> ]
iex(2)> Enum.into(entries, TodoList.new())
%TodoList{...}
通过实现 Collectable 协议,实际上是把 TodoList 抽象适配到了任何依赖该协议的泛型代码,例如 Enum.into/2 或 for 推导式。
总结
- 模块用于创建抽象。模块函数会创建、操作和查询数据。客户端可以检查整个结构,但不应该依赖其形状。
- 映射可用于把不同字段组合到单个结构中。
- struct 是特殊映射,允许定义与模块相关的数据抽象。
- 多态可以通过协议实现。协议定义一个供泛型逻辑使用的接口。然后,可以为某个数据类型提供具体协议实现。