协议

协议是一种实现 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列表协议,因为没有为列表预先计算的“大小”信息,并且列表的长度必须被计算(with 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]

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

  • Atom
  • BitString
  • Float
  • Function
  • Integer
  • List
  • Map
  • PID
  • Port
  • Reference
  • Tuple

协议和结构

当协议和结构一起使用时,Elixir 的可扩展性的力量就来了。

在前一章中,我们已经了解到虽然结构是地图,但它们并不与地图共享协议实现。例如,MapSets(基于地图的集合)被实现为结构体。让我们尝试使用Size协议MapSet

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

与其使用地图共享协议实现,结构体需要自己的协议实现。由于a 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

上面的实现可以说是不合理的。例如,它没有任何意义说的大小PID或者Integer0

但是,如果我们对实现很好Any,为了使用这种实现,我们需要告诉我们的结构明确地派生Size协议:

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

推导时,Elixir 将OtherUser根据所提供的Any实施实施Size协议。

退回Any

另一种选择@derive是明确告诉协议回退到Any无法找到实现的时间。这可以通过在协议定义中设置@fallback_to_any来实现true

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

正如我们在上一节中所说的,Sizefor Any的实现不适用于任何数据类型。这@fallback_to_any是选择加入行为的原因之一。对于大多数协议,当协议未实现时引发错误是正确的行为。这就是说,假设我们已经Any按照前面的部分来实施:

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

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

在导出和回退之间哪种技术最好取决于用例,但是,由于 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}

当需要“打印”更复杂的数据结构时,可以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 中还有其他协议,但是它涵盖了最常见的协议。

协议合并

使用混合构建工具处理 Elixir 项目时,您可能会看到如下输出:

Consolidated String.Chars
Consolidated Collectable
Consolidated List.Chars
Consolidated IEx.Info
Consolidated Enumerable
Consolidated Inspect

这些是 Elixir 附带的所有协议,并且它们正在进行合并。由于协议可以分派到任何数据类型,协议必须检查每个呼叫是否存在给定类型的实现。这可能是昂贵的。

但是,在使用像 Mix 这样的工具编译我们的项目后,我们知道所有已定义的模块,包括协议及其实现。这样,该协议可以合并为一个非常简单快速的调度模块。

从 Elixir v1.2 开始,所有项目都会自动进行协议合并。我们将在 Mix 和 OTP 指南中构建自己的项目。