Декларативные макросы
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Декларативные макросы
В прошлом уроке мы дошли до потолка системы типов: дженерик обобщает значения, но не умеет порождать сам код. А дублирование часто живёт именно на уровне объявлений: десять почти одинаковых
impl, функция с переменным числом аргументов, конструктор коллекции из списка элементов. Функцией это не закрыть, потому что у функции фиксированная арность и она вычисляет аргументы до вызова, а не строит из них новый синтаксис. Тут начинается метапрограммирование, и первый инструмент этоmacro_rules!: кодогенерация по образцу, работающая на этапе компиляции. Ты описываешь пару «образец, во что развернуть», компилятор подбирает образец под вызов и подставляет раскрытие прямо в твою программу ещё до проверки типов. В этом уроке мы разберём, как устроен этот подбор, что такое фрагменты и повторения, чем токены отличаются от текста, и заглянем внутрь самого знакомого макроса:println!.
Зачем макрос, если есть функция
Сначала честный вопрос: что нельзя выразить функцией или дженериком, и потому нужен макрос. Таких задач три класса.
Переменное число аргументов. У функции арность фиксирована: fn sum(a: i32, b: i32) берёт ровно два. А vec![1, 2, 3] принимает сколько угодно элементов, и каждый раз разную длину. Дженерик тут не спасёт, потому что число аргументов это не тип, его нельзя обобщить параметром.
Порождение объявлений. Функция возвращает значение, но не может создать новый impl, struct или match. Если десять типов должны получить почти одинаковую реализацию трейта, дженерик иногда помогает, а иногда нет (например, когда тела чуть-чуть разные). Макрос же подставляет настоящие элементы языка: «сгенерируй мне impl Display для каждого из этих типов» это работа для макроса.
Откладывание вычисления и работа с синтаксисом. Функция вычисляет аргументы до входа в тело. А assert_eq!(a, b) должен при провале напечатать исходный текст a и b, то есть увидеть не значения, а сам код. Функция исходный текст аргумента не видит, макрос видит.
Декларативный макрос отвечает на все три задачи одним механизмом: сопоставлением с образцом по токенам и подстановкой шаблона. «Декларативный» он потому, что ты не пишешь алгоритм генерации, а декларируешь соответствие «такой-то вызов разворачивается в такой-то код».
Анатомия macro_rules!
Объявление выглядит как функция со странным телом. Внутри лежат правила, каждое это пара: образец слева от => и раскрытие справа.
macro_rules! square {
($x:expr) => { // образец: ловим одно выражение в метапеременную $x
$x * $x // раскрытие: подставляем его дважды
};
}
fn main() {
let n = square!(3 + 1); // развернётся в (3 + 1) * (3 + 1) == 16
println!("{n}");
}
Заметь две вещи. Во-первых, $x это не переменная времени выполнения, а метапеременная: она ловит кусок кода при сопоставлении и подставляет его при раскрытии. Во-вторых, square!(3 + 1) разворачивается в (3 + 1) * (3 + 1), а не в 3 + 1 * 3 + 1: захваченный как :expr фрагмент компилятор оборачивает в невидимые скобки и потом не даёт ему «развалиться» соседними операторами. Это ключевое отличие от текстовой подстановки в стиле #define из C, где такой макрос дал бы баг с приоритетом операций. Rust работает не с текстом, а с фрагментами синтаксиса.
Матчеры: что именно ловить
Двоеточие после имени метапеременной задаёт фрагмент-спецификатор: какой кусок синтаксиса метапеременная имеет право поймать. Их около полутора десятков, вот рабочее ядро:
| Спецификатор | Ловит | Пример захвата |
|---|---|---|
:expr | выражение | 3 + 1, foo(), vec![1] |
:ident | идентификатор | x, MyStruct, push |
:ty | тип | u32, Vec<String>, &'a str |
:pat | образец для match | Some(x), _, 0..=9 |
:path | путь | std::cmp::max, Option::Some |
:block | блок в фигурных скобках | { let x = 1; x } |
:literal | литерал | 42, "строка", true |
:tt | одно дерево токенов | что угодно цельное: токен или (...), [...], {...} |
Самый особенный это :tt, дерево токенов: он ловит ровно одну цельную единицу, будь то одиночный токен или целая скобочная группа (...) вместе с содержимым. Поэтому :tt ловит почти всё и стоит в основе продвинутых приёмов. Важная тонкость: все спецификаторы, кроме :tt и :ident, делают захват непрозрачным. Поймав $e:expr, ты уже не можешь в другом образце разобрать $e на части: для компилятора это запечатанный фрагмент-выражение, цельный кирпич.
Повторения: образец, который растягивается
Переменное число аргументов ловится повторением: $( ... )*. Внутри скобок лежит образец, который компилятор сопоставит ноль или больше раз, между ними может стоять разделитель.
macro_rules! my_vec {
( $( $x:expr ),* ) => {{ // ловим ноль+ выражений через запятую
let mut v = Vec::new();
$( v.push($x); )* // на каждое пойманное $x генерируем один push
v
}};
}
let nums = my_vec![10, 20, 30]; // три раза $x: 10, 20, 30
// развернётся в { let mut v = Vec::new(); v.push(10); v.push(20); v.push(30); v }
Символ повторения значит то же, что в регулярках: * это ноль или больше, + это один или больше, ? это ноль или один (без разделителя). Запятая перед * это разделитель между повторами. В раскрытии повторение пишется зеркально: то же $( ... )* разворачивает тело по разу на каждый пойманный элемент, синхронно подставляя метапеременные. Если в образце ты поймал три $x, то и $( v.push($x); )* сгенерирует три push.
Часто в конце ставят $(,)?, чтобы разрешить висячую запятую (my_vec![1, 2, 3,]): «ноль или одна запятая в хвосте». Это та же забота об удобстве, что делает стандартный vec! терпимым к лишней запятой.
Несколько правил и порядок подбора
В одном макросе может быть несколько правил, и компилятор пробует их сверху вниз, побеждает первое подошедшее. Это позволяет дать макросу разные формы вызова:
macro_rules! greet {
() => { println!("привет, мир") }; // правило 0: без аргументов
($name:expr) => { println!("привет, {}", $name) }; // правило 1: с именем
}
greet!(); // подходит правило 0
greet!("Вася"); // правило 0 не подошло, берётся правило 1
Порядок важен: если более общее правило стоит выше более частного, частное может никогда не сработать. Правила пробуются жадно и без отката между ними на уровне «попробовать следующее, если внутри что-то не сошлось по типам»: подбор идёт по форме токенов, а проверка типов случается уже после раскрытия. Это типичный источник путаницы: макрос «подобрался», развернулся, а ошибка вылезла в сгенерированном коде, и указывает она внутрь раскрытия.
Покрути стенд: тот же макрос-конструктор словаря с двумя правилами. Меняй вызов и следи, какое правило подбирается, какие метапеременные захватываются и во что всё разворачивается. Обрати внимание, как повторение $(...)+ растягивает тело ровно по числу пойманных пар.
Токены, а не текст
Теперь важнейшая мысль урока, отделяющая макросы Rust от препроцессора C. Макрос работает не со строкой символов, а с потоком токенов: код уже разбит лексером на идентификаторы, литералы, знаки и скобочные группы. Из этого следует всё остальное.
Во-первых, раскрытие не может «протечь» в окружающий код. square!(3 + 1) встраивается как целое выражение, а не как голый текст 3 + 1, поэтому приоритет операций не ломается. Во-вторых, фрагмент, пойманный как :expr, обязан быть синтаксически корректным выражением уже в момент захвата, мусор макрос не примет. В-третьих, результат раскрытия обязан быть валидным деревом токенов, которое встанет на своё место (выражение туда, где ждут выражение; элемент туда, где ждут элемент). А вот проверку типов раскрытие проходит уже потом, наравне с обычным кодом. Это и значит, что макрос не текстовая подстановка, а структурное преобразование синтаксиса.
Разбор: во что разворачивается println!
Закрепим на самом знакомом. println!("{} + {} = {}", a, b, a + b) это макрос, и разворачивается он (упрощённо) примерно так:
// то, что ты пишешь
println!("{} + {} = {}", a, b, a + b);
// во что это разворачивается (по сути)
std::io::_print(std::format_args!("{} + {} = {}", a, b, a + b));
Вся интересная работа спрятана в format_args!, тоже макросе. Именно он на этапе компиляции разбирает строку формата, считает количество {}, сверяет его с числом аргументов и собирает структуру Arguments без единой аллокации в куче. Поэтому несовпадение числа дырок и аргументов это ошибка компиляции, а не паника в рантайме: строку формата проанализировал макрос ещё до запуска. И поэтому же println! видит исходные выражения a, b, a + b, а не только их значения. Функция так не умеет, а макрос умеет, потому что получает код как токены. Когда в следующий раз увидишь error: 1 positional argument in format string, but no arguments were given, ты знаешь, кто и когда это проверил.
Правило урока
Свернём механизм в одну мысль.
Декларативный макрос это набор правил «образец => раскрытие», которые компилятор пробует сверху вниз и подставляет первое подошедшее, ещё до проверки типов. Метапеременные вида
$x:exprловят фрагменты кода по спецификатору (expr,ident,ty,ttи другие), причём всё, кромеttиident, дальше непрозрачно. Повторение$( ... )sep*сопоставляет и разворачивает образец много раз, давая переменную арность. Главное: макрос работает с токенами, а не с текстом, поэтому раскрытие не ломает приоритет операций и встраивается как цельная синтаксическая единица. Бери макрос, когда обобщить надо не значение, а синтаксис: переменное число аргументов, генерация объявлений, доступ к исходному тексту аргумента.
Типичная ошибка, которую снимает урок: написать макрос как текстовую замену и ждать #define-поведения. На деле захваченный :expr уже цельный, его не разорвать оператором снаружи и не разобрать образцом изнутри; а ошибка после раскрытия указывает внутрь сгенерированного кода, потому что типы проверяются после подбора правила, а не во время него. Читая такое сообщение, мысленно разверни макрос (а в следующем уроке научишься делать это буквально, через cargo expand) и смотри на настоящий код, а не на вызов.
ДЗ
Дальше
Ты понял механику: правила, фрагменты, повторения, токены вместо текста, и увидел, что println! это не магия, а разобранный на этапе компиляции макрос. Но мы обошли стороной два вопроса, без которых макросы в реальном коде писать опасно. Первый: почему переменная m, которую мой макрос завёл внутри раскрытия, не сталкивается с переменной m в коде вызова, и как этим управлять, когда столкновение всё-таки нужно. Второй: как макрос может вызвать сам себя, обрабатывая аргументы по одному, и как всё это отлаживать, когда раскрытие стало нечитаемым. В следующем уроке разберём гигиену макросов, рекурсивные приёмы вроде TT muncher и инструмент cargo expand, который показывает настоящее раскрытие твоими глазами.