Elixir Метапрограммирование Elixir: AST, quote/unquote, гигиена, макросы и DSL
0%

Метапрограммирование Elixir: AST, quote/unquote, гигиена, макросы и DSL

Метапрограммирование 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% вопросов «почему макрос видит не то, что я ожидал».

Главный вывод: макрос выполняется в момент компиляции и работает не со значениями, а с деревьями. Он получает 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 вызовет исходную.

Хуки жизненного цикла модуля

Этот трёхтактный узор — регистрация в атрибуты, затем генерация в @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:

  1. Решается ли задача функцией? В 95% случаев да. Функции композируются, тестируются и отлаживаются.
  2. Нужен ли мне отложенный вызов? Если единственная причина — «не вычислять аргумент сразу», передайте анонимную функцию: fn -> expr end. Это дешевле макроса.
  3. Нужно ли мне сгенерировать код на этапе компиляции? Только здесь макрос по-настоящему уместен: 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.Builderplug/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-проекте. Умение вовремя не писать их — делает ваш код читаемым для остальных.

Источники

Что дальше

Erlang-интероп и бинарные данные — как выйти за пределы Elixir: битовый синтаксис, порты, NIF и Rustler.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов