Полиморфизм является важной концепцией в программировании, и начинающие программисты обычно узнают об этом в первые месяцы обучения. Полиморфизм в основном означает, что вы можете применить аналогичную операцию к объектам разных типов. Например, функция count / 1 может применяться как к диапазону, так и к списку:
1
2
|
Enum.count(1..3)
Enum.count([1,2,3])
|
Как это возможно? В Elixir полиморфизм достигается с помощью интересной функции, называемой протоколом , которая действует как контракт . Для каждого типа данных, который вы хотите поддерживать, этот протокол должен быть реализован.
В целом, этот подход не является революционным, как это встречается в других языках (например, в Ruby). Тем не менее, протоколы действительно удобны, поэтому в этой статье мы обсудим, как определить, реализовать и работать с ними, а также рассмотрим некоторые примеры. Давайте начнем!
Краткое введение в протоколы
Итак, как уже упоминалось выше, протокол имеет некоторый общий код и опирается на конкретный тип данных для реализации логики. Это разумно, потому что разные типы данных могут требовать разных реализаций. Затем тип данных может отправлять данные по протоколу, не беспокоясь о его внутренностях.
Elixir имеет множество встроенных протоколов, включая Enumerable
, Collectable
, List.Chars
, String.Chars
и String.Chars
. Некоторые из них будут обсуждаться позже в этой статье. Вы можете внедрить любой из этих протоколов в свой пользовательский модуль и получить множество функций бесплатно. Например, реализовав Enumerable , вы получите доступ ко всем функциям, определенным в модуле Enum , что довольно круто.
Если вы пришли из чудесного мира Ruby, полного объектов, классов, фей и драконов, вы встретите очень похожую концепцию миксинов . Например, если вам когда-либо понадобится сделать ваши объекты сопоставимыми, просто смешайте модуль с соответствующим именем в классе. Затем просто реализуйте метод космического корабля <=>
и все экземпляры класса получат все методы, такие как >
и <
бесплатно. Этот механизм чем-то похож на протоколы в Elixir. Даже если вы никогда не встречали эту концепцию раньше, поверьте мне, это не так сложно.
Итак, обо всем по порядку: протокол должен быть определен, поэтому давайте посмотрим, как это можно сделать в следующем разделе.
Определение протокола
Определение протокола не включает никакой черной магии — на самом деле, это очень похоже на определение модулей. Используйте defprotocol / 2, чтобы сделать это:
1
2
|
defprotocol MyProtocol do
end
|
Внутри определения протокола вы размещаете функции, как и в случае с модулями. Разница лишь в том, что эти функции не имеют тела. Это означает, что протокол определяет только интерфейс, план, который должен быть реализован всеми типами данных, которые хотят отправить по этому протоколу:
1
2
3
|
defprotocol MyProtocol do
def my_func(arg)
end
|
В этом примере программист должен реализовать my_func/1
для успешного использования MyProtocol
.
Если протокол не реализован, возникнет ошибка. Давайте вернемся к примеру с функцией count/1
определенной внутри модуля Enum
. Выполнение следующего кода приведет к ошибке:
1
2
3
4
5
|
Enum.count 1
# ** (Protocol.UndefinedError) protocol Enumerable not implemented for 1
# (elixir) lib/enum.ex:1: Enumerable.impl_for!/1
# (elixir) lib/enum.ex:146: Enumerable.count/1
# (elixir) lib/enum.ex:467: Enum.count/1
|
Это означает, что Integer
не реализует протокол Enumerable
(что удивительно), и поэтому мы не можем считать целые числа. Но протокол действительно может быть реализован, и этого легко достичь.
Реализация протокола
Протоколы реализуются с помощью макроса defimpl / 3 . Вы указываете, какой протокол для реализации и для какого типа:
1
2
3
4
5
|
defimpl MyProtocol, for: Integer
def my_func(arg) do
IO.puts(arg)
end
end
|
Теперь вы можете сделать ваши целые числа исчисляемыми, частично реализовав протокол Enumerable
:
1
2
3
4
5
6
7
|
defimpl Enumerable, for: Integer do
def count(_arg) do
{:ok, 1} # integers always contain one element
end
end
Enum.count(100) |> IO.puts # => 1
|
Мы обсудим протокол Enumerable
более подробно позже в этой статье, а также реализуем его другую функцию.
Что касается типа (переданного в for
), вы можете указать любой встроенный тип, свой собственный псевдоним или список псевдонимов:
1
2
|
defimpl MyProtocol, for: [Integer, List] do
end
|
Кроме того, вы можете сказать Any
:
1
2
3
4
5
|
defimpl MyProtocol, for: Any
def my_func(_) do
IO.puts «Not implemented!»
end
end
|
Это будет действовать как резервная реализация, и ошибка не возникнет, если протокол не реализован для какого-либо типа. Чтобы это работало, установите для атрибута @fallback_to_any
значение true
в вашем протоколе (в противном случае ошибка все равно будет возникать):
1
2
3
4
|
defprotocol MyProtocol do
@fallback_to_any true
def my_func(arg)
end
|
Теперь вы можете использовать протокол для любого поддерживаемого типа:
1
2
|
MyProtocol.my_func(5) # simply prints out 5
MyProtocol.my_func(«test») # prints «Not implemented!»
|
Примечание о структурах
Реализация протокола может быть вложена в модуль. Если этот модуль определяет структуру , вам даже не нужно указывать при вызове defimpl
:
1
2
3
4
5
6
7
8
9
|
defmodule Product do
defstruct title: «», price: 0
defimpl MyProtocol do
def my_func(%Product{title: title, price: price}) do
IO.puts «Title #{title}, price #{price}»
end
end
end
|
В этом примере мы определяем новую структуру под названием Product
и реализуем наш демонстрационный протокол. Внутри просто сопоставьте образец и цену, а затем выведите строку.
Помните, однако, что реализация должна быть вложена в модуль — это означает, что вы можете легко расширить любой модуль, не обращаясь к его исходному коду.
Пример: протокол String.Chars
Хорошо, хватит с абстрактной теорией: давайте посмотрим на некоторые примеры. Я уверен, что вы довольно широко использовали функцию IO.puts / 2 для вывода отладочной информации на консоль при игре с Elixir. Конечно, мы можем легко выводить различные встроенные типы:
1
2
3
|
IO.puts 5
IO.puts «test»
IO.puts :my_atom
|
Но что произойдет, если мы попытаемся вывести нашу структуру Product
созданную в предыдущем разделе? Я помещу соответствующий код в модуль Main
потому что в противном случае вы получите сообщение о том, что структура не определена или не доступна в той же области видимости:
01
02
03
04
05
06
07
08
09
10
11
|
defmodule Product do
defstruct title: «», price: 0
end
defmodule Main do
def run do
%Product{title: «Test», price: 5} |> IO.puts
end
end
Main.run
|
Запустив этот код, вы получите ошибку:
1
|
(Protocol.UndefinedError) protocol String.Chars not implemented for %Product{price: 5, title: «Test»}
|
Ага! Это означает, что функция put опирается на встроенный протокол String.Chars . Пока это не реализовано для нашего Product
, ошибка возникает.
String.Chars
отвечает за преобразование различных структур в двоичные файлы, и единственная функция, которую вам нужно реализовать, это to_string / 1 , как указано в документации. Почему бы нам не реализовать это сейчас?
1
2
3
4
5
6
7
8
9
|
defmodule Product do
defstruct title: «», price: 0
defimpl String.Chars do
def to_string(%Product{title: title, price: price}) do
«#{title}, $#{price}»
end
end
end
|
Имея этот код на месте, программа выведет следующую строку:
1
|
Test, $5
|
А это значит, что все работает просто отлично!
Пример: проверка протокола
Другая очень распространенная функция — IO.inspect / 2 для получения информации о конструкции. Внутри модуля Kernel
определена функция inspect / 2 — она выполняет проверку в соответствии со встроенным протоколом Inspect .
Нашу структуру Product
можно проверить сразу же, и вы получите краткую информацию о ней:
1
2
|
%Product{title: «Test», price: 5} |> IO.inspect
# or: %Product{title: «Test», price: 5} |> inspect |> IO.puts
|
Будет возвращено %Product{price: 5, title: "Test"}
. Но, опять же, мы можем легко реализовать протокол Inspect
, который требует кодирования только функции inspect / 2 :
1
2
3
4
5
6
7
8
9
|
defmodule Product do
defstruct title: «», price: 0
defimpl Inspect do
def inspect(%Product{title: title, price: price}, _) do
«That’s a Product struct. It has a title of #{title} and a price of #{price}. Yay!»
end
end
end
|
Второй аргумент, передаваемый этой функции — это список опций , но мы не заинтересованы в них.
Пример: перечислимый протокол
Теперь давайте рассмотрим немного более сложный пример, когда будем говорить о протоколе Enumerable. Этот протокол используется модулем Enum , который предоставляет нам такие удобные функции, как each / 2 и count / 1 (без него вам пришлось бы придерживаться простой старой рекурсии).
Enumerable определяет три функции, которые вы должны реализовать для реализации протокола:
- count / 1 возвращает размер перечисляемого.
- member? / 2 проверяет, содержит ли перечисляемый элемент элемент.
- Reduce / 3 применяет функцию к каждому элементу перечислимого.
Имея все эти функции на месте, вы получите доступ ко всем вкусностям, предоставляемым модулем Enum
, что действительно выгодно.
В качестве примера давайте создадим новую структуру под названием Zoo
. Он будет иметь название и список животных:
1
2
3
|
defmodule Zoo do
defstruct title: «», animals: []
end
|
Каждое животное также будет представлено структурой:
1
2
3
|
defmodule Animal do
defstruct species: «», name: «», age: 0
end
|
Теперь давайте создадим новый зоопарк:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
|
defmodule Main do
def run do
my_zoo = %Zoo{
title: «Demo Zoo»,
animals: [
%Animal{species: «tiger», name: «Tigga», age: 5},
%Animal{species: «horse», name: «Amazing», age: 3},
%Animal{species: «deer», name: «Bambi», age: 2}
]
}
end
end
Main.run
|
Итак, у нас есть «Демо-зоопарк» с тремя животными: тигром, лошадью и оленем. Теперь я хотел бы добавить поддержку функции count / 1 , которая будет использоваться следующим образом:
1
|
Enum.count(my_zoo) |> IO.inspect
|
Давайте реализуем эту функциональность сейчас!
Реализация функции счета
Что мы имеем в виду, когда говорим «посчитай мой зоопарк»? Это звучит немного странно, но, вероятно, это означает подсчет всех животных, которые там живут, поэтому реализация основной функции будет довольно простой:
1
2
3
4
5
6
7
8
9
|
defmodule Zoo do
defstruct title: «», animals: []
defimpl Enumerable do
def count(%Zoo{animals: animals}) do
{:ok, Enum.count(animals)}
end
end
end
|
Все, что мы здесь делаем, это полагаемся на функцию count / 1 при передаче ей списка животных (потому что эта функция поддерживает списки из коробки). Очень важно упомянуть, что функция count/1
должна возвращать свой результат в виде кортежа {:ok, result}
как диктуется документами. Если вы ** (CaseClauseError) no case clause matching
только число, вы увидите ошибку ** (CaseClauseError) no case clause matching
будет ** (CaseClauseError) no case clause matching
.
Вот и все. Теперь вы можете сказать Enum.count(my_zoo)
внутри Main.run
, и он должен вернуть 3
в результате. Молодец!
Реализующий член? функция
Следующая функция, определяемая протоколом, это member?/2
. В результате он должен вернуть кортеж {:ok, boolean}
который говорит, содержит ли перечислимое (переданное в качестве первого аргумента) элемент (второй аргумент).
Я хочу, чтобы эта новая функция говорила, живет ли конкретное животное в зоопарке или нет. Поэтому реализация также довольно проста:
01
02
03
04
05
06
07
08
09
10
11
|
defmodule Zoo do
defstruct title: «», animals: []
defimpl Enumerable do
# …
def member?(%Zoo{title: _, animals: animals}, animal) do
{:ok, Enum.member?(animals, animal)}
end
end
end
|
Еще раз отметим, что функция принимает два аргумента: перечисляемый и элемент. Внутри мы просто полагаемся на функцию member?/2
для поиска животного в списке всех животных.
Итак, теперь мы бежим:
1
|
Enum.member?(my_zoo, %Animal{species: «tiger», name: «Tigga», age: 5}) |> IO.inspect
|
И это должно вернуть true
поскольку у нас действительно есть такое животное в списке!
Реализация функции уменьшения
Все становится немного сложнее с помощью функции reduce/3
. Он принимает следующие аргументы:
- перечислимый, чтобы применить функцию к
- аккумулятор для хранения результата
- фактическая функция редуктора для применения
Что интересно, аккумулятор на самом деле содержит кортеж с двумя значениями: глаголом и значением: {verb, value}
. Глагол представляет собой атом и может иметь одно из следующих трех значений:
-
:cont
(продолжение) -
:halt
(прекратить) -
:suspend
(временно приостановить)
Результирующее значение, возвращаемое функцией reduce/3
также является кортежем, содержащим состояние и результат. Состояние также является атомом и может иметь следующие значения:
-
:done
(обработка завершена, это конечный результат) -
:halted
(обработка была остановлена, потому что аккумулятор содержал глагол:halt
) -
:suspended
(обработка приостановлена)
Если обработка была приостановлена, мы должны вернуть функцию, представляющую текущее состояние обработки.
Все эти требования хорошо демонстрируются реализацией функции reduce/3
для списков (взятых из документов):
1
2
3
4
|
def reduce(_, {:halt, acc}, _fun), do: {:halted, acc}
def reduce(list, {:suspend, acc}, fun), do: {:suspended, acc, &reduce(list, &1, fun)}
def reduce([], {:cont, acc}, _fun), do: {:done, acc}
def reduce([h | t], {:cont, acc}, fun), do: reduce(t, fun.(h, acc), fun)
|
Мы можем использовать этот код в качестве примера и кодировать нашу собственную реализацию для структуры Zoo
:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
|
defmodule Zoo do
defstruct title: «», animals: []
defimpl Enumerable do
def reduce(_, {:halt, acc}, _fun), do: {:halted, acc}
def reduce(%Zoo{animals: animals}, {:suspend, acc}, fun) do
{:suspended, acc, &reduce(%Zoo{animals: animals}, &1, fun)}
end
def reduce(%Zoo{animals: []}, {:cont, acc}, _fun), do: {:done, acc}
def reduce(%Zoo{animals: [head | tail]}, {:cont, acc}, fun) do
reduce(%Zoo{animals: tail}, fun.(head, acc), fun)
end
end
end
|
В последнем предложении функции мы берем заголовок списка, содержащего всех животных, применяем к нему функцию, а затем выполняем reduce
отношению к хвосту. Когда животных больше не осталось (третье предложение), мы возвращаем кортеж с состоянием :done
и конечным результатом. Первое предложение возвращает результат, если обработка была остановлена. Второе предложение возвращает функцию, если передан глагол :suspend
.
Теперь, например, мы можем легко рассчитать общий возраст всех наших животных:
1
|
Enum.reduce(my_zoo, 0, fn(animal, total_age) -> animal.age + total_age end) |> IO.puts
|
По сути, теперь у нас есть доступ ко всем функциям модуля Enum
. Давайте попробуем использовать join / 2 :
1
|
Enum.join(my_zoo) |> IO.inspect
|
Однако вы получите сообщение о том, что протокол String.Chars
не реализован для структуры Animal
. Это происходит потому, что join
пытается преобразовать каждый элемент в строку, но не может сделать это для Animal
. Поэтому давайте теперь также реализуем протокол String.Chars
:
1
2
3
4
5
6
7
8
9
|
defmodule Animal do
defstruct species: «», name: «», age: 0
defimpl String.Chars do
def to_string(%Animal{species: species, name: name, age: age}) do
«#{name} (#{species}), aged #{age}»
end
end
end
|
Теперь все должно работать просто отлично. Кроме того, вы можете попробовать запустить каждую / 2 и отобразить отдельных животных:
1
|
Enum.each(my_zoo, &(IO.puts(&1)))
|
Еще раз, это работает, потому что мы реализовали два протокола: Enumerable
(для Zoo
) и String.Chars
(для Animal
).
Вывод
В этой статье мы обсудили, как полиморфизм реализуется в Elixir с использованием протоколов. Вы узнали, как определять и реализовывать протоколы, а также использовать встроенные протоколы: Enumerable
, String.Chars
и String.Chars
.
В качестве упражнения вы можете попытаться расширить возможности нашего модуля Zoo
с помощью протокола Collectable, чтобы можно было правильно использовать функцию Enum.into / 2 . Этот протокол требует реализации только одной функции: в / 2 , которая собирает значения и возвращает результат (обратите внимание, что он также должен поддерживать глаголы :done
:halt
и :cont
; состояние не должно сообщаться). Поделитесь своим решением в комментариях!
Надеюсь, вам понравилось читать эту статью. Если у вас есть какие-либо вопросы, не стесняйтесь обращаться ко мне. Спасибо за терпение и до скорой встречи!