Elixir入门教程-协议

  1. 协议和结构体
  2. 实现Any
    2.1. 派生
    2.3. 回退到Any
  3. 内建协议
  4. 协议整合

协议是Elixir里实现多态的一种机制。在一个协议上派发消息给任意数据类型是可行的,只要这个数据类型实现了这个协议。让我们看一个例子。

在Elixir里,我们有两个动作来检查在一个数据结构里有多少个元素:length 和 size。length 意味着信息必需是被计算的。例如:length(list)需要遍历整个列表来计算它的长度。而另一方面,tuple_size(tuple) 和 byte_size(binary) 不依赖于元组和二进制数据的大小,因为在数据结构里大小的信息已经被预先计算了。

虽然我们已经有Elixir内建的获取大小的指定类型函数(比如tuple_size/1),我们还是想实现一个通用的 Size 协议,然后所有数据结构只要它的大小是预先计算的,都可以实现这个协议。

协议的定义看起来像下面这样:

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

Size协议期望有一个接受一个入参(我们想要知道大小的数据结构)叫做size的函数被实现。我们现在可以为一些数据结构实现这个协议,这些数据结构应该有一个合适的实现:

1
2
3
4
5
6
7
8
9
10
11
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)。

现在我们有了协议定义和实现,我们可以开始使用它:

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

传入一个没有实现协议的数据类型将会引起一个错误:

1
2
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的可扩展性的能力来自当协议和结构一起使用时。

在上一章,我们已经学到,虽然结构体底层是映射,但是它没有和映射共享协议实现。例如,MapSet (基于映射的集合)被实现为结构体。让我们尝试应用Size协议于MapSet上:

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

结构体没有和映射共享协议实现,而是需要它自己的协议实现。因为MapSet有它自己的预先计算大小并且可以通过MapSet.size/1访问,我们可以为它定义一个Size协议的实现:

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

如果需要,你可以拿出你自己结构大小的语义。你不仅可以用结构体来构建更健壮的数据类型,比如像队列,而且可以为这个数据类型实现所有相关的协议,比如 Enumerable 和 可能的 Size。

1
2
3
4
5
6
7
defmodule User do
defstruct [:name, :age]
end
defimpl Size, for: User do
def size(_user), do: 2
end

实现Any

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

派生

Elixir允许我们基于Any的实现派生一个协议实现。让我们先实现Any:

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

上述的实现可以说是不合理的。例如,说一个PID或一个整数的大小为零。

然而,我们应该很好地处理这个Any的实现。为了使用这个实现,我们应该需要告诉我们的结构体明确地派生于这个Size协议:

1
2
3
4
defmodule OtherUser do
@derive [Size]
defstruct [:name, :age]
end

当派生的时候,Elixir将基于为Any提供的实现为OtherUser实现Size协议。

回退到Any

当找不到实现的时候,对于 @derive 的另外一个选择就是明确地告诉协议退回到Any。这可以在协议定义里通过设置@fallback_to_any为true来实现:

1
2
3
4
defprotocol Size do
@fallback_to_any true
def size(data)
end

正如我们在前一节所说,为Any所做的Size实现不能应用到所有数据类型。这就是为什么@fallback_to_any是一个可选项的原因之一。对于大多数协议来说,当没有实现的时候抛出一个错误是正确的行为。也就是说,假设像上一节一样我们已经实现了Any:

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

那么现在所有数据类型(包括结构体),如果它没有实现Size协议,则被认为大小为0。

派生和回退到Any哪一个技术最好,这要依赖于使用场景。但是,Elixir开发者喜欢明确的而不是推断的。你可以看到许多库倾向于 @derive 方法。

内建协议

Elixir内建了一些协议。前面的章节里,我们讨论过的Enum模块,它提供了许多函数处理任意数据结构,这个模块就实现了 Enumerable 协议:

1
2
3
4
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函数暴露出来:

1
2
iex> to_string :hello
"hello"

注意到Elixir字符串插入是调用to_string函数:

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

上面的例子可以正常运行是因为数字数据类型实现了String.Chars协议。如果传递一个元组,则会导致一个错误:

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

当需要“打印”一个更复杂的数据结构的时候,我们可以用基于Inspect协议的inspect函数:

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

Inspect协议被用来转换任何数据结构为一个易于阅读的文本。像IEx这样的工具就是用它来打印的:

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

记住,按照惯例,每当被检查的价值以#开头,它是代表这是非有Elixir的语法的数据结构。这意味着inspect协议是不可逆的,因为以这种方式信息可能会丢失:

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

Elixir还有其他协议,不过本文讲最常用的这几个。

协议整合

当处理Elixir项目的时候,使用Mix构建工具,你可以看到如下的输出:

1
2
3
4
5
6
Consolidated String.Chars
Consolidated Collectable
Consolidated List.Chars
Consolidated IEx.Info
Consolidated Enumerable
Consolidated Inspect

这些是Elixir自带的所有协议并且它们被整合。因为一个协议可以派发消息到任何数据类型,如果对于给定的类型的实现存在,则协议一定检查每一个调用。这可能非常消耗资源。

但是,在我们的项目使用像Mix这样的工具被编译后,我们知道所有被定义的模块,包括协议和他们的实现。用这个方法,协议可以被合并到一个非常简单快速的派发模块。

从Elixir v1.2开始,对于所有项目,协议合并都自动发生。我们将在Mix和OTP指引里构建我们的项目。

原文链接: http://elixir-lang.org/getting-started/protocols.html