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
, и еще в некоторых случаях. Что было бы, наткнись я на такое в джаве? — Страшно себе представить.
Удачного макросинга!