Метапрограммирование Elixir
В главе Идиоматика и обработка ошибок было сказано: «не пишите макросы, пока не исчерпаны функции». Совет верный — и при этом читать макросы вам придётся с первого дня. defmodule, def, if, use Ecto.Schema, ~H, schema "users" do ... end, test "..." do ... end — всё это макросы. Пока они выглядят магией, чужой код кажется непредсказуемым, ошибки компиляции — загадочными, а стектрейсы — бессмысленными.
Эта глава снимает магию. Мы разберём, что такое AST, как код превращается в код, почему переменные не «протекают», и в каких редких случаях свой макрос действительно оправдан.
Код — это данные
Elixir принадлежит к семейству гомоиконных языков: любую конструкцию можно представить структурой данных самого языка. Эта структура — AST (абстрактное синтаксическое дерево), и в Elixir она предельно простая. Любой не-литерал — это кортеж из трёх элементов:
{форма, метаданные, аргументы}
Проверим в IEx:
iex> quote do: 1 + 2
{:+, [context: Elixir, imports: [{1, Kernel}, {2, Kernel}]], [1, 2]}
iex> quote do: sum(1, 2, 3)
{:sum, [], [1, 2, 3]}
iex> quote do: x
{:x, [], Elixir} # переменная: третий элемент — контекст, а не список
iex> quote do: %{a: 1}
{:%{}, [], [a: 1]}
quote/2 не вычисляет выражение, а возвращает его дерево. Обратная операция — Macro.to_string/1:
iex> quote(do: if(x > 0, do: :pos, else: :neg)) |> Macro.to_string()
"if(x > 0, do: :pos, else: :neg)"
Литералы квотируются в себя. Атомы, числа, строки, списки, двухэлементные кортежи остаются собой — их не нужно оборачивать:
iex> quote do: [1, :ok, "текст", {1, 2}]
[1, :ok, "текст", {1, 2}]
Вложенное выражение — это вложенное дерево. 1 + 2 * 3 разбирается по приоритетам операторов:
Общая теория синтаксических деревьев — в треке про компиляторы: AST. Особенность Elixir в том, что AST — не внутренний артефакт компилятора, а обычное значение, которое ваш код может строить, обходить и возвращать.
Этапы компиляции
Понимание, что происходит когда, снимает 90% вопросов «почему макрос видит не то, что я ожидал».
(quoted expression)"] AST --> EXP["Раскрытие макросов
(expansion), рекурсивно"] EXP --> CHK["Проверки компилятора:
неиспользуемые переменные,
неизвестные функции, @impl"] CHK --> BC["Erlang Abstract Format"] BC --> BEAM[".beam — байт-код"] BEAM --> RUN["Загрузка в VM
и исполнение"] EXP -.макрос сам исполняется ЗДЕСЬ.-> EXP
Главный вывод: макрос выполняется в момент компиляции и работает не со значениями, а с деревьями. Он получает AST аргументов и обязан вернуть AST — который будет подставлен на место вызова и раскрыт дальше.
Посмотреть результат раскрытия можно руками — это лучший инструмент отладки макросов:
iex> quote(do: if(true, do: :a, else: :b))
...> |> Macro.expand(__ENV__)
...> |> Macro.to_string()
...> |> IO.puts()
case true do
x when x in [false, nil] -> :b
_ -> :a
end
Вот и вся «магия» if: это макрос, который разворачивается в case.
Первый макрос: unless на пустом месте
defmodule MyMacros do
# defmacro, а не def. Аргументы приходят как AST, возвращаем AST.
defmacro my_unless(condition, do: block) do
quote do
if unquote(condition), do: nil, else: unquote(block)
end
end
end
defmodule Demo do
require MyMacros # require обязателен: макрос нужен на этапе компиляции
import MyMacros
def run(x) do
my_unless x > 10 do
IO.puts("не больше десяти")
end
end
end
unquote/1 — «дырка» в дереве: подставь сюда AST, который пришёл в переменной. Без unquote вы вставите буквально имя переменной condition, а не то, что за ней стоит.
Разница между require и import: require гарантирует, что модуль скомпилирован до текущего и его макросы доступны по полному имени; import дополнительно вносит имена в текущее пространство. Для макросов require обязателен, import — по вкусу.
unquote_splicing и bind_quoted
unquote_splicing/1 вставляет список элементов «россыпью», а не одним списком:
iex> args = [1, 2, 3]
iex> quote(do: sum(unquote_splicing(args))) |> Macro.to_string()
"sum(1, 2, 3)"
bind_quoted решает частую и коварную проблему — многократное вычисление аргумента:
# ПЛОХО: expr будет вычислено дважды
defmacro log_twice(expr) do
quote do
IO.puts("1: #{inspect(unquote(expr))}")
IO.puts("2: #{inspect(unquote(expr))}")
end
end
# log_twice(pop_from_queue()) — очередь опустеет на два элемента!
# ХОРОШО: bind_quoted вычисляет ровно один раз и связывает результат
defmacro log_twice(expr) do
quote bind_quoted: [value: expr] do
IO.puts("1: #{inspect(value)}")
IO.puts("2: #{inspect(value)}")
end
end
Это один из немногих «острых углов» метапрограммирования, который кусает даже опытных: аргумент макроса — не значение, а рецепт его получения, и каждый unquote вставляет этот рецепт заново.
Гигиена: почему переменные не протекают
Наивная реализация макросов в других языках приводит к захвату переменных: макрос заводит temp, и вы больше не можете иметь свою temp рядом. Elixir гигиеничен: переменные из quote живут в собственном контексте.
defmodule Hygiene do
defmacro define_temp do
quote do
temp = :из_макроса
end
end
end
defmodule UseIt do
require Hygiene
def run do
temp = :моё
Hygiene.define_temp()
temp # => :моё — макрос НЕ затёр нашу переменную
end
end
Технически это работает через третий элемент AST-кортежа переменной: у {:temp, [], Elixir} и {:temp, [], Hygiene} разный контекст, и компилятор считает их разными переменными.
Иногда «протечка» нужна намеренно — например, чтобы макрос ввёл переменную, видимую пользователю. Тогда есть var!/1:
defmacro define_conn do
quote do
var!(conn) = %Plug.Conn{} # намеренно вводим conn в область вызова
end
end
Так работает conn в тестах Phoenix и socket в некоторых DSL. Пользуйтесь этим редко и документируйте: неявно появившаяся переменная — то, что читатель кода не может вывести из текста.
Гигиена распространяется и на алиасы с импортами: quote запоминает, какие alias/import действовали в месте определения макроса, и раскрытие использует именно их, а не то, что оказалось в области вызова.
use, __using__ и хуки компиляции
use SomeModule — не ключевое слово, а макрос из трёх строк:
# упрощённая суть Kernel.use/2
defmacro use(module, opts) do
quote do
require unquote(module)
unquote(module).__using__(unquote(opts))
end
end
То есть use GenServer буквально означает «вставь сюда то, что вернёт GenServer.__using__/1». Никакого наследования, никакого «фреймворка внутри» — просто вставка кода.
defmodule MyApp.Repoish do
defmacro __using__(opts) do
table = Keyword.fetch!(opts, :table)
quote do
# 1. Атрибуты уровня модуля
@table unquote(table)
# 2. Готовые функции — их получит каждый, кто сделает use
def table_name, do: @table
def all, do: MyApp.Repo.query!("SELECT * FROM #{@table}")
# 3. Функция с возможностью переопределения
def cast(row), do: row
defoverridable cast: 1
end
end
end
defmodule MyApp.Users do
use MyApp.Repoish, table: "users"
# переопределяем только то, что нужно
def cast(row), do: Map.update!(row, "email", &String.downcase/1)
end
defoverridable — важный приём библиотек: даёте разумную реализацию по умолчанию и позволяете заменить её, при этом super/1 вызовет исходную.
Хуки жизненного цикла модуля
(route/2, field/2, ...) —
копят данные в @атрибутах C->>B: @before_compile Lib B-->>M: генерирует итоговые функции
из накопленных атрибутов C->>C: компиляция в байт-код Note over C: @after_compile — проверки уже готового модуля
Этот трёхтактный узор — регистрация в атрибуты, затем генерация в @before_compile — лежит в основе почти всех DSL Elixir: роутер Phoenix, schema Ecto, plug в Plug.Builder.
Собственный DSL: маленький валидатор
Соберём всё вместе на реалистичном примере — декларативном описании правил валидации, которое компилируется в обычные функции.
defmodule Contract do
defmacro __using__(_opts) do
quote do
import Contract, only: [rule: 2]
# accumulate: true — каждый @rule добавляется в список, а не перезаписывает
Module.register_attribute(__MODULE__, :rules, accumulate: true)
@before_compile Contract
end
end
@doc "Регистрирует правило: поле и проверяющая функция"
defmacro rule(field, do: check) do
quote do
# Сохраняем AST проверки в атрибут — на этапе компиляции
@rules {unquote(field), unquote(Macro.escape(check))}
end
end
defmacro __before_compile__(env) do
rules = Module.get_attribute(env.module, :rules)
# Генерируем по клаузе на каждое правило + собирающую функцию
checks =
for {field, check} <- rules do
quote do
defp check_one(unquote(field), value) do
var!(value) = value
_ = var!(value)
unquote(check)
end
end
end
quote do
unquote_splicing(checks)
@doc "Проверяет map и возвращает список ошибок"
def validate(data) do
for {field, _} <- @rules,
not check_one(field, Map.get(data, field)),
do: {field, :invalid}
end
def rules, do: @rules
end
end
end
defmodule UserContract do
use Contract
rule :email do
is_binary(value) and String.contains?(value, "@")
end
rule :age do
is_integer(value) and value >= 18
end
end
UserContract.validate(%{email: "a@b.com", age: 30}) # => []
UserContract.validate(%{email: "нет", age: 12}) # => [email: :invalid, age: :invalid]
Разберём, что здесь произошло:
Module.register_attribute(..., accumulate: true)превращает@rulesв накопитель.Macro.escape/1нужен, потому что мы кладём AST внутрь атрибута: без escape компилятор попытался бы вычислить дерево как значение.@before_compileполучает%Macro.Env{}— снимок окружения компиляции (модуль, файл, строка, импорты) — и по накопленным данным генерирует функции.- Результат — обычный модуль с обычными функциями. Никакого рантайм-оверхеда: правила «зашиты» в байт-код, а
UserContract.rules/0даёт интроспекцию.
Ровно так же устроен schema в Ecto: field/3 копит описания полей в атрибут, а @before_compile генерирует defstruct, __schema__(:fields), __schema__(:type, field) и прочую рефлексию.
Обход и трансформация деревьев
Macro.prewalk/postwalk обходят AST, позволяя его анализировать или переписывать. Это инструмент линтеров, инструментации и генераторов.
# Посчитать, сколько вызовов IO.inspect осталось в коде
ast = Code.string_to_quoted!(File.read!("lib/my_app/orders.ex"))
{_ast, count} =
Macro.prewalk(ast, 0, fn
{{:., _, [{:__aliases__, _, [:IO]}, :inspect]}, _, _} = node, acc -> {node, acc + 1}
node, acc -> {node, acc}
end)
Именно на таких обходах построены Credo и форматтер: они читают ваш код как данные. Полезно знать и как отладочный приём — «а что на самом деле видит компилятор».
Сигилы
Сигилы — родственный механизм: ~D, ~r, ~H — это макросы sigil_D, sigil_r, sigil_H. Свой сигил определяется тривиально:
defmodule MySigils do
# ~i"1, 2, 3" -> [1, 2, 3]
def sigil_i(string, _modifiers) do
string |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.map(&String.to_integer/1)
end
end
~H из главы про LiveView — макрос-сигил, который разбирает HTML на этапе компиляции и порождает структуру со статикой и динамикой. Именно поэтому диффы LiveView стоят дёшево: вся работа по разделению шаблона сделана компилятором, а не в рантайме.
Честная цена макросов
Метапрограммирование не бесплатно, и цена платится не автором, а читателем.
| Что теряете | В чём проявляется |
|---|---|
| Читаемость | нельзя перейти к определению «функции», которой не существует до компиляции |
| Отладку | стектрейс указывает на сгенерированный код; IO.inspect внутри макроса печатает при сборке |
| Скорость компиляции | раскрытие макросов и зависимости между модулями удлиняют полную пересборку |
| Композицию | макрос нельзя передать в Enum.map или в ` |
| Точность инструментов | Dialyzer и LSP хуже понимают сгенерированный код |
Правило трёх вопросов перед тем, как писать defmacro:
- Решается ли задача функцией? В 95% случаев да. Функции композируются, тестируются и отлаживаются.
- Нужен ли мне отложенный вызов? Если единственная причина — «не вычислять аргумент сразу», передайте анонимную функцию:
fn -> expr end. Это дешевле макроса. - Нужно ли мне сгенерировать код на этапе компиляции? Только здесь макрос по-настоящему уместен: DSL с интроспекцией, компиляция шаблона, регистрация маршрутов, проверка на этапе сборки.
Пример честно оправданного макроса — проверка, которая должна упасть при сборке, а не в проде:
defmacro compile_time_env!(key) do
value = Application.compile_env(:my_app, key)
unless value, do: raise(CompileError, description: "Не задан ключ #{inspect(key)}")
Macro.escape(value)
end
Функцией так нельзя: она выполняется слишком поздно.
Карта инструментов
Весь арсенал метапрограммирования Elixir умещается в одну картинку — полезно держать её перед глазами, разбирая чужой код:
Что почитать в исходниках
Лучший способ научиться — читать эталонные реализации. По возрастанию сложности:
Kernel.if/2,Kernel.unless/2,Kernel.|>/2— минимальные, понятные за минуту.ExUnit.Case—__using__+ накопление тестов в атрибутах + генерация функций.Plug.Builder—plug/2копит шаги,@before_compileсобирает их в одну функцию с проверкойhalted.Ecto.Schema— полноценный DSL с рефлексией и генерацией структуры.Phoenix.Router— компиляция маршрутов в клаузы с паттерн-матчингом.
Общая теория проектирования языков поверх языка — в главе Проектирование DSL.
Типичные ошибки
- Макрос там, где хватило бы функции. Самая частая и самая дорогая.
- Забытый
require— «undefined function» на макрос, который точно есть. - Многократный
unquoteодного аргумента — побочные эффекты выполняются несколько раз; лечитсяbind_quoted. var!без нужды — неявные переменные, которые читатель не может отследить.- Работа со значением вместо AST. Внутри макроса
arg— это дерево;arg + 1не сложит числа, а построит дерево сложения (иногда это как раз то, что нужно, — но осознанно). - Отсутствие
Macro.escape/1при попытке положить структуру или AST вquote. - Генерация кода в цикле по большому списку — сотни сгенерированных функций раздувают модуль и время компиляции.
Мини-итог
Метапрограммирование Elixir держится на четырёх фактах: код — это трёхэлементные кортежи; quote даёт дерево, unquote вставляет в него дырки; макросы исполняются на этапе компиляции и возвращают деревья; гигиена не даёт им испортить область вызова. Всё «волшебство» фреймворков — комбинация этих четырёх фактов плюс приём «копим в атрибуты, генерируем в @before_compile».
Умение читать макросы делает вас свободнее в любом Elixir-проекте. Умение вовремя не писать их — делает ваш код читаемым для остальных.
Источники
- Elixir — Macros и Quote and unquote.
- Macro, Code, Module — рабочие инструменты.
- Книга «Metaprogramming Elixir» (Chris McCord, PragProg) — короткая и до сих пор лучшая по теме.
- Anti-patterns: метапрограммирование — официальный список того, чего делать не стоит.
Что дальше
Erlang-интероп и бинарные данные — как выйти за пределы Elixir: битовый синтаксис, порты, NIF и Rustler.