协议是一种在 Elixir 中实现多态性的机制,您希望行为根据数据类型而变化。我们已经熟悉解决此类问题的一种方法:通过模式匹配和保护子句。考虑一个简单的实用程序模块,它会告诉我们输入变量的类型:
如果此模块的使用仅限于您自己的项目,您将能够继续为每种新数据类型定义新的 type/1 函数。但是,如果此代码作为依赖项由多个应用程序共享,则可能会出现问题,因为没有简单的方法来扩展其功能。
这就是协议可以帮助我们的地方:协议允许我们根据需要扩展尽可能多的数据类型的原始行为。这是因为协议上的调度适用于已实现协议的任何数据类型,并且任何人都可以随时实现协议。
以下是我们如何将相同的 Utility.type/1 功能编写为协议:
我们使用 defprotocol/2 定义协议 - 其功能和规范可能看起来类似于其他语言中的接口或抽象基类。我们可以使用 defimpl/2 添加任意数量的实现。输出与我们拥有一个具有多个函数的模块完全相同:
但是,使用协议,我们不再需要不断修改同一模块以支持越来越多的数据类型。例如,我们可以将上面的 defimpl 调用分散到多个文件中,Elixir 将根据数据类型将执行分派到适当的实现。协议中定义的函数可能有多个输入,但分派将始终基于第一个输入的数据类型。
您可能遇到的最常见的协议之一是 String.Chars 协议:为您的自定义结构实现其 to_string/1 函数将告诉 Elixir 内核如何将它们表示为字符串。我们稍后将探索所有内置协议。现在,让我们实现我们自己的协议。
例子
现在您已经看到了协议有助于解决的问题类型及其解决方式的示例,让我们看一个更深入的示例。
在 Elixir 中,我们有两个用于检查数据结构中有多少项的习语:长度和大小。长度意味着必须计算信息。例如,length(list) 需要遍历整个列表来计算其长度。另一方面,tuple_size(tuple) 和 byte_size(binary) 不依赖于元组和二进制大小,因为大小信息是在数据结构中预先计算的。
即使我们在 Elixir 中内置了用于获取大小的类型特定函数(例如 tuple_size/1),我们也可以实现一个通用的 Size 协议,所有预先计算大小的数据结构都会实现该协议。
协议定义如下:
Size 协议需要一个名为 size 的函数,该函数接收一个参数(我们想知道其大小的数据结构)。我们现在可以为具有兼容实现的数据结构实现此协议:
我们没有为列表实现 Size 协议,因为列表没有预先计算的“大小”信息,并且必须计算列表的长度(使用 length/1)。
现在有了定义的协议和实现,我们可以开始使用它了:
传递未实现协议的数据类型会引发错误:
可以为所有 Elixir 数据类型实现协议:
协议和结构
当协议和结构一起使用时,Elixir 的可扩展性就会发挥出来。
在上一章中,我们了解到,尽管结构是映射,但它们并不与映射共享协议实现。例如,MapSet(基于映射的集合)是作为结构实现的。让我们尝试将 Size 协议与 MapSet 一起使用:
结构不需要与映射共享协议实现,而是需要自己的协议实现。由于 MapSet 的大小是预先计算的,并且可以通过 MapSet.size/1 访问,因此我们可以为其定义一个 Size 实现:
如果需要,您可以为结构的大小提出自己的语义。不仅如此,您还可以使用结构来构建更强大的数据类型(如队列),并为这种数据类型实现所有相关协议(如 Enumerable 和可能的 Size)。
任意实现(Any)
手动实现所有类型的协议很快就会变得重复而乏味。在这种情况下,Elixir 提供了两种选择:我们可以明确派生我们类型的协议实现,或者自动实现所有类型的协议。在这两种情况下,我们都需要为 Any 实现协议。
派生
Elixir 允许我们基于 Any 实现派生协议实现。让我们首先按如下方式实现 Any:
上述实现可能不合理。例如,说 PID 或 Integer 的大小为 0 是没有意义的。
但是,如果我们对 Any 的实现满意,为了使用这种实现,我们需要告诉我们的结构明确派生 Size 协议:
在派生时,Elixir 将根据为 Any 提供的实现为 OtherUser 实现 Size 协议。
回退到 Any
@derive 的另一种替代方法是明确告知协议在找不到实现时回退到 Any。这可以通过在协议定义中将 @fallback_to_any 设置为 true 来实现:
正如我们在上一节中所说,Any 的 Size 实现不能应用于任何数据类型。这就是 @fallback_to_any 是可选行为的原因之一。对于大多数协议,当协议未实现时引发错误是正确的行为。也就是说,假设我们已经像上一节中那样实现了 Any:
现在,所有未实现 Size 协议的数据类型(包括结构)都将被视为具有 0 的大小。
在派生和回退到 Any 之间哪种技术最好取决于用例,但鉴于 Elixir 开发人员更喜欢显式而不是隐式,您可能会看到许多库都在推行 @derive 方法。
内置协议
Elixir 附带一些内置协议。在前面的章节中,我们讨论了 Enum 模块,它提供了许多可与实现 Enumerable 协议的任何数据结构配合使用的函数:
另一个有用的例子是 String.Chars 协议,它指定如何将数据结构转换为其以字符串形式表示的形式。它通过 to_string 函数公开:
请注意,Elixir 中的字符串插值调用 to_string 函数:
上面的代码片段只适用于数字实现 String.Chars 协议的情况。例如,传递元组将导致错误:
当需要“打印”更复杂的数据结构时,可以使用基于 Inspect 协议的 inspect 函数:
Inspect 协议是用于将任何数据结构转换为可读文本表示的协议。这就是 IEx 等工具用来打印结果的方式:
请记住,按照惯例,只要检查的值以 # 开头,它就表示非有效 Elixir 语法的数据结构。这意味着检查协议不可逆,因为信息可能会在此过程中丢失:
Elixir 中还有其他协议,但本文涵盖了最常见的协议。您可以在协议模块中了解有关协议和实现的更多信息。