Протоколи


Протоколите са начин за постигане на полиморфизъм в Elixir. Те ни предоставят механизъм, чрез който вече съществуващо поведение може да се имплементира за нов тип от данни. Използвайки протоколи можем да си построим библиотека, която да е възможно да бъде разширявана от този, който я ползва.

Протоколите в Elixir са имплементирани върху нещо наречено поведения (behaviors). Те са специфични за езика, за разлика от тези поведения, които идват от Erlang.

Дефиниране на протокол

Протокол може да се дефинира с defprotocol. В тази статия, ще дефинираме протокол за преобразуване на стойности в JSON формат:

defprotocol JSON do
  @doc "Converts the given data to its JSON representation"
  def encode(data)
end

Дефинирахме протокол JSON, който декларира функция JSON.encode/1. Тя преобразува данни в някаква валидна JSON форма. Например за атома nil, трябва да получим "null".

Нека да пробваме:

JSON.encode(nil)
#=> ** (Protocol.UndefinedError) protocol JSON not implemented for nil
#=> json_protocol.ex:1: JSON.impl_for!/1
#=> json_protocol.ex:3: JSON.encode/1

Тази грешка получаваме, защото не сме имплементирани протокола за nil.

Имплементиране на протокол

Протокол се имплементира с defimpl. Нека имплементираме JSON за атоми:

defimpl JSON, for: Atom do
  def encode(true), do: "true"
  def encode(false), do: "false"
  def encode(nil), do: "null"

  def encode(atom) do
    JSON.encode(Atom.to_string(atom))
  end
end

Тук разписваме JSON протокола за Atom данни. Тъй като в JSON формата "null", "true" и "false" са валидни стойности, ние ги връщаме като низ съответно за nil, true и false. Всички други атоми, най-лесно могат да се възприемат като низове.

JSON.encode(true)
#=> "true"
JSON.encode(false)
#=> "false"
JSON.encode(nil)
#=> "null"

#=> JSON.encode(:name)
#=> ** (Protocol.UndefinedError) protocol JSON not implemented for "name"
#=>     json_protocol.ex:1: JSON.impl_for!/1
#=>     json_protocol.ex:3: JSON.encode/1

Това е нормално поведение. Имаме имплементация за nil и булевите стойности, но не и за низове. Нека имплементираме протокола за низове. В Elixir, това са binary-та, както знаем.

defimpl JSON, for: BitString do
  def encode(<< >>), do: ~s("")
  def encode(str) do
    cond do
      String.valid?(str) -> ~s("#{str}")
      true -> JSON.encode(bitstring_to_list(str))
    end
  end

  defp bitstring_to_list(binary) when is_binary(binary) do
    list_of_bytes(binary, [])
  end

  defp bitstring_to_list(bits), do: list_of_bits(bits, [])

  defp list_of_bytes(<<>>, list), do: list |> Enum.reverse
  defp list_of_bytes(<< x, rest::binary >>, list) do
    list_of_bytes(rest, [x | list])
  end

  defp list_of_bits(<<>>, list), do: list |> Enum.reverse
  defp list_of_bits(<< x::1, rest::bits >>, list) do
    list_of_bits(rest, [x | list])
  end
end

За този случай за тип се използва BitString. Когато str е валиден низ, всичко е лесно, просто слагаме стойността му в кавички (""). При тези дефиниции използваме сигила ~s, за да не ескейпваме кавичките. Когато обаче str не е валиден низ, го трансформираме в списък от байтове, ако е binary и в списък от битове ако броят на битовете му не е кратен на 8.

JSON.encode(:name)
#=> "\"name\""
JSON.encode("")
#=> "\"\""
JSON.encode("some")
#=> "\"some\""

JSON.encode(<< 200, 201 >>)
#=> ** (Protocol.UndefinedError) protocol JSON not implemented for [200, 201]
#=>     json_protocol.ex:1: JSON.impl_for!/1
#=>     json_protocol.ex:3: JSON.encode/1

Работи както трябва - нормално е да се оплаква че протоколът не е имплементиран за списък. Нека го имплементираме:

defimpl JSON, for: List do
  def encode(list) do
    "[#{list |> Enum.map(&JSON.encode/1) |> Enum.join(", ")}]"
  end
end

Имплементацията е доста проста - всеки от елементите на списъка е конвертиран към JSON, след това са събрани в [] и разделени със запетая. Да видим дали работи:

JSON.encode([nil, true, false])
#=> "[null, true, false]"

JSON.encode(<< 200, 201 >>)
#=> ** (Protocol.UndefinedError) protocol JSON not implemented for 200
#=>              json_protocol.ex:1: JSON.impl_for!/1
#=>              json_protocol.ex:3: JSON.encode/1

Работи за списък от вече имплементирани типове данни. Но за списък от цели числа - байтовете, не работи. Трябва да го имплементираме за цели числа:

defimpl JSON, for: Integer do
  def encode(n), do: n
end

За цели числа е наистина лесно : просто връщаме числото. Сега следното работи:

JSON.encode(<< 200, 201 >>)
#=> "[200, 201]"

Последното, което остана е да го имплементираме за речници. Стойности от този тип данни ще се трансформират в JSON обекти от типа { "key": value }:

defimpl JSON, for: Map do
  def encode(map) do
    "{#{map |> Enum.map(&encode_pair/1) |> Enum.join(", ")}}"
  end

  defp encode_pair(pair) do
    {key, value} = pair

    "#{JSON.encode(to_string(key))}: #{JSON.encode(value)}"
  end
end

Отново проста имплентация: за всяка двойка ключ и стойност, превръщаме ключа в низ и го трансформираме във валиден JSON низ, трансформираме стойността в JSON и ги конкатенираме с : между тях -> "key": value. Всички такива двойки обединяваме в един низ със запетайки между тях. Този низ слагаме в ‘къдрави’ скоби.

data = %{
  name: "Pesho",
  age: 43,
  likes: [:drinking, "eating shopska salad", "да гледа мачове"]
}

JSON.encode(data)
#=> {"age": 43, "likes": ["drinking", "eating shopska salad", "да гледа мачове"], "name": "Pesho"}

или

{
  "age": 43,
  "likes": [
    "drinking",
    "eating shopska salad",
    "да гледа мачове"
  ],
  "name": "Pesho"
}

Структури и протоколи

Както знаем структурите са просто именовани речници. От това би трябвало да следва, че ако имплементираме протокол за речник, той ще е може да се прилага за структури.

defmodule Man do
  defstruct [:name, :age, :likes]
end

kosta = %Man{
  name: "Коста",
  age: 54,
  likes: ["Турбо фолк", "Телевизия", "да гледа мачове"]
}
JSON.encode(kosta)
#=> ** (Protocol.UndefinedError) protocol JSON not implemented for %Man{...}
#=>     json_protocol.ex:1: JSON.impl_for!/1
#=>     json_protocol.ex:3: JSON.encode/1

Всяка структура е като нов тип. Ако искаме да имплементира протокол ще трябва да го направим ръчно или да използваме имплементация за всички случаи.

Покриване на всички случаи

Вградените типове за които можем да имплементираме протокол са:

  • Atom
  • BitString
  • Float
  • Function
  • Integer
  • List
  • Map
  • PID
  • Port
  • Reference
  • Tuple

Има начин да имплементираме протокол за всички случаи за които не е имплементиран, използвайки Any:

defimpl JSON, for: Any do
  def encode(_), do: "null"
end

Казваме че за неизвестни типове ще връщаме null като JSON. Нека да пробваме с kosta:

JSON.encode(kosta)
#=> ** (Protocol.UndefinedError) protocol JSON not implemented for %Man{...}
#=>     json_protocol.ex:1: JSON.impl_for!/1
#=>     json_protocol.ex:3: JSON.encode/1

Същото поведение. Това е така, защото за да използваме имплементацията за Any, трябва да го обявим при дефиниране на структурата:

defmodule Man do
  @derive JSON
  defstruct [:name, :age, :likes]
end

nikodim = %Man{
  name: "Никодим",
  age: 15,
  likes: ["Порно", "GTA V"]
}
JSON.encode(nikodim)
#=> "null"

С тази директива - @derive, казваме на Man да имплементира JSON, използвайки Any имплементацията.

Ако не искаме да обявяваме @derive JSON за всяка имплементация, можем да модифицираме самия протокол, така че да се ползва Any имплементацията за всеки тип, който не имплементира протокола:

defprotocol JSON do
  @fallback_to_any true

  @doc "Converts the given data to its JSON representation"
  def encode(data)
end

Така за тип като Tuple, за който протоколът JSON не е имплементиран ще получим имплементацията за Any:

JSON.encode({:ok, :KO})
#=> "null"

Протоколи идващи с езика

Има няколко протокола, които идват с езика. Можем да ги видим използвайки Protocol.extract_protocols/1. Модулът Protocol съдържа функции за работа с протоколи. Ще разгледаме някои от тях. Ето как можем да видим списък съдържащ вградените протоколи:

path = :code.lib_dir(:elixir, :ebin)
Protocol.extract_protocols([path])
#=> [Collectable, Inspect, String.Chars, List.Chars, Enumerable]

Използвахме :code.lib_dir/2 да извлечем пътя до директорията, съдържаща вградените модули на Elixir. Това е Erlang-ска функция, затова получаваме charlist. След това, с помощта на Protocol.extract_protocols/1 прочитаме списъка.

Да разгледаме протоколите:

  • Collectable - това е протоколът, използван от Enum.into/2 и Enum.into/3, за преобразуване на Enummerable в дадена колекция.
  • Inspect - използва се за pretty printing и debug.
  • String.Chars - Kernel.to_string/1 го използва.
  • List.Chars - Kernel.to_charlist/1 го използва.
  • Enumerable - Enum функциите очакват това, което им се подава като първи аргумент, да имплементира този протокол. И Collectable работи с имплементации на протокола.

Само 5, но всички тях вече сме ползвали по един или друг начин.

Нека видим кои типове имплементират Enumerable. Това можем да направим, използвайки Protocol.extract_impls/2. Тази функция взима за първи аргумент протокола, на който търсим имплементациите, както и пътища. Ще използваме същия този път до вградените модули на Elixir:

Protocol.extract_impls(Enumerable, [path])
#=> [MapSet, Function, Range, Stream, IO.Stream, Date.Range, HashSet, File.Stream,
#=>  GenEvent.Stream, List, Map, HashDict]

Тук няма BitString, тоест низовете не са Enumerable. Това е нормално, защото BitString може да е низ, но може да е просто binary, с невалидни за unicode байтове. Може и да е поредица от битове. Ако е низ, то може да го обхождаме по различни начини - по графеми или по codepoint-и.

Нека все пак за упражнението да имплементираме Enumerable за низове. Идеята е да ги обхождаме по графеми:

defimpl Enumerable, for: BitString do
  def count(str), do: {:ok, String.length(str)}
  def member?(str, char), do: {:ok, String.contains?(str, char)}
  def slice(_), do: {:error, __MODULE__}

  def reduce(_, {:halt, acc}, _fun), do: {:halted, acc}
  def reduce(str, {:suspend, acc}, fun) do
    {:suspended, acc, &reduce(str, &1, fun)}
  end

  def reduce("", {:cont, acc}, _fun), do: {:done, acc}
  def reduce(str, {:cont, acc}, fun) do
    {next, rest} = String.next_grapheme(str)
    reduce(rest, fun.(next, acc), fun)
  end
end

Протоколът Enumerable изисква имплементацията на 4 функции.

Функцията Enumerable.count/1 се използва за намиране на големината на дадената енумерация. Тази функция или връща {:ok, <count>} или {:error, __MODULE__}. Първият тип резултат е ръчна имплементация, докато вторият казва на Enum да ползва Enumerable.reduce/3 в подразбираща се имплементация с линейно време. В случая, връщаме String.length/1 на низа.

Enum.count("Далия")
#=> 5

Следващата функция, която трябва да бъде имплементирана е Enumerable.member?/2. Тя проверява дали елемент е член на дадената енумерация. Възможни резултати са {:ok, boolean} и {:error, __MODULE__}. Както за count, резултатът с :error ще използва reduce. Документацията на тези две функции, препоръчва да ги имплементираме с {:error, __MODULE__}, ако нашата имплементация не е по-добра от линейна имплементация. За упражнението, ние ги имплементираме ръчно, но на практика в тези случаи е най-добре да се върне {:error, __MODULE__} Тук ползваме String.contains?/2.

Enum.member?("Далия", "я")
#=> true

Третата функция е Enumerable.slice/1. Идеята и е да предостави начин за разделяне на енумерация. По този начин намирането на позиция в колекцията може да се оптимизира. Функцията или трябва да върне {:ok, size, slicing_fun} или {:error, __MODULE__}. Тук ние връщаме {:error, __MODULE__}, защото взимането на size е линейно, а се препоръчва ако искаме да ползваме наша имплементация, то да е константно. И тази функция има default-на имплементация чрез reduce. При имплементация, slicing_fun трябва да приема два елемента - позиция в колекцията (>=0) и дължина на парчето за “отрязване” от колекцията (>=1). Много от функциите в Enum ползват тази имплементация, примери са Enum.slice/3, Enum.fetch/2 и Enum.random/1. Важното е да запомним, че я имплементираме само ако можем да разделяме колекцията без линейно обхождане, иначе е по-добре да върнем {:error, __MODULE__}.

Последната функция за имплементиране е Enumerable.reduce/3. Тази функция се ползва както за eager (списъци, речници), така и lazy (потоци) енумерации. Първият ѝ аргумент е енумерацията. В нашия случай - низ. Вторият е двойка от “таг” и акумулираната стойност досега. Третият е функцията reducer, която смята акумулираната стойност. Нека разгледаме нашите имплементации за низ.

Първата имплементация е за случая, когато получим “таг” :halt. Това означава че трябва да прекратим обхождането. Пример за такъв случай е, когато търсим нещо в една енумерация и го намерим. В този случай трябва да върнем {:halted, <акумулирана стойност>}. Правим точно това.

Втората имплементация е за случая, когато получим “таг” :suspend. Това значи да прекратим изпълнението до тук, но това не означава, че то е свършило. Трябва да върнем тройката - :suspended, акумулираната стойност, която да се подаде, когато трябва да продължим и функция която да имплементира продължението. Това е частично извикана функция, знаеща енумерацията и редуциращата си функция и очакваща акумулираната стойност. Може да се използва Enumerable.reduce/3 имплементация, както в примера.

Третата имплементация е за случая, когато получим “таг” :cont и празна енумерация. Трябва да върнем двойката {:done, <резултата-акумулираната-стойност>}. Това е дъното на рекурсията - в нашия случай при празен низ.

Четвъртата ни имплементация е имплементацията на reduce за произволна итерация. Отново идва с “тага” :cont. Тук използваме String.next_grapheme/1 за да вземем текущата първа графема и остатъка от низа. Извикваме рекурсивно reduce с остатъка като енумерация, нова акумулирана стойност, получена от прилагането на редуциращата функция към текущата графема и текущата акумулирана стойност и редуциращата функция.

С тези имплементации Enum функциите ще работят за низове.

"Далия"
|> Enum.filter(fn
     c when c in ~w(а ъ о у е и) -> false
     _ -> true
   end)
|> Enum.join("")
#=> "Для"

Така премахваме гласните букви с помощта на Enum.

Консолидация

Извикването на функции на протоколи е по-бавно от извикването на функции на модули. За протокол, при извикване, трябва да се мине през всички имплементации, докато не се намери тази за типа на аргумента или докато не се изчерпат.

Така наречената консолидация превръща протокол и неговите имплементации в прост модул. При компилация с mix, това става автоматично (от версия 1.2).

Заключение

Протоколите дават възможност за имплементация на дадено поведение за множество типове. Чудесни са за случая, когато предлагате собствена библиотека и желаете този, който я ползва да може да я разшири за собствените си структури.

Добър пример е JSON протокола, който дефинирахме, даже има такава библиотека, която може да се разшири именно защото ползва протокол - poison. Имплементирайте и дефинирайте протоколи, само когато се налага. Не правете кода си прекалено разхвърлян, ако знаете точните типове за които искате да работи.

Ако искате кодът ви да работи за безкрайно множество от типове - може да ползвате протоколи. В Elixir, полиморфизъм може да се постигне по няколко начина. Когато говорим за типове, най-простият е с множество версии на една функция в даден модул, когато борят на имплементациите е ограничен и се знае. Най-често това ще е нашият случай.