Протоколи
Протоколите са начин за постигане на полиморфизъм в 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, полиморфизъм може да се постигне по няколко начина. Когато говорим за типове, най-простият е с множество версии на една функция в даден модул, когато борят на имплементациите е ограничен и се знае. Най-често това ще е нашият случай.