檢視原始碼 協定 (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!(協定, 基底)

檢視原始碼
@spec assert_impl!(module(), module()) :: :ok

檢查指定的模組是否已載入,且為指定協定的實作。

如果是,則傳回 :ok,否則會引發 ArgumentError

@spec assert_protocol!(module()) :: :ok

檢查指定的模組是否已載入,且為協定。

如果是,則傳回 :ok,否則會引發 ArgumentError

連結到此函式

consolidate(協定, 類型)

檢視原始碼
@spec consolidate(module(), [module()]) ::
  {:ok, binary()} | {:error, :not_a_protocol} | {:error, :no_beam_info}

接收協定和實作清單,並合併指定的協定。

合併是透過變更抽象格式中的協定 impl_for 來進行,以建立快速的查詢規則。通常會在合併期間使用的實作清單,會透過 extract_impls/2 的協助來擷取。

它會傳回協定位元組碼的更新版本。如果元組的第一個元素是 :ok,表示協定已合併。

可以透過分析協定屬性來檢查指定的位元組碼或協定實作是否已合併

Protocol.consolidated?(Enumerable)

此函式在任何時間點都不會載入協定,也不會載入已編譯模組的新位元組碼。但是,每個實作都必須可用,而且會載入。

@spec consolidated?(module()) :: boolean()

如果協定已合併,則傳回 true

連結到此巨集

derive(協定, 模組, 選項 \\ [])

檢視原始碼 (巨集)

使用指定的選項為 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(協定, 路徑)

檢視原始碼
@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(路徑)

檢視原始碼
@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