Протоколы и расширяемость в Elixir
В данной статье речь пойдёт о протоколах — особенности Elixir, обеспечивающей расширяемость приложений.
Протоколы
Согласно документации:
Протоколы — это механизм реализации полиморфизма в Elixir. Диспетчеризация возможна для любого типа данных, если тип указан в реализации протокола.
Другими словами, можно создать функцию, поведение которой будет различно в зависимости от типа её первого аргумента. Реализации протоколов могут существовать для одного из встроенных поддерживаемых псевдонимов типов: Atom
, BitString
, Float
, Function
, Integer
, List
, Map
, PID
, Port
, Reference
, Tuple
, Any
, а также для пользовательских структур. Создадим протокол под названием Countable для подсчёта элементов.
Сначала протокол нужно определить:
iex> defprotocol Countable do
...> def count_items(term)
...> end
Заметьте, функция, объявленная в протоколе, не имеет тела. Протоколы поддерживают только заголовки определений; в сущности, они переопределяют макрос def
. Заголовки определений имеют больше ограничений. К примеру, определение, содержащее охранное условие, не будет скомпилировано:
iex> defprotocol Countable do
...> def count_items(term) when is_binary(term)
...> end
** (CompileError) iex:4: missing do keyword in def
iex:4: (module)
Такая же ошибка возникнет при попытке компиляции функции сопоставления с литералом:
iex> defprotocol Countable do
...> def count_items("")
...> end
** (CompileError) iex:4: can use only variables and \\ as arguments in definition header
iex:4: (module)
Заголовок функции в объявлении протокола должен содержать хотя бы один аргумент, именно он впоследствии понадобится для обращения к нужной реализации протокола:
iex> defprotocol Countable do
...> def count_items()
...> end
** (ArgumentError) protocol functions expect at least one argument
(elixir) expanding macro: Protocol.def/1
iex:19: Countable (module)
Определим реализации протокола для подсчёта элементов для типов List
, Map
и пользовательского типа Order
.
Реализация для List
:
iex> defimpl Countable, for: List do
...> def count_items(list), do: length(list)
...> end
Реализация для Map
:
iex> defimpl Countable, for: Map do
...> def count_items(map), do: map_size(map)
...> end
Реализация для Order
:
iex> defmodule Order do
...> defstruct number: 0, items: []
...>
...> defimpl Countable do
...> def count_items(%Order{items: items}) do
...> Countable.count_items(items)
...> end
...> end
...> end
Обратите внимание, что при определении реализации внутри модуля, в котором уже определена структура, аргумент for
может быть опущен.
Несмотря на наличие ограничений на сопоставление при объявлении протокола, в его реализации эти ограничения отсутствуют: можно без опаски использовать сопоставление с образцом и охранные условия. Это означает, что для одного и того же количества аргументов могут существовать несколько тел функции.
Вызовем функцию протокола с указанием аргумента, который совпадает с указанными выше типами:
iex> Countable.count_items([:one, :two])
2
iex> Countable.count_items(%{one: 1, two: 2})
2
iex> Countable.count_items(%Order{number: 1, items: [:apple]})
1
iex> Countable.count_items({:one, :two})
** (Protocol.UndefinedError) protocol Countable not implemented for {:one, :two}
iex:1: Countable.impl_for!/1
iex:2: Countable.count_items/1
Любопытно, что каждая реализация становится подмодулем модуля протокола, а значит, в реализации протокола будут поддерживаться атрибуты модуля. Чтобы убедиться в этом, посмотрим информацию о модуле:
iex> Countable.Order.__info__(:functions)
[__impl__: 1, count_items: 1]
iex> Countable.impl_for(%Order{})
Countable.Order
Атрибут модуля @for
Реализации протокола имеют доступ к атрибуту модуля @for
, который представляет собой псевдоним текущего типа, и атрибуту @protocol
— псевдониму реализуемого протокола. Это очень удобно во время реализации протокола для различных псевдонимов:
iex> defprotocol ToList do
...> def to_list(term)
...> end
iex> defimpl ToList, for: [Map, Tuple] do
...> def to_list(term) do
...> @for.to_list(term)
...> end
...> end
iex> ToList.to_list({:ok, "Hurray"})
[:ok, "Hurray"]
В примере выше оба модуля Map
и Tuple
определяют функцию to_list
, и в зависимости от значения атрибута @for
будет вызвана правильная реализация.
Резервная реализация для Any
Any
— особый псевдоним, который можно использовать в качестве резервной реализации протокола или его реализации по умолчанию. При этом, в объявлении протокола необходимо указать:
iex> defprotocol Countable do
...> @fallback_to_any true
...> def count_items(term)
...> end
iex> defimpl Countable, for: Any do
...> def count_items(_), do: :unknown
...> end
iex> Countable.count_items({:one, :two})
:unknown
Примечание: можно обратиться к реализации протокола с Any, если необходимо создать список модулей, которые могут быть восстановлены.
Встроенные протоколы
Существует некоторое количество встроенных протоколов, которые могут оказаться крайне полезными в практических задачах. На данный момент это такие протоколы, как Collectable, Enumerable, Inspect, List.Chars и String.Chars. Каждый из них имеет только одну функцию, за исключением Enumerable
, у которого их целых три. Стоит отметить, что лучше делать протоколы настолько «лёгкими», насколько это возможно.
К списку выше можно было бы добавить и модуль Access, который из протокола превратился в поведение.
Когда их использовать
Одно из значительных преимуществ протоколов заключается в том, что реализация протокола может находиться вне библиотеки/приложения, где он определён. Это пригодится разработчикам библиотек: они смогут создавать точки расширения для пользовательских типов. Вот несколько наглядных примеров из реальных проектов:
- Plug.Exception — протокол, позволяющий исключениям получать код состояния
- Phoenix.Para — протокол, конвертирующий структуры данных в параметры URL
- Scrivener.Paginater — протокол, нумерующий типы
- Poison.Encoder — протокол, кодирующий типы в формат JSON
- Joken.Claims — протокол, превращающий данные в запросы.
Производительность
Согласно документации, существует потенциальная угроза снижения производительности при использовании протоколов:
Поскольку протокол может работать с любым типом данных, необходимо при каждом вызове проверять, существует ли реализация для данного типа. Производительность при этом может упасть.
В связи с этим консолидация протоколов (как часть компиляции) по умолчанию включена. Консолидация протоколов — это процесс оптимизации диспетчеризации путём поиска всех реализаций в проекте или приложении. Чтобы проверить, включена ли эта опция, запустите следующую команду:
iex> Mix.Project.config[:consolidate_protocols]
true
Заключение
Протоколы позволяют с лёгкостью добавлять в код точки расширения, что особенно важно для создателей библиотек. Разумеется, не стоит этим увлекаться. Не используйте протоколы, когда к ним обращаются функции, проводящие сопоставление с образцом. То, что встроенных протоколов всего шесть, только подтверждает эту мысль.