Исходный код Протоколы
Протоколы — это механизм достижения полиморфизма в 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.16.3/protocols.html