1. Мотивация

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

Остальные рабочие движки можно было бы стандартно реализовать с помощью нескольких специфических серверов движков, но мне захотелось сделать всё по правилам ООП и испытать выразительную мощь языка Elixir. Т. е. дальнейшую разработку предполагается провести в рамках парадигмы ООП, расширяя модуль «класса» тактовых генераторов.

2. Предварительное исследование

В начале поиска решения был расчет на механизм Поведения (behaviour) Elixir. Поэтому более внимательно перечитал учебную литературу, и выяснил, что этот инструмент для схожей, но другой задачи: задание контракта поведения и проверка на этапе компиляции реализации модулем требуемых функций обратного вызова.

Ещё я потратил много времени на поиск в Интернете, как коллеги расширяют модули Elixir аля классы ООП и нашел только один источник конференции по этому вопросу [1]. Но я решил вначале самому попробовать пройти путь квеста, чтобы сравнить потом свой результат с чужими.

Забегая вперед, могу сказать, что одно решения [1#0] с конференции почти совпало с моим, но там отсутствуют обобщение и выводы, которые я собираюсь вам сообщить в конце.

Справка: В статье невольно производится сопоставление ООП и Elixir, из чего можно сделать вывод, что я отношу Elixir к функциональным языкам. Формально это так и есть, но на деле я вслед за ерлангелистом Сашей Юрич [2] расцениваю Elixir вместе с Erlang преимущественно языком акторов.

3. Постановка задачи

Для экспериментов потребовалось выбрать задачу, которая представляла бы полноценный OTP GenServer, достаточно простой, чтобы не «утонуть» в прикладных аспектах и которая естественным образом расширялась бы новыми функциями. Под эти критерии идеально подошла задача сервера одно–операционного калькулятора, производящего только операцию суммирования. Для имитации расширения класса калькулятор необходимо дополнить операцией умножения.

Для простоты будем называть одно–операционный калькулятор базовым классом, а расширенный — подклассом. Им соответствуют модули PlusClass и MulPlusClass.

Модули PlusClass и MulPlusClass представляют собой сервера OTP GenServer. Состав состояния серверов, которое сохраняет результат арифметической операции, в данном случае одинаков. Пока не будем рассматривать изменение полей данных у подкласса.

Для возврата состояния должна быть предусмотрена соответствующая обратная функция.

На мой взгляд, препятствием простого расширения функционала калькулятора импортом модуля является оболочка «черного ящика» OTP GenServer, которая накладывает на модули свои свойства.

4. Реализация

Изначально было понятно, что для целей расширения модуля наиболее приспособлены макросы.

«Большая часть метапрограммирования в Elixir выполняется в определениях модулей с целью расширения функциональности других модуле.»[2]

Проще всего начать решение с образца прямой реализации обратных функций подкласса MulPlusClass, чтобы затем экспериментировать с составом функций. Вот его реализация:

defmodule MulPlusClass do
   use GenServer 
 
   def init(initial_number) do
     { :ok, initial_number }
   end
 
   def handle_call(:number, from, currentnumber) do
     {:reply, current_number, current_number}
   end
 
   def handle_cast({:plus, number}, current_number) do
     { :noreply, current_number + number}
   end
 
   def handle_cast({:mul, number}, current_number) do
     { :noreply, current_number * number}
   end
 end

Функции init/1 и handle_call(:number, from, currentnumber), т. е. возврата состояния, являются общими как для одно–операционного калькулятора, так для расширенного.

Обратим внимание, что 3–я функция суммирования должна наследоваться из составе базового класса PlusClass.

Функции внешнего API я вообще не рассматриваю и не реализовывал их: они только бы размывали конечную структуру решения.

Из состава прямой реализации подкласса MulPlusClass и экспериментов с макрорасширеним обратных функций стало понятно, что на первом шаге разработки потребует оформление обратных функций в макросах: common, plus и mul в отдельном модуле Impl. Вот его реализация:

defmodule Impl do
   defmacro common do
     quote do
       def init(initial_number) do   
         { :ok, initial_number }
       end
 
       def handle_call(:number, from, currentnumber) do
           {:reply, current_number, current_number}
       end
     end
   end
 
   defmacro plus do
     quote do
       def handle_cast({:plus, number}, current_number) do
         { :noreply, current_number + number}
       end
     end
   end
 
   defmacro mul do
     quote do
       def handle_cast({:mul, number}, current_number) do
         { :noreply, current_number * number}
       end
     end
   end 
 end

AST, возвращаемые из макросов модуля Impl, расширяется в контексте целевого модуля класса во время компиляции. А вот реализация модуля MulPlusClass:

defmodule MulPlusClass do
   use GenServer  
    require Impl 

    Impl.common
   Impl.plus
   Impl.mul
 end  

Реализация модуля PlusClass получается простым удалением предложения Impl.mul из модуля MulPlusClass.

И этот вариант реализации уже вполне работает.

5. Конечный вариант

Следующий шаг в расширении модуля свелся к переписыванию предыдущего варианта с использованием макроса use, чтобы получить преимущества стандартных расширений API Elixir.

«В некотором смысле, use — это тривиальная фу��кция. Вы передаёте ей модуль с необязательным аргументом, а она вызывает функцию или макрос use в этом модуле…» [3]. 

defmodule Impl.Common do
   defmacro using(_option) do
     quote do
       import unquote(__MODULE__) # можно закомментировать
       def init(initial_number) do
         { :ok, initial_number }
       end
 
       def handle_call(:number, from, currentnumber) do
           {:reply, current_number, current_number}
       end
     end
   end
 end
 
 defmodule Impl.Plus do
   defmacro using(_option) do
     quote do
       import unquote(__MODULE__) # можно закомментировать
       def handle_cast({:plus, number}, current_number) do
         { :noreply, current_number + number}
       end
     end
   end
 end
 
 defmodule Impl.Mul do
   defmacro using(_option) do
     quote do
       import unquote(__MODULE__) # можно закомментировать
       def handle_cast({:mul, number}, current_number) do
         { :noreply, current_number * number}
       end
     end
   end 
 end   

Костяк логики не изменился, но произошли следующие изменения:

  • Одно определение общего модуля реализации поделили на определения трёх специализированных модулей: Impl.Common, Impl.Plus и Impl.Mul. Каждый макрос получил свою модульную прописку.

  • Помеченные операции импорта можно безболезненно закомментировать. Наши модули классов настолько примитивны, что можно не импортировать модули макросов. Объяснение этому я нашёл в источнике [7].

Реализация модуля MulPlusClass тоже изменилась:

defmodule MulPlusClass do
 use GenServer
 use Impl.Common
 use Impl.Mul
use Impl.Plus
end

Аналогично предыдущему шагу модуль PlusClass получается удалением use Impl.Mul.

Модуль класса выглядит симпатично. Ради такой синтаксической красоты всё это и затевалось. Кому–то это должно понравиться, но я почему–то склонен ко 2–ому брутальному варианту.

Пример использования

iex(1)> {:ok, p_pid} = GenServer.start_link(PlusClass, 1)
{:ok, #PID<0.174.0>}
iex(2)> GenServer.call(p_pid, :number)
1
iex(3)> {:ok, mp_pid} = GenServer.start_link(MulPlusClass, 1)
{:ok, #PID<0.175.0>}
iex(4)> GenServer.call(mp_pid, :number)
1
iex(5)> GenServer.cast(p_pid, {:plus, 10})
:ok
iex(6)> GenServer.cast(mp_pid, {:mul, 10})
:ok
iex(7)> GenServer.call(p_pid, :number)
11
iex(8)> GenServer.call(mp_pid, :number)

Выводы

  1. Внимательный читатель уже обратил внимание, что в результате получилось не классическое наследование подкласса от суперкласса, а простая возможность набора функций класса из общей палитры макросов.

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

  2. Расширение модуля — это чистой воды метапрограммирование. Если вам необходимо специфическое расширение модуля, то AST вам в помощь. Только сначала ответьте себе на один вопрос: оно того стоит? и где вы проводите границу между прикладным программированием и разработкой трансляторов?

  3. Мы не затрагивали вопрос изменения состава обрабатываемых данных, так как в рассматриваемом примере это не требовалось. На практике, в частности при реализации специфических движков, состава состояния сервера будет меняться.

    В таком случае я решил при инициализации движка передавать обратной функции init/1 в качестве параметра словарь пар ключ/значение для адресности обращения к хранилищу состояния. В ближайшее время этим займусь.

Благодарности

Я буду благодарен всем читателям, которые дадут совет, как улучшить предлагаемый способ, укажут на мои недочеты и/или найдут альтернативные решения реализации шаблона имитации расширения модулей в Elixir аки классов.

Литература
  1. https://stackoverflow.com/questions/35302208/how-do-you-extend-inherit-an-elixir-module

  2. Саша Юрич, Elixir в действии, - М.: ДМК Пресс, 2020.

  3. Programmin Elixir≥ 1.6 Functional |> Concurrent |> Pragmatic |> Fun by Dave Thomas, The Pragmatic Bookshelf

  4. Metaprogramming Elixir. Write Less Code, Get More Done (and Have Fun!) by Chris McCord, The Pragmatic Bookshelf.

  5. Саша Юрич, The Erlangelist, https://translated.turbopages.org/proxy_u/enru. ru.992e7888-6444c482-53f34aca- 74722d776562/https/www.theerlangelist.com/

  6. https://blog.appsignal.com/2021/09/07/an-introduction-to-metaprogramming-in-elixir.html

  7. https://stackoverflow.com/questions/41231482/what-does-import-unquote-module-do-in-a-using-callback