查看源代码 Protocol (Elixir v1.16.2)

关于使用协议工作的参考和函数。

协议指定了由其实现定义的 API。协议使用 Kernel.defprotocol/2 定义,其实现使用 Kernel.defimpl/3 定义。

一个实际案例

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

虽然 Elixir 包含诸如 tuple_sizebinary_sizemap_size 之类的特定函数,但有时我们希望能够检索数据结构的大小,无论其类型如何。在 Elixir 中,我们可以通过使用协议编写多态代码,即适用于不同形状/类型的代码。一个大小协议可以实现如下

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

现在协议可以针对每个数据结构实现,协议可能对每个数据结构都有一个兼容的实现

defimpl Size, for: BitString do
  def size(binary), do: byte_size(binary)
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 协议来调用正确的实现

Size.size({1, 2})
# => 2
Size.size(%{key: :value})
# => 1

请注意,我们没有针对列表实现它,因为我们没有列表的 size 信息,而是需要使用 length 计算其值。

您正在为其实现协议的数据结构必须是协议中定义的所有函数的第一个参数。

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

协议和结构体

协议的真正好处在于与结构体混合使用。例如,Elixir 附带了许多作为结构体实现的数据类型,例如 MapSet。我们也可以针对这些类型实现 Size 协议

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

当针对结构体实现协议时,如果 defimpl/3 调用位于定义结构体的模块中,则可以省略 :for 选项

defmodule User do
  defstruct [:email, :name]

  defimpl Size do
    # two fields
    def size(%User{}), do: 2
  end
end

如果没有为给定类型找到协议实现,则调用协议将引发错误,除非它被配置为回退到 Any。还提供了用于在现有实现之上构建实现的便利性,有关更多信息,请查看 defstruct/1 以了解有关推断协议的信息。

回退到 Any

在某些情况下,提供所有类型的默认实现可能很方便。这可以通过在协议定义中将 @fallback_to_any 属性设置为 true 来实现

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

现在可以针对 Any 实现 Size 协议

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

虽然上面的实现可能不合理。例如,说 PID 或整数的大小为 0 没有意义。这就是为什么 @fallback_to_any 是一个可选行为的原因之一。对于大多数协议,当未实现协议时引发错误是正确的行为。

多个实现

协议也可以同时针对多种类型实现

defprotocol Reversible do
  def reverse(term)
end

defimpl Reversible, for: [Map, List] do
  def reverse(term), do: Enum.reverse(term)
end

defimpl/3 内部,您可以使用 @protocol 访问正在实现的协议,并使用 @for 访问正在为其定义的模块。

类型

定义协议会自动定义一个名为 t 的零元类型,可以按如下方式使用

@spec print_size(Size.t()) :: :ok
def print_size(data) do
  result =
    case Size.size(data) do
      0 -> "data has no items"
      1 -> "data has one item"
      n -> "data has #{n} items"
    end

  IO.puts(result)
end

上面的 @spec 表示所有允许实现给定协议的类型都是给定函数的有效参数类型。

反射

任何协议模块都包含三个额外的函数

  • __protocol__/1 - 返回协议信息。该函数接受以下原子之一

    • :consolidated? - 返回协议是否已整合

    • :functions - 返回协议函数及其元数的关键字列表

    • :impls - 如果已整合,则返回 {:consolidated, modules},其中包含实现协议的模块列表,否则返回 :not_consolidated

    • :module - 协议模块的原子名称

  • impl_for/1 - 返回针对给定参数实现协议的模块,否则返回 nil

  • impl_for!/1 - 与上面相同,但在未找到实现时引发 Protocol.UndefinedError

例如,对于 Enumerable 协议,我们有

iex> Enumerable.__protocol__(:functions)
[count: 1, member?: 2, reduce: 3, slice: 1]

iex> Enumerable.impl_for([])
Enumerable.List

iex> Enumerable.impl_for(42)
nil

此外,每个协议实现模块都包含 __impl__/1 函数。该函数接受以下原子之一

  • :for - 返回负责协议实现的数据结构的模块

  • :protocol - 返回提供此实现的协议模块

例如,实现列表的 Enumerable 协议的模块是 Enumerable.List。因此,我们可以对该模块调用 __impl__/1

iex(1)> Enumerable.List.__impl__(:for)
List

iex(2)> Enumerable.List.__impl__(:protocol)
Enumerable

整合

为了加快协议分派速度,只要所有协议实现都是预先知道的(通常是在项目中的所有 Elixir 代码编译后),Elixir 提供了一项名为 *协议整合* 的功能。整合直接将协议与其实现链接起来,以一种方式调用整合协议中的函数相当于调用两个远程函数。

协议整合在编译期间默认应用于所有 Mix 项目。这在测试期间可能是一个问题。例如,如果您想在测试期间实现协议,该实现将不起作用,因为协议已经整合。一个可能的解决方案是在您的 mix.exs 中包含特定于您的测试环境的编译目录

def project do
  ...
  elixirc_paths: elixirc_paths(Mix.env())
  ...
end

defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]

然后,您可以在 test/support/some_file.ex 中定义特定于测试环境的实现。

另一种方法是在您的 mix.exs 中在测试期间禁用协议整合

def project do
  ...
  consolidate_protocols: Mix.env() != :test
  ...
end

如果您正在使用 Mix.install/2,则可以通过传递 consolidate_protocols 选项来做到这一点

Mix.install(
  deps,
  consolidate_protocols: false
)

虽然这样做不建议,因为它可能会影响代码的性能。

最后,请注意所有协议都使用 debug_info 设置为 true 编译,而与 elixirc 编译器设置的选项无关。调试信息用于整合,并且在整合后会删除,除非全局设置。

总结

函数

检查给定模块是否已加载,并且是给定协议的实现。

检查给定模块是否已加载,并且是协议。

接收协议和实现列表,并整合给定协议。

如果协议已整合,则返回 true

使用给定选项为 module 推断 protocol

从给定路径中提取为给定协议实现的所有类型。

从给定路径中提取所有协议。

函数

链接到此函数

assert_impl!(protocol, base)

查看源代码
@spec assert_impl!(module(), module()) :: :ok

检查给定模块是否已加载,并且是给定协议的实现。

如果成功则返回 :ok,否则引发 ArgumentError

链接到此函数

assert_protocol!(module)

查看源代码
@spec assert_protocol!(module()) :: :ok

检查给定模块是否已加载,并且是协议。

如果成功则返回 :ok,否则引发 ArgumentError

链接到此函数

consolidate(protocol, types)

查看源代码
@spec consolidate(module(), [module()]) ::
  {:ok, binary()} | {:error, :not_a_protocol} | {:error, :no_beam_info}

接收协议和实现列表,并整合给定协议。

整合通过将协议 impl_for 更改为抽象格式来实现快速查找规则。通常,整合期间使用的实现列表是通过 extract_impls/2 的帮助检索的。

它返回协议字节码的更新版本。如果元组的第一个元素是 :ok,则表示协议已整合。

可以通过分析协议属性来检查给定的字节码或协议实现是否已整合。

Protocol.consolidated?(Enumerable)

此函数不会在任何时候加载协议,也不会加载编译模块的新字节码。但是,每个实现都必须可用,并且将被加载。

链接到此函数

consolidated?(protocol)

查看源代码
@spec consolidated?(module()) :: boolean()

如果协议已整合,则返回 true

链接到此宏

derive(protocol, module, options \\ [])

查看源代码 (宏)

使用给定选项为 module 推断 protocol

如果您的实现传递了选项,或者您正在根据结构体生成自定义代码,那么您还需要实现一个定义为 __deriving__(module, struct, options) 的宏来获取传递的选项。

示例

defprotocol Derivable do
  def ok(arg)
end

defimpl Derivable, for: Any do
  defmacro __deriving__(module, struct, options) do
    quote do
      defimpl Derivable, for: unquote(module) do
        def ok(arg) do
          {:ok, arg, unquote(Macro.escape(struct)), unquote(options)}
        end
      end
    end
  end

  def ok(arg) do
    {:ok, arg}
  end
end

defmodule ImplStruct do
  @derive [Derivable]
  defstruct a: 0, b: 0
end

Derivable.ok(%ImplStruct{})
#=> {:ok, %ImplStruct{a: 0, b: 0}, %ImplStruct{a: 0, b: 0}, []}

现在可以通过 __deriving__/3 调用显式推断。

# Explicitly derived via `__deriving__/3`
Derivable.ok(%ImplStruct{a: 1, b: 1})
#=> {:ok, %ImplStruct{a: 1, b: 1}, %ImplStruct{a: 0, b: 0}, []}

# Explicitly derived by API via `__deriving__/3`
require Protocol
Protocol.derive(Derivable, ImplStruct, :oops)
Derivable.ok(%ImplStruct{a: 1, b: 1})
#=> {:ok, %ImplStruct{a: 1, b: 1}, %ImplStruct{a: 0, b: 0}, :oops}
链接到此函数

extract_impls(protocol, paths)

查看源代码
@spec extract_impls(module(), [charlist() | String.t()]) :: [atom()]

从给定路径中提取为给定协议实现的所有类型。

路径可以是字符列表或字符串。它们在内部以字符列表的形式处理,因此将它们作为列表传递可以避免额外的转换。

不加载任何实现。

示例

# Get Elixir's ebin directory path and retrieve all protocols
iex> path = Application.app_dir(:elixir, "ebin")
iex> mods = Protocol.extract_impls(Enumerable, [path])
iex> List in mods
true
链接到此函数

extract_protocols(paths)

查看源代码
@spec extract_protocols([charlist() | String.t()]) :: [atom()]

从给定路径中提取所有协议。

路径可以是字符列表或字符串。它们在内部以字符列表的形式处理,因此将它们作为列表传递可以避免额外的转换。

不加载任何协议。

示例

# Get Elixir's ebin directory path and retrieve all protocols
iex> path = Application.app_dir(:elixir, "ebin")
iex> mods = Protocol.extract_protocols([path])
iex> Enumerable in mods
true