Статьи

Полиморфизм с протоколами в эликсире

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

Помните, однако, что реализация должна быть вложена в модуль — это означает, что вы можете легко расширить любой модуль, не обращаясь к его исходному коду.

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

Надеюсь, вам понравилось читать эту статью. Если у вас есть какие-либо вопросы, не стесняйтесь обращаться ко мне. Спасибо за терпение и до скорой встречи!