查看源代码 协议
协议是 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 中,我们有两种习惯用法来检查数据结构中存在多少个项目:length
和 size
。length
意味着必须计算信息。例如,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
上面的实现可能不是合理的。例如,说 PID
或 Integer
的大小为 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
正如我们在上一节中所说,Size
的 Any
实现不是可以应用于任何数据类型的实现。这就是 @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
模块中了解有关协议和实现的更多信息。