檢視原始碼 協定

協定是 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 中,我們有兩種慣用語法來檢查資料結構中有多少個項目: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 協定,因為清單沒有預先計算的「大小」資訊,而且清單的長度必須計算(使用 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

如果需要,您可以為結構的大小想出自己的語意。不僅如此,您可以使用結構來建構更強健的資料類型,例如佇列,並為此資料類型實作所有相關協定,例如 EnumerableSize

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

上述實作可以說是不可取的。例如,說 PIDInteger 的大小為 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

正如我們在前一節所說,SizeAny 的實作並不能套用於任何資料類型。這是 @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 語法。這表示 inspect 協定不可逆,因為資訊可能會在過程中遺失

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

Elixir 中還有其他協定,但這涵蓋了最常見的協定。您可以在 Protocol 模組中瞭解更多關於協定和實作的資訊。