Протоколи
Протоколите са начин за постигане на полиморфизъм в 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"
.
Нека да пробваме:
iex> 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
.
iex> JSON.encode(true)
"true"
iex> JSON.encode(false)
"false"
iex> JSON.encode(nil)
"null"
iex> 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
.
iex> JSON.encode(:name)
"\"name\""
iex> JSON.encode("")
"\"\""
iex> JSON.encode("some")
"\"some\""
iex> 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
, след това са събрани в []
и разделени със запетая. Да видим дали работи:
iex> JSON.encode([nil, true, false])
"[null, true, false]"
iex> 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
За цели числа е наистина лесно - просто връщаме числото. Сега следното работи:
iex> JSON.encode(<< 200, 201 >>)
"[200, 201]"
Последното, което остана е да го имплементираме за Map
. Този тип данни ще са
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
. Всички такива двойки обединяваме
в един низ със запетайки между тях. Този низ слагаме в ‘къдрави’ скоби.
iex> data = %{
name: "Pesho",
age: 43,
likes: [:drinking, "eating shopska salad", "да гледа мачове"]
}
iex> IO.puts JSON.encode(data)
{"age": 43, "likes": ["drinking", "eating shopska salad", "да гледа мачове"], "name": "Pesho"}
или
{
"age": 43,
"likes": [
"drinking",
"eating shopska salad",
"да гледа мачове"
],
"name": "Pesho"
}
Структури и протоколи
Както знаем структурите са просто именовани Map
-ове. От това би трябвало да
следва, че ако имплементираме протокол за Map
, той ще е може да се прилага
за структури.
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"
Сега работи с Any
имплементацията. С тази директива - @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
:
iex> JSON.encode({:ok, :KO})
"null"
Протоколи идващи с езика
Има няколко протокола, които идват с езика.
Можем да ги видим използвайки Protocol.extract_protocols/1
.
Модулът Protocol
съдържа методи за работа с протоколи, ще разгледаме някои от
тях. Нека започнем с този. Нека да го накараме да ни върне списък с вградените протоколи:
iex> path = :code.lib_dir(:elixir, :ebin)
iex> 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
, за преобразуване наEnummerable
в дадена колекция.Inspect
- използва се за pretty printing.String.Chars
-Kernel.to_string/1
го използва.List.Chars
-Kernel.to_charlist/1
го използва.Enumerable
-Enum
методите очакват това, което им се подава да имплементира този протокол. Също иCollectable
работи с имплементации на протокола.
Само 5
, но всички тях вече сме ползвали по един или друг начин.
Нека видим кои типове имплементират Enumerable
. Това можем да направим, използвайки
Protocol.extract_impls/2
. Този метод взима за първи аргумент протокола на който ще намерим имплементациите,
както и пътища. Ще използваме същия този път до вградените модули на Elixir
:
iex> Protocol.extract_impls(Enumerable, [path])
[Stream, List, Function, Map, IO.Stream, Range, MapSet, HashDict, HashSet,
GenEvent.Stream, File.Stream]
Ето как виждаме кои типове имплементират даден протокол. Както виждате го няма
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 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
изисква имплементацията на 3
функции.
Функцията Enumerable.count/1
се използва за намиране на големината на дадената енумерация.
Тази функция или връща {:ok, <count>}
или {:error, __MODULE__}
.
Първият тип резултат е ръчна имплементация, докато вторият казва на Enum
да ползва Enumerable.reduce/3
,
в подразбираща се имплементация с линейно време.
В случая, връщаме String.length/1
на низа.
iex> Enum.count("Далия")
5
Следващата функция, която трябва да бъде имплементирана е Enumerable.member?/2
.
Тя проверява дали елемент е член на дадената енумерация. Възможни резултати са
{:ok, boolean}
и {:error, __MODULE__}
. Както за count
, резултатът с :error
ще използва reduce
.
Документацията на тези два метода, препоръчва да ги имплементираме с {:error, __MODULE__}
, ако нашата
имплементация не е по-добра от линейна имплементация.
За упражнението, ние ги имплементираме ръчно, но на практика в тези случаи е най-добре да се върне {:error, __MODULE__}
Тук ползваме String.contains?/2
.
iex> Enum.member?("Далия", "я")
true
Последният метод за имплементиране е 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
.
Когато говорим за типове - най-простият е с множество версии на една функция в даден модул,
при ограничен брой имплементации. Най-често това ще е нашият случай.