Исходный код Протоколы
Протоколы — это механизм достижения полиморфизма в 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 у нас есть два способа проверки количества элементов в структуре данных: length и size. length означает, что информация должна быть вычислена. Например, 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
При необходимости вы могли бы разработать собственную семантику для размера вашей структуры. Более того, вы можете использовать структуры для создания более надежных типов данных, таких как очереди, и реализовать для них все необходимые протоколы, такие как Enumerable и, возможно, Size, для этого типа данных.
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
Вышеприведённая реализация, вероятно, не является разумной. Например, не имеет смысла говорить, что размер PID или Integer равен 0.
Однако, если мы удовлетворены реализацией для Any, чтобы использовать такую реализацию, нам нужно указать нашей структуре явно вывести протокол Size:
defmodule OtherUser do @derive [Size] defstruct [:name, :age] end
При выводе Elixir будет реализовывать протокол Size для OtherUser на основе предоставленной реализации для Any.
Падение по умолчанию на Any
Ещё один вариант @derive — явно указать протоколу переходить по умолчанию на Any, когда реализация не найдена. Это можно сделать, установив @fallback_to_any на true в определении протокола:
defprotocol Size do @fallback_to_any true def size(data) end
Как мы говорили в предыдущем разделе, реализация Size для Any не может быть применена ко всем типам данных. Это одна из причин, почему @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.
© 2012-2024 The Elixir Team
Licensed under the Apache License, Version 2.0.
https://hexdocs.pm/elixir/1.17.2/protocols.html