查看源代码 协议

协议是 Elixir 中实现多态的一种机制,在这种情况下,你希望行为根据数据类型而有所不同。我们已经熟悉了解决这类问题的一种方法:通过模式匹配和保护子句。考虑一个简单的实用程序模块,它可以告诉我们输入变量的类型

defmodule Utility do
  def type(value) when is_binary(value), do: "string"
  def type(value) when is_integer(value), do: "integer"
  # ... other implementations ...
end

如果该模块的使用仅限于你自己的项目,你将能够为每个新的数据类型定义新的 type/1 函数。但是,如果此代码作为多个应用程序的依赖项共享,则此代码可能会存在问题,因为没有简单的方法来扩展其功能。

这就是协议可以帮助我们的地方:协议允许我们根据需要扩展原始行为以适用于尽可能多的数据类型。这是因为**对协议的调度适用于已实现该协议的任何数据类型**,并且任何人都可以随时实现协议。

以下是如何使用协议编写与 Utility.type/1 相同的功能

defprotocol Utility do
  @spec type(t) :: String.t()
  def type(value)
end

defimpl Utility, for: BitString do
  def type(_value), do: "string"
end

defimpl Utility, for: Integer do
  def type(_value), do: "integer"
end

我们使用 defprotocol/2 定义协议——它的函数和规范可能看起来类似于其他语言中的接口或抽象基类。我们可以使用 defimpl/2 添加任意数量的实现。输出与我们只有一个包含多个函数的模块完全相同

iex> Utility.type("foo")
"string"
iex> Utility.type(123)
"integer"

但是,使用协议,我们不再需要不断修改同一个模块来支持越来越多的数据类型。例如,我们可以将上面的 defimpl 调用分散在多个文件中,Elixir 将根据数据类型将执行调度到适当的实现。在协议中定义的函数可能有多个输入,但**调度始终基于第一个输入的数据类型**。

你可能遇到的最常见的协议之一是 String.Chars 协议:为你的自定义结构体实现其 to_string/1 函数将告诉 Elixir 内核如何将它们表示为字符串。我们将在后面探讨所有内置协议。现在,让我们实现我们自己的协议。

示例

现在你已经看到了协议可以帮助解决的类型问题的示例以及它们是如何解决的,让我们来看一个更深入的示例。

在 Elixir 中,我们有两种习惯用法来检查数据结构中存在多少个项目:lengthsizelength 意味着必须计算信息。例如,length(list) 需要遍历整个列表才能计算其长度。另一方面,tuple_size(tuple)byte_size(binary) 不依赖于元组和二进制的大小,因为大小信息在数据结构中是预先计算的。

即使我们拥有内置在 Elixir 中的用于获取大小的特定类型函数(例如 tuple_size/1),我们也可以实现一个通用的 Size 协议,所有预先计算了大小的数据结构都可以实现该协议。

协议定义如下

defprotocol Size do
  @doc "Calculates the size (and not the length!) of a data structure"
  def size(data)
end

Size 协议期望实现一个名为 size 的函数,该函数接收一个参数(我们要了解大小的数据结构)。现在,我们可以为将具有兼容实现的数据结构实现此协议

defimpl Size, for: BitString do
  def size(string), do: byte_size(string)
end

defimpl Size, for: Map do
  def size(map), do: map_size(map)
end

defimpl Size, for: Tuple do
  def size(tuple), do: tuple_size(tuple)
end

我们没有为列表实现 Size 协议,因为列表没有预先计算的“大小”信息,列表的长度必须计算(使用 length/1)。

现在,协议定义和实现到位后,我们可以开始使用它

iex> Size.size("foo")
3
iex> Size.size({:ok, "hello"})
2
iex> Size.size(%{label: "some label"})
1

传递未实现协议的数据类型会导致错误

iex> Size.size([1, 2, 3])
** (Protocol.UndefinedError) protocol Size not implemented for [1, 2, 3] of type List

可以为所有 Elixir 数据类型实现协议

协议和结构体

当协议和结构体一起使用时,Elixir 的可扩展性的强大功能就体现出来了。

上一章 中,我们了解到尽管结构体是映射,但它们并不与映射共享协议实现。例如,MapSet(基于映射的集合)是作为结构体实现的。让我们尝试使用 Size 协议和 MapSet

iex> Size.size(%{})
0
iex> set = %MapSet{} = MapSet.new
MapSet.new([])
iex> Size.size(set)
** (Protocol.UndefinedError) protocol Size not implemented for MapSet.new([]) of type MapSet (a struct)

结构体不需要与映射共享协议实现,它们需要自己的协议实现。由于 MapSet 的大小是预先计算的,并且可以通过 MapSet.size/1 访问,因此我们可以为它定义 Size 实现

defimpl Size, for: MapSet do
  def size(set), do: MapSet.size(set)
end

如果需要,你可以为结构体的大小提出自己的语义。不仅如此,你还可以使用结构体来构建更强大的数据类型,例如队列,并为这种数据类型实现所有相关的协议,例如 Enumerable 以及可能 Size

defmodule User do
  defstruct [:name, :age]
end

defimpl Size, for: User do
  def size(_user), do: 2
end

实现 Any

为所有类型手动实现协议很快就会变得重复和乏味。在这种情况下,Elixir 提供了两种选择:我们可以明确地为我们的类型推导出协议实现,或者为所有类型自动实现协议。在这两种情况下,都需要为 Any 实现协议。

推导

Elixir 允许我们根据 Any 实现来推导出协议实现。让我们首先按照以下步骤实现 Any

defimpl Size, for: Any do
  def size(_), do: 0
end

上面的实现可能不是合理的。例如,说 PIDInteger 的大小为 0 是没有意义的。

但是,如果我们对 Any 的实现感到满意,为了使用此实现,我们需要告诉我们的结构体显式地推导出 Size 协议

defmodule OtherUser do
  @derive [Size]
  defstruct [:name, :age]
end

在推导时,Elixir 将根据为 Any 提供的实现来为 OtherUser 实现 Size 协议。

回退到 Any

另一种替代 @derive 的方法是明确地告诉协议,当找不到实现时,回退到 Any。这可以通过在协议定义中将 @fallback_to_any 设置为 true 来实现

defprotocol Size do
  @fallback_to_any true
  def size(data)
end

正如我们在上一节中所说,SizeAny 实现不是可以应用于任何数据类型的实现。这就是 @fallback_to_any 是一种选择性行为的原因之一。对于大多数协议而言,在未实现协议时引发错误是正确的行为。也就是说,假设我们已经按照上一节中的方式实现了 Any

defimpl Size, for: Any do
  def size(_), do: 0
end

现在,所有未实现 Size 协议的数据类型(包括结构体)都将被视为大小为 0

在推导和回退到 Any 之间,哪种技术更好取决于用例,但是,鉴于 Elixir 开发人员更喜欢显式而不是隐式,你可能会看到许多库都倾向于使用 @derive 方法。

内置协议

Elixir 附带了一些内置协议。在前面的章节中,我们讨论了 Enum 模块,该模块提供了许多适用于实现 Enumerable 协议的任何数据结构的函数

iex> Enum.map([1, 2, 3], fn x -> x * 2 end)
[2, 4, 6]
iex> Enum.reduce(1..3, 0, fn x, acc -> x + acc end)
6

另一个有用的例子是 String.Chars 协议,它指定如何将数据结构转换为人类可读的字符串表示形式。它通过 to_string 函数公开

iex> to_string(:hello)
"hello"

请注意,Elixir 中的字符串插值调用 to_string 函数

iex> "age: #{25}"
"age: 25"

上面的代码段之所以有效,是因为数字实现了 String.Chars 协议。例如,传递元组会导致错误

iex> tuple = {1, 2, 3}
{1, 2, 3}
iex> "tuple: #{tuple}"
** (Protocol.UndefinedError) protocol String.Chars not implemented for {1, 2, 3} of type Tuple

当需要“打印”更复杂的数据结构时,可以使用 inspect 函数,该函数基于 Inspect 协议

iex> "tuple: #{inspect(tuple)}"
"tuple: {1, 2, 3}"

Inspect 协议是用于将任何数据结构转换为可读文本表示形式的协议。这就是 IEx 等工具用于打印结果的方式

iex> {1, 2, 3}
{1, 2, 3}
iex> %User{}
%User{name: "john", age: 27}

请记住,按照约定,每当被检查的值以 # 开头时,它都表示以非有效 Elixir 语法表示的数据结构。这意味着检查协议不可逆,因为信息可能会在过程中丢失

iex> inspect &(&1+2)
"#Function<6.71889879/1 in :erl_eval.expr/5>"

Elixir 中还有其他协议,但这些是最常见的协议。你可以在 Protocol 模块中了解有关协议和实现的更多信息。