Полиморфизм является важной концепцией в программировании, и начинающие программисты обычно узнают об этом в первые месяцы обучения. Полиморфизм в основном означает, что вы можете применить аналогичную операцию к объектам разных типов. Например, функция 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 ; состояние не должно сообщаться). Поделитесь своим решением в комментариях!
Надеюсь, вам понравилось читать эту статью. Если у вас есть какие-либо вопросы, не стесняйтесь обращаться ко мне. Спасибо за терпение и до скорой встречи!