Раздел 23 · Rust

Гигиена и паттерны макросов

senior~30 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Гигиена и паттерны макросов

В прошлом уроке мы собрали механику macro_rules!: правила, фрагменты, повторения, токены вместо текста. Но два вопроса остались висеть, и без ответов на них писать макросы в реальном коде опасно. Первый: почему переменная tmp, которую мой макрос завёл внутри раскрытия, не затирает переменную tmp в коде вызова, и что делать, когда столкновение всё-таки нужно. Второй: как макрос вызывает сам себя, чтобы обработать аргументы по одному, и чем это отлаживать, когда раскрытие стало нечитаемым. В этом уроке разберём гигиену, рекурсивные приёмы и cargo expand, а в конце честно поговорим о цене: подсказки IDE, время сборки, читаемость и когда декларативный макрос вообще не стоит писать.

Гигиена: у каждого имени свой контекст

Возьмём макрос, который заводит свою временную переменную:

macro_rules! make {
    ($x:expr) => {{
        let tmp = 100;
        $x + tmp
    }};
}

fn main() {
    let tmp = 1;              // твоя переменная с тем же именем
    let r = make!(tmp);       // подставляем твой tmp как $x
    println!("{r}");          // 101, а не 200
}

После раскрытия в коде оказываются две переменные с именем tmp: твоя (= 1) и заведённая макросом (= 100). По наивной логике текстовой подстановки внутренний let tmp = 100 затенил бы твою, и $x + tmp посчиталось бы как 100 + 100. Но результат 101: твой tmp уцелел и подставился как 1. Это работает гигиена макроса.

Механизм такой: каждому токену компилятор приписывает синтаксический контекст, невидимую метку «откуда ты пришёл». Имя tmp, написанное в main, и имя tmp, рождённое внутри make!, несут разные контексты. А две переменные считаются одной и той же, только если совпали и текст, и контекст. Текст совпал, контекст нет, значит, это разные переменные, и они мирно сосуществуют.

Покрути стенд: переключи между настоящим, гигиеничным раскрытием Rust и тем, что было бы при тупой текстовой замене. Цвет показывает контекст каждого имени, а внизу результат.

Важная оговорка: macro_rules! гигиеничен частично. Метки-контексты защищают локальные переменные и времена жизни, но не распространяются на типы, поля структур и пути к именам. Поэтому, если макрос ссылается на Vec::new(), а в крейте вызова кто-то завёл свой тип Vec, может случиться путаница. Лечение это $crate: специальная метапеременная, которая разворачивается в путь к крейту, где макрос определён. Пиши $crate::collections::HashMap вместо голого HashMap, и макрос будет ссылаться на правильный тип независимо от того, что наимпортировано на стороне вызова.

Когда гигиену нужно обойти

Гигиена защищает, но иногда мешает: бывает, макрос обязан ввести имя, видимое снаружи. Например, макрос-конструктор, который должен объявить переменную, и ты потом ей пользуешься. Если макрос придумает имя сам, ты до него не дотянешься: разные контексты. Решение одно: передай идентификатор внутрь как аргумент через :ident.

macro_rules! declare {
    ($name:ident, $val:expr) => {
        let $name = $val;     // имя пришло из места вызова, контекст у него твой
    };
}

fn main() {
    declare!(score, 42);      // ты сам дал имя score
    println!("{score}");      // и видишь его: контекст совпадает
}

Тут $name несёт контекст места вызова (ты же его и написал), поэтому переменная score после раскрытия видна твоему коду. Это и есть управление гигиеной: имена, которые макрос придумывает сам, изолированы; имена, которые ты передал ему как :ident, остаются твоими. Запомни правило: если переменная из макроса должна быть видна снаружи, её имя обязано прийти снаружи.

Рекурсивные макросы и TT muncher

Повторение $( ... )* хорошо, пока все элементы однородны. Но для разнородной обработки (по одному токену за шаг, с накоплением состояния) макрос вызывает сам себя. Базовый приём это TT muncher: на каждом шаге макрос откусывает ведущий токен через :tt, обрабатывает его и зовёт себя с хвостом, а отдельное правило ловит пустой вход и завершает рекурсию.

macro_rules! count {
    () => { 0 };                              // база: пусто это ноль
    ($head:tt $($tail:tt)*) => {              // откусываем один токен,
        1 + count!($($tail)*)                 // считаем 1 и рекурсия по хвосту
    };
}

fn main() {
    let n = count!(a b c d);    // 1 + (1 + (1 + (1 + 0))) == 4
    println!("{n}");
}

Читается как обычная рекурсия: правило с пустым входом это базовый случай, правило $head:tt $($tail:tt)* это шаг (голова плюс хвост). Так макрос обрабатывает произвольно сложный, разнородный вход, который одним повторением не описать. У приёма две цены, о которых надо знать заранее. Первая: каждый шаг переразбирает весь оставшийся хвост, поэтому стоимость растёт квадратично по числу токенов, и на больших входах компиляция ощутимо тормозит. Вторая: рекурсия упирается в лимит рекурсии: по умолчанию 128 уровней, и длинный muncher его пробивает с ошибкой recursion limit reached. Поэтому для частых операций вроде подсчёта элементов есть нерекурсивные трюки (например, через массив и его len()), которые и быстрее, и не упираются в лимит.

Ещё один частый приём это внутренние правила: служебные ветки макроса помечают префиксом вроде @inner, чтобы спрятать рекурсивную кухню внутри одного имени и не засорять крейт десятком вспомогательных макросов. Пользователь зовёт mymacro!(...), а ветки mymacro!(@step ...) работают незаметно для него.

Отладка: cargo expand

Самая частая боль с макросами: вызов компилируется, а ошибка вылезает где-то «внутри» и непонятна. Лечится тем, что раскрытие можно увидеть глазами. Инструмент это cargo expand, сторонняя подкоманда:

cargo install cargo-expand     # один раз
cargo expand                   # печатает код после раскрытия всех макросов
cargo expand path::to::module  # только нужный модуль

Запусти её на коде с твоим макросом, и ты увидишь настоящий сгенерированный код: развёрнутые println!, твои count!, любой #[derive(...)]. Это первое, что стоит сделать, когда макрос ведёт себя не так: не гадать по вызову, а прочитать раскрытие. Оговорка: вывод частично теряет форматирование и метки гигиены, так что это инструмент понять, а не буквально скомпилировать обратно. Но для отладки бесценно.

Цена и когда не надо

Макрос умеет то, чего не умеет функция, но платит за это не процессор, а человек и сборка.

  • Подсказки IDE слабеют. Внутри раскрытия автодополнение, переход к определению и подсветка ошибок работают хуже или не работают вовсе. Код, спрятанный в макрос, для редактора полупрозрачен.
  • Время компиляции растёт. Рекурсивные макросы (особенно TT muncher с квадратичной стоимостью) и обильная генерация заметно удлиняют сборку.
  • Читаемость падает. Тот, кто читает вызов mymacro!(...), не видит, что он порождает. Лишний уровень косвенности, который надо держать в голове.
  • Размер бинарника. Макрос разворачивается на каждом вызове, и если раскрытие большое, код дублируется (в отличие от функции, которую вызывают по одному адресу).

Отсюда правило выбора. Сначала пробуй функцию или дженерик. Они дают полные подсказки IDE, понятный стек ошибок, переиспользование без дублирования кода. Макрос бери только тогда, когда без него нельзя: переменная арность, генерация объявлений, доступ к исходному синтаксису аргумента, своя мини-DSL. Хорошая эвристика: если задачу решает обычная функция, написать макрос это почти всегда ошибка. Если же ты ловишь себя на копировании одного и того же impl по десяти типам или на «хочу синтаксис, которого в языке нет», макрос на месте. Сборник тонких приёмов в dtolnay/case-studies хорошо показывает, что сложность тут не в самих макросах, а в обычных возможностях Rust, которые они вытаскивают наружу.

Правило урока

Свернём всё в одну мысль.

Гигиена приписывает каждому имени синтаксический контекст «откуда оно пришло», и две переменные считаются одной, только если совпали и текст, и контекст; поэтому tmp из макроса не затирает tmp из места вызова. Защита частичная: на типы и пути она не распространяется, для устойчивых ссылок есть $crate. Если же имя из макроса должно быть видно снаружи, передай его внутрь как :ident, и контекст останется твоим. Разнородный вход обрабатывают рекурсией (TT muncher: откуси :tt, обработай, позови себя с хвостом), помня про квадратичную цену и лимит рекурсии 128. Видеть раскрытие глазами помогает cargo expand. И главное: макрос платит подсказками IDE, временем сборки и читаемостью, поэтому берут его, только когда функция или дженерик задачу не решают.

Типичная ошибка, которую снимает урок: ждать, что переменная, заведённая внутри макроса, будет видна в коде вызова (или наоборот, бояться, что временная переменная макроса затрёт твою). На деле всё решает контекст: имя, которое макрос придумал сам, изолировано гигиеной; чтобы протащить имя наружу, его надо принять параметром :ident. А когда раскрытие ведёт себя загадочно, не гадай по вызову, запусти cargo expand и прочитай настоящий код.

ДЗ

Дальше

Ты закрыл декларативные макросы целиком: механику из прошлого урока и тонкую часть из этого, гигиену, рекурсию, отладку и цену. У macro_rules! есть жёсткий потолок: он умеет только сопоставлять с образцом и подставлять шаблон. Он не может прочитать имена полей структуры, перебрать варианты enum, посмотреть на типы, вычислить что-то нетривиальное по входу. Как только нужна настоящая логика над кодом (обойти все поля, сгенерировать match по вариантам, разобрать атрибуты), декларативного макроса не хватает. Тут начинаются процедурные макросы: на этапе компиляции запускается обычная программа на Rust, которая принимает код как TokenStream и возвращает другой TokenStream. В следующем уроке напишем свой #[derive(...)] с помощью syn и quote и заглянем, как под капотом устроен serde::Serialize.