Elixir снабжен сложной, очень хорошо продуманной, инфраструктурой макросов. С легкой руки Криса Маккорда, в сообществе существует негласный закон, который неизбежно озвучивается сразу, как только речь заходит о макросах: «Первое правило использования макросов — вы не должны их использовать». Иногда с малозаметной ремаркой, набранной бледно-серым шрифтом четвертого кегля: «только если вам этого не избежать, и вы очень хорошо понимаете, на что идете, и чем рискуете». Это связано с тем, что макросы имеют доступ ко всему AST модуля, в котором они используются, и, вообще говоря, могут до неузнаваемости изменить получившийся код.


Я в принципе согласен, что не следует использовать макросы в процессе ознакомления с языком. Пока вы не можете, будучи разбуженным в три часа ночи с похмелья, ответить на вопрос, выполняется ли этот код на стадии компиляции, или же в рантайме. Elixir — компилируемый язык, и в процессе компиляции происходит выполнение кода «верхнего уровня», полное раскрытие синтаксического дерева до тех пор, пока мы не окажемся в ситуации, когда дальше раскрывать уже нечего, и вот этот результат в конечном итоге и компилируется в BEAM. Когда компилятор встречает вызов макроса в исходном коде, он полностью раскрывает AST для него и впихивает вместо собственно вызова. Понять это невозможно, это можно только запомнить.


Но как только мы почувствуем себя свободно в синтаксисе, мы неизбежно захотим воспользоваться всей мощью инструментария; тут уж без макросов — никуда. Главное — не злоупотреблять. Макросы позволяют резко уменьшить (до отрицательных величин) количество шаблонного кода, который может потребоваться, да и обеспечивают естественный и очень удобный способ манипулирования AST. Phoenix, Ecto, и все заметные библиотеки очень интенсивно используют макросы.


Вышесказанное справедливо для любой универсальной библиотеки / пакета. По моему опыту, обычным проектам макросы скорее не нужны, или нужны в очень ограниченном ареале применения. Библиотеки, наоборот, часто состоят из макросов в соотношении 80/20 к обычному коду.


Я не собираюсь устраивать тут песочницу и лепить куличики макросов напоказ, для тех, кто пока не очень в курсе, с чем их вообще едят; если интересно начать изучение с азов, понять, что это такое в принципе, или как и зачем они используются в самом Elixir, — лучше сразу закрыть эту страницу и прочитать блестящую книгу Metaprogramming Elixir Криса Маккорда, создателя Phoenix Framework. Я хочу всего лишь продемонстрировать некоторые трюки, позволяющие сделать уже существующую макро-эко-систему лучше.




Макросы намеренно скупо документированы. Этот нож слишком острый, чтобы рекламировать его малышам.


Существует два способа использования макросов. Простейший — вы инструктируете компилятор, что данный модуль будет использовать макросы из другого с помощью директивы Kernel.SpecialForms.require/2, и вызываете сам макрос после этого (для макросов определенных в этом же модуле, явный require не нужен). В этой заметке нас интересует другой, более заковыристый способ. Когда внешний код вызывает use MyLib, ожидается, что наш модуль MyLib реализует макрос __using__/1, который и будет вызван компилятором, когда он встретит use MyLib. Синтаксический сахар, да. Convention over configuration. Рельсовое прошлое Жозе не прошло бесследно.


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


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


defmodule User do
  use MyApp.ActiveRecord,
    repo: MyApp.Repo,
    roles: ~w|supervisor client subscriber|,
    preload: ~w|setting companies|a

Аргумент типа keyword() будет передан в MyApp.ActiveRecord.__using__/1, и там я его использую для создания разнообразных хелперов для работы с этой моделью. (Примечание: этот код давно выпилен, потому что ActiveRecord проигрывает по всем статьям нативным вызовам Ecto).




Иногда мы хотим ограничить использование макросов некоторым подмножеством модулей (например, разрешить использовать его только в структурах). Явная проверка внутри реализации __using__/1 не будет работать, так, как нам бы хотелось, потому что в процессе компиляции модуля у нас нет доступа к его __ENV__ (а и был бы — он еще далеко не полон в момент, когда компилятор наткнулся на вызов __using__/1. Идеально было бы выполнить эту проверку после завершения компиляции.


Нет проблем! Есть два атрибута модуля, которые настраивают именно это. Добро пожаловать к нам в гости, уважаемые колбеки времени компиляции.


Приведу тут краткий отрывок из документации.


@after_compile колбек будет вызван сразу после компиляции текущего модуля.

Принимает модуль или кортеж {module, function_name}. Сам колбек должен принимать два аргумента: окружение модуля и его байт-код. Когда в качестве аргумента передается только модуль, предполагается, что этот модуль экспортирует функцию __after_compile__/2 существует.

Колбеки, зарегистрированные первыми, будут выполняться последними.
defmodule MyModule do
  @after_compile __MODULE__
  def __after_compile__(env, _bytecode) do
    IO.inspect env
  end
end

Я настоятельно не рекомендую инжекцию __after_compile__/2 непосредственно в сгенерированный код, поскольку это может привести к конфликтам с намерениями конечных пользователей (которые могут захотеть использовать свои собственные обработчики). Определите функцию где-то внутри вашего MyLib.Helpers или типа того, и передайте кортеж в @after_compile:


quote location: :keep do
  @after_compile({MyLib.Helpers, :after_mymodule_callback})
end



Этот колбек будет вызван сразу после компиляции соответствующего модуля, который использует нашу библиотеку, и получит два параметра: структуру __ENV__ и байт-код скомпилированного модуля. Последний редко используется простыми смертными; первый обеспечивает все, что нам нужно. Ниже приведен пример того, как я защищаюсь от попыток вызвать use Iteraptable из модулей, не имплементирующих структуры. По сути, проверяющий код просто вызывает из колбека __struct__ на скомпилированном модуле и банально делегирует Elixir право выбросить исключение с внятным текстом, объясняющим причину неполадки:


def struct_checker(env, _bytecode), do: env.module.__struct__

Код выше бросит исключение, если скомпилированный модуль не является структурой. Конечно, код проверки может быть намного сложнее, но основная идея заключается в том, ожидает ли ваш используемый модуль чего-то от модуля, который его использует. Если да — имеет смысл не забыть про @after_compile и ругаться оттуда, если не будут выполнены все необходимые условия. Бросить исключение — правильный подход в этом случае чуть более, чем всегда, поскольку этот код выполняется на стадии компиляции.




С @after_callback связана забавная история, которая в полной мере объясняет, почему я люблю OSS в целом и Elixir в частности. Около года назад я ошибся в копипасте и скопировал откуда-то @after_callback вместо @before_callback. Разница между ними, наверное, очевидна: второй вызывается перед компиляцией, и оттуда каждый может до неузнаваемости изменить синтаксическое дерево. И я его — ух, как — менял. Но это не приводило ни к каким результатам в скомпилированном коде: тот не менялся вовсе. Спустя три чашки кофе, я заметил опечатку, заменил after на before и все завелось; но меня терзал вопрос: а почему компилятор-то молчал, как партизан. Оказалось, что Module.open?/1 возвращает true из этого колбека (что, в принципе, не далеко от истины — модуль-то еще и правда не закрыт, доступ к его атрибутам не закрыт, и многие библиотеки этим недокументированным багом пользуются).


Ну что, я набросал фикс, отослал пулл-реквест в корку языка (в компилятор, если совсем строго), и менее, чем через сутки — он оказался в мастере.


Так было и когда мне потребовались пользовательские параметры в IO.inspect/2, и еще в некоторых случаях. Что было бы, наткнись я на такое в джаве? — Страшно себе представить.




Удачного макросинга!