CNN Türkİngiltere Hakem Kurulu, hatasını kabul etti: Haaland'ın golü sayılmamalıydıInquirerNTF-Elcac vows infra support for former conflict areasPunchMorning recap: INEC unveils list of Senate race, banks shut 476 branches in three years, other top storiesRTP DesportoAutogolo infeliz aos 90 dita empate do Sporting em FamalicãoESPNBlue Jays go wild as Vlad Jr. slugs first HR in Toronto this yearDaily MaverickWELLNESS: Creatine: What works, what might work, and what is still unprovenוואלהיו"ר סיעת ישראל ביתנו הגיש בקשה לפסילת מועמדותו של עופר כסיף לכנסת ה-26ESPN DeportesClaves del triunfo de Real Madrid vs Rayo Vallecano en LaLiga한겨레[뉴스 다이브] 용혜인 ‘사퇴’·김승원 ‘엄호’GhaflaNuru Okanga Celebrates Massive Turnout at Linda Mwananchi RallySözcü2 il arasındaki yol 2,5 saate inecekSky TG24Oroscopo del giorno, le previsioni del 14 settembre segno per segno
The Daily Newsstand · Free, Always
Monday, September 14, 2026

Декларативные макросы в Rust: полное руководство

Translate

Макросы - это механизм генерации кода на этапе компиляции. В отличие от макросов в C, где препроцессор работает на уровне текста, макросы Rust работают с токенами языка, а не с текстом программы. Процедурные макросы получают TokenStream и возвращают новый TokenStream, а декларативные сопоставляют поток токенов с заданными шаблонами. После раскрытия макросов полученный код компилируется так же, как если бы его написал программист вручную.

Макросы позволяют решать задачи, которые невозможно или крайне неудобно выразить средствами языка без генерации кода:

  • переменное число аргументов (println!, vec!)

  • генерация шаблонного кода (#[derive(Debug)])

  • встраивание DSL (query! из sqlx или html! из yew)

  • доступ к информации об исходном коде (stringify!, file!, line!)

В rust макросы делятся на два типа : декларативные (macro_rules!) и процедурные (derive, атрибутные и функциональные). Они отличаются возможностями, ограничениями и влиянием на время компиляции. У каждого из них есть свои ограничения и подводные камни.

Классификация макросов

Прежде чем нырять в детали, обозначим чуть конкретнее типы макросов:

  • Декларативные (macro_rules!) - описываются в коде с помощью правил сопоставления с образцом. Их можно представить как match, который сопоставляет не значения во время выполнения, а синтаксис во время компиляции

  • Процедурные - это обычные функции, которые принимают TokenStream и возвращают TokenStream. Живут в отдельном специальном крейте. Делятся на три вида:

    • Derive-макросы - #[derive(MyTrait)] над структурой или enum. Получают определение типа и генерируют дополнительный код (обычно impl), не изменяя сам тип

    • Атрибутные - #[my_attribute] над функцией, типом или модулем. Получают помеченный элемент целиком и возвращают произвольный набор элементов

    • Функциональные - вызываются как my_macro!(...), синтаксически не отличаются от декларативных, но внутри могут выполнять гораздо более сложную обработку входных токенов

Ключевое различие в возможностях и сложности. macro_rules! проще написать, он не требует отдельного крейта и компилируется быстрее, но ограничен правилами сопоставления токенов. Процедурный макрос может выполнять произвольную логику во время компиляции, однако требует отдельного крейта, обычно использует syn и quote, что увеличивает время сборки.

В этой статье мы разберём работу декларативных макросов, а в следующей - процедурных.

Синтаксис

macro_rules! - это макрос, который определяет другой макрос. Общая форма:

macro_rules! имя {
    (образец_1) => { шаблон_1 };
    (образец_2) => { шаблон_2 };
    // ...
}

Синтаксис напоминает match: набор правил вида образец => шаблон для раскрытия, разделённых через ; и проверяемых сверху вниз до первого совпадения. Основное отличие с match заключается в том, что сопоставляется не значение, а синтаксис.

Самый простой макрос, который ничего не принимает:

// Определение макроса
macro_rules! hello_world {
	// Образец макроса - пустой (не принимает аргументов)
    () => {
	    // Шаблон генерации
        println!("Hello world!");
    };
}

Вызов макроса всегда записывается через !, что позволяет отличить его от вызова функции. После ! можно использовать любые парные скобки:

hello_world!();
hello_world![];
hello_world!{};

Для macro_rules! они эквивалентны. Обычно () используют для макросов похожих на функции, [] - для списков элементов, а {} - когда внутри находится большой блок кода.

Метапеременные

Метапеременная - это именованный захват части кода, переданного в макрос. Можете считать их аналогом параметров функций, только вместо значений она принимает фрагменты кода.

Метапеременная обозначается как $имя:фрагмент. Например, в $x:expr:

  • $x - метапеременная, в которую будет сохранён переданный фрагмент кода

  • expr - спецификатор фрагмента, который говорит, что именно может быть захвачено

macro_rules! square {
    ($x:expr) => {
        $x * $x
    };
}

let result = square!(2 + 3);   // макросы захватывает всё выражение целиком
println!("{}", result);        // 25

При подстановке rust сохраняет структуру захваченного выражения, поэтому square!(2 + 3) раскрывается эквивалентно (2 + 3) (2 + 3), а не 2 + 3 2 + 3 как это произошло бы при текстовой подстановке в C.

Метапеременные используются не только для выражений. Например, с помощью ident можно захватить имя и использовать его для создания функции:

macro_rules! create_function {
	// $name захватывает имя функции
    ($name:ident) => {
	    // Используем захваченное имя при объявлении функции
        fn $name() {
	        // stringify! превращает переданный идентификатор в строку
            println!("Вызвана функция {}()", stringify!($name));
        }
    };
}

create_function!(foo);
create_function!(bar);

foo(); // Вызвана функция foo()
bar(); // Вызвана функция bar()

То же самое работает с локальными переменными:

macro_rules! make_var {
    ($var:ident) => {
	    // Объявляем переменную
        let $var = 10;
    };
}

make_var!(var);
dbg!(var); // 10

Обратите внимание, что имя var пришло в макрос извне. Поэтому это не то же самое, что идентификатор, созданный внутри макроса. К этому чуть подробнее вернёмся в главе о гигиене.

Фрагменты-спецификаторы

Спецификатор фрагмента - это указание, какой именно фрагмент кода может захватить метапеременная.

Спецификатор

Что матчит

Пример

expr

выражение

1 + 2, f(x), if x { 1 }

ident

идентификатор

foo, Bar, x, fn, match, self

ty

тип

i32, Vec<u8>, impl Iterator

pat

паттерн

Some(x), (a, b), _

stmt

инструкция

let x = 5;, x += 2;

block

блок кода в фигурных скобках

{ foo(); bar() }
{ let x = 5; x + 1 }

item

элемент

fn foo() {}, struct Bar;, use std::io;, mod foo {}

path

путь

std::collections::HashMap
crate::module::Type

literal

литерал

42, "hello", 3.14, true

lifetime

время жизни

'a, 'static

vis

модификатор видимости

pub, pub(crate)

meta

содержимое атрибута

derive(Debug), inline, cfg(target_os = "windows")

pat

паттерн

Some(x), (a, b), 1 \| 2

pat_param

паттерн без верхнеуровневого \|

Some(x), (a, b)

tt

одно токен-дерево

отдельное токен-дерево: либо один токен, либо группа токенов, заключённая в любые парные скобки

Примечание:

  • Спецификаторы работают на уровне синтаксиса: они определяют форму фрагмента, но не проверяют его типы

  • vis единственный спецификатор, который может сопоставиться с пустым фрагментом

  • ident способен захватывать не только обычные идентификаторы, но и ключевые слова (fn, match, self и т. д.)

  • tt наиболее общий спецификатор. Он не требует от макроса заранее знать, является ли фрагмент выражением, типом, паттерном и т.д. Благодаря этому tt полезен при создании рекурсивных макросов, простых парсеров и DSL

  • Ещё есть expr_2021 - это вариант expr, сохраняющий правила сопоставления выражений Rust 2021. Он предназначен для совместимости макросов между edition

Повторения

Одна из ключевых возможностей macro_rules! - это повторять части шаблона. Синтаксис повторений:

  • $( ... )* - повторять ноль или более раз

  • $( ... )+ - повторять один или более раз

  • $( ... )? - ноль или один раз (без разделителя)

Синтаксис и + может использовать разделитель. Например $($x:expr), означает ноль или более выражений, разделённых запятыми (вместо , можно подставить любой другой знак, например ;). Разница между * и + заключается в том, допускается ли пустая последовательность:

macro_rules! one_or_more {
	// Требуется хотя бы одно выражение
    ($($x:expr),+) => {};
}
one_or_more!();         // ошибка
one_or_more!(1);        // ок
one_or_more!(1, 2, 3);  // ок
one_or_more!(1, 2, 3,); // ошибка

macro_rules! zero_or_more {
	// Последовательность может быть пустой
    ($($x:expr);*) => {};
}
zero_or_more!();         // ok
zero_or_more!(1);        // ok
zero_or_more!(1; 2; 3);  // ок
zero_or_more!(1; 2; 3;); // ошибка

В первом макросе в качестве разделителя используется ,, а во втором - ;. Это сделано только для демонстрации, что в качестве разделителя можно использовать любые другие символы.

? используется, когда часть шаблона может встретиться ноль или один раз. Например, его удобно использовать для разрешения необязательной последней запятой:

macro_rules! list {
    // После списка элементов запятая может присутствовать, а может отсутствовать
    ($($x:expr),* $(,)?) => {};
}

list!();         // ok
list!(1);        // ок
list!(1, 2, 3);  // ок
list!(1, 2, 3,); // ок

В отличие от * и +, повторение с ? не может иметь разделитель, поскольку он допускает только одно повторение. То есть образец вида $($x:expr),? не корректен.

Рассмотрим простой макрос, который превращает список выражений в цепочку сложений:

macro_rules! sum {
	// Захватываем ноль или более выражений, разделённых запятыми
    ($($x:expr),*) => {
	    // Для каждого захваченного $x добавляется "+ $x"
        0 $(+ $x)*
    };
}

let s = sum!(1, 2, 3, 4); // Раскрывается в 0 + 1 + 2 + 3 + 4 
let empty = sum!();       // Раскрывается в 0

В образце $($x:expr),* конструкция $(...) определяет повторяемую часть, $x:expr захватывает отдельное выражение, а , задаёт разделитель. В шаблоне 0 $(+ $x)* часть внутри $( ) повторяется по разу на каждое захваченное $x, так что sum!(1, 2, 3, 4) раскрывается в 0 + 1 + 2 + 3 + 4. Приём с 0 делает пустой вызов sum!() корректным, он задаёт начальное значение для операции сложения.

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

use std::any::type_name_of_val; 

macro_rules! define_functions {
	// захватываем имя функции и её тип
    ($($name:ident: $ty:ty),* $(,)?) => {
	    // Генерируем отдельную функцию для каждой пары
        $(
            fn $name(val: impl Into<$ty>) -> $ty {
                val.into()
            }
        )*
    };
}

define_functions!(get_i32: i32, get_u8: u8);

let r1 = get_i32(12);
let r2 = get_u8(10);

println!("{}: {}", r1, type_name_of_val(&r1)); // 12: i32
println!("{}: {}", r2, type_name_of_val(&r2)); // 10: u8

Вызов define_functions!(get_i32: i32, get_u8: u8); приведёт к генерации такого кода::

fn get_i32(val: impl Into<i32>) -> i32 {
    val.into()
}

fn get_u8(val: impl Into<u8>) -> u8 {
    val.into()
}

То есть повторение позволяет не просто получить несколько значений, а создать несколько элементов rust-кода.

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

Например, у нас есть несколько типов, которые соответствуют вариантам одного enum:

struct Function;
struct Const;
struct TypeAlias;

enum TraitItem {
    Function(Function),
    Const(Const),
    TypeAlias(TypeAlias),
}

Для каждого типа мы хотим реализовать From, чтобы автоматически преобразовывать его в соответствующий вариант TraitItem:

impl From<Function> for TraitItem {
    fn from(v: Function) -> Self {
        TraitItem::Function(v)
}

impl From<Const> for TraitItem {
    fn from(v: Const) -> Self {
        TraitItem::Const(v)
}

impl From<TypeAlias> for TraitItem {
    fn from(v: TypeAlias) -> Self {
        TraitItem::TypeAlias(v)
}

Все эти реализации отличаются только именем типа и варианта enum. Поэтому вместо ручного написания каждой реализации можно сгенерировать шаблонный код через макрос:

macro_rules! impl_froms {
	// $e — имя enum, $v — список его вариантов.
    ($e:ident: $($v:ident),*) => {
        $(
            impl From<$v> for $e {
                fn from(it: $v) -> $e {
                    $e::$v(it)
                }
            }
        )*
    };
}

impl_froms!(TraitItem: Function, Const, TypeAlias);

Такой подход часто встречается в исходниках std для генерации однотипных реализаций трейтов. Например, трейты для числовых типов по типу Add, Sub, Mul и т.д. генерируются через подобные макросы.

Несколько правил и перегрузка

Как и у match, macro_rules! может иметь несколько правил, чтобы обработать разное число или форму аргументов:

macro_rules! hello {
	// Вызов без аргументов
    () => {
        println!("Привет, мир!");
    };
	// Вызов с одним выражением
    ($name:expr) => {
        println!("Привет, {}!", $name);
    };
}

hello!();      // Привет, мир!
hello!("Аня"); // Привет, Аня!

При вызове макроса компилятор последовательно пытается сопоставить вход с правилами сверху вниз. Как только находится подходящее правило, остальные уже не рассматриваются.

Например:

macro_rules! example {
	// Одно выражение
    ($x:expr) => {
        println!("одно выражение");
    };
    // Два выражения через запятую
    ($x:expr, $y:expr) => {
        println!("два выражения");
    };
    // Два выражения через точку с запятой
    ($x:expr; $y:expr) => {
        println!("два выражения");
    };
}

example!(1);    // первое правило
example!(1, 2); // второе правило
example!(1; 2); // третье правило

Такое поведение часто называют перегрузкой макроса, но с перегрузкой функций оно имеет мало общего. Ветка выбирается до проверок типов и опирается только на синтаксис: количество аргументов и их порядкок, разделители, скобки, используемые фрагментны спецификаторов. Перегрузки по типу в macro_rules! отсутствует.

Например:

macro_rules! show {
    ($value:expr) => {
        println!("{}", $value);
    };
}

И show!(42), и show!("hello") подходят под одно и то же правило expr. На момент сопоставления макроса компилятор ещё не выбирает ветку на основании типов. Поэтому перегрузить macro_rules! по типу невозможно:

show!(42);    
show!("hello");

Если нужно различать такие случаи, различие должно быть выражено непосредственно в синтаксисе вызова:

macro_rules! show {
    (number $value:expr) => {
        println!("number: {}", $value);
    };
    (text $value:expr) => {
        println!("text: {}", $value);
    };
}

show!(number 42);
show!(text "hello");

Здесь правила уже можно различить без знания типов: первое начинается с токена number, второе - с text. Таким образом, macro_rules! позволяет делать синтаксическую перегрузку, но не перегрузку по типам. Это специальный синтаксис макросов, о нём поговорим чуть ниже.

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

macro_rules! oops {
	// Подходит для любого количества токенов, включая пустой ввод
    ($($t:tt)*) => { "что угодно" };
    // Мёртвая ветка, любой вариант уже подошёл под первое правило
    ()          => { "пусто" };  
} 

$($t:tt)* способен сопоставиться с любым количеством токенов, в том числе с нулём. Поэтому первый matcher подходит и для oops!(), и для oops!(1, 2, 3).

В отличие от match, macro_rules! не проверяет правила на исчерпываемость и не предупреждает о перекрывающихся или недостижимых ветках. За их порядком и корректностью должен следить автор макроса. Иногда пересечение правил менее очевидно.

Например:

macro_rules! print_value {
    ($value:literal) => {
        println!("literal");
    };

    ($value:expr) => {
        println!("expression");
    };
}

Вызов print_value!(42); подходит сразу под оба правила: 42 является и literal, и expr. Но сработает первое правило, потому что оно расположено выше.

Также образец макроса может содержать обычные токены. Например:

macro_rules! entry {
    ($key:expr => $value:expr) => {
        ($key, $value)
    };
}

let pair = entry!("name" => "Аня");

Токен => здесь не имеет какого-то специального значения для macro_rules!. Макрос просто требует, чтобы между двумя выражениями находился именно этот токен.

То же самое можно сделать с практически любым удобным синтаксисом:

macro_rules! command {
    (run $name:ident) => {
	    ...
    };
}

command!(run server);

Такой подход позволяет создавать синтаксис, которого нет в обычном rust. В простых случаях это всего несколько правил, а в более сложных macro_rules! может превратиться в небольшой специализированный язык - DSL (Domain-Specific Language).

Таким образом, несколько правил позволяют одному макросу обрабатывать разные формы входного синтаксиса. Сопоставление происходит сверху вниз и основывается только на токенах и фрагментах спецефикаторах: типы и значения аргументов при выборе правила не участвуют.

Теперь объединим несколько рассмотренных возможностей и напишем упрощённую версию vec!:

macro_rules! vec {
	 // Пустой вызов vec![]
    [] => {
        Vec::new()
    };
    // Один и более элементов через запятую
    // Последняя запятая необязательна
    [$($item:expr),+ $(,)?] => {{
	    // Создаём пустой вектор
        let mut v = Vec::new();
        // Для каждого захваченного элемента выполняем вызов push
        $( v.push($item); )+
        // Возвращаем готовый ветор
        v
    }}
}

let v = vec![1, 2, 3]; 
let empty = vec![]; 

println!("{:?}", v);     // [1, 2, 3] 
println!("{:?}", empty); // []

Примечания:

  • $($x:expr),+ означает одно или более выражений, разделённых запятыми. В шаблоне раскрытия $( v.push($x); )+ повторяется столько раз, сколько выражений было захвачено

  • $(,)? нужен для поддержки висячей запятой. Без неё vec![1, 2, 3,] не скомпилируется

  • двойные фигурные скобки в => {{ ... }} нужны потому, что внешние фигурные скобки относятся к синтаксису самого макроса, а внутренние образуют блок-выражение. Благодаря этому всё раскрытие является единым выражением со своей областью видимости

  • напоминаю, что скобки при вызове (vec!(), vec![], vec!{}) взаимозаменяемы, но для списков элементов принято использовать [] скобки

  • это лишь учебная демонстрация, макрос vec! из std реализован намного сложнее

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

macro_rules! matrix {
    // Внешний * повторяет строки
    // Внутренний * обозначает элементы внутри каждой строки
    ($([$($x:expr),*]),* $(,)?) => {
        vec![
            $(
                vec![$($x),*]
            ),*
        ]
    };
}

let m = matrix![
    [1, 2, 3],
    [4, 5, 6],
];

Внешнее повторение проходит по строкам [1, 2, 3] и [4, 5, 6], а внутреннее по элементам каждой строки. В результате генерируется примерно такой код:

vec![
    vec![1, 2, 3],
    vec![4, 5, 6],
]

Гигиена

Гигиена макросов - это механизм, который не позволяет именам, созданным внутри макроса, случайно конфликтовать с именами в месте его вызова. Без гигиены macro_rules! мог бы работать почти как текстовая подстановка: макрос вставлял бы свой код непосредственно в место вызова и локальная переменная, объявленная внутри макроса, могла бы случайно перехватить переменную из окружающего кода. Rust этого не допускает - идентификаторы, созданные внутри макроса, сохраняют информацию о контексте, в котором они были определены. То есть локальные переменные и метки разрешаются в контексте определения макроса, а большинство других имён в контексте места вызова.

macro_rules! using_a {
    ($e:expr) => {{
	    // Локальная для макроса переменная
        let a = 42;
        // Без двойных {{}} не получится вернуть переменную
        $e
    }};
}

let a = 10;
let result = using_a!(a + 1); // 11, а не 43

Переменная a, объявленная внутри макроса, это другая a. Идентификатор из входного выражения в $e не начинает ссылаться на неё только потому, что внутри раскрытия появилось имя a. И наоборот, макрос не может случайно перехватить переменную вызывающего кода.

Если макрос создаёт локальную переменную, вызывающий код не может обратиться к ней по имени:

macro_rules! make_x {
    () => { let x = 5; };
}

make_x!();
println!("{x}"); // ошибка

Идентификатор x, созданный макросом, сохраняет контекст макроса. Поэтому после раскрытия нельзя обратиться к нему как к переменной из места вызова.

Если такое поведение действительно нужно, имя должно прийти снаружи как параметр:

macro_rules! create_value { 
	($name:ident, $value:expr) => { 
		let $name = $value; 
	}; 
}

create_value!(answer, 42); 
println!("{answer}");

Здесь имя пришло в макрос извне. Поэтому $name наследует контекст места вызова и созданная переменная становится доступна вызывающему коду. Созданный макросом идентификатор и идентификатор, переданный макросу, ведут себя по-разному.

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

1) Функция или структура, созданная внутри макроса, не становится локальной для места вызова

macro_rules! def {
    () => {
        // Макрос не создаёт для функции локальную область видимости
        fn helper() {}
    };
}

def!();
helper(); // функция доступна после раскрытия макроса
def!();   // ошибка: повторное определение функции helper

2) use внутри макроса может создавать конфликты или экспортировать имена в текущий namespace

// Так макрос способен внести целую пачку имён в пространство имён вызывающего кода
macro_rules! setup {
    () => {
        use foo::*;
    };
}

// Конфликт с уже существующим hashmap 
macro_rules! make {
    () => {
        use std::collections::HashMap;
    };
}
use some_other_crate::HashMap;
make!();

3) Могут возникнуть проблемы с тем, как ведут себя имена функций, которые используются внутри макроса:

// log_it.rs
fn helper(x: i32) -> i32 { x * 2 }

macro_rules! log_it {
    ($x:expr) => {
        println!("{}", helper($x));
    };
}

// lib.rs 
fn helper(x: i32) -> String { format!("{x}") } 
log_it!(21);

В результате будет взята реализация helper из места вызова макроса, а не из места объявления макроса. Об решении этой проблемы поговорим в следующей главе.

$crate и экспорт

При раскрытии macro_rules! большинство имён разрешается в контексте места вызова. Поэтому библиотечный макрос, использующий обычный crate::, может обратиться не к библиотеке, в которой он определён, а к crate пользователя.

macro_rules! log_value {
    ($value:expr) => {
        crate::logging::log(&$value)
    };
}

Если log_value! вызывается из другого крейта, crate будет относиться к крейту пользователя, а не к библиотеке, в которой определён макрос.

Для обращения к crate, в котором определён макрос, используется специальный $crate:

macro_rules! log_value {
    ($value:expr) => {
        $crate::logging::log(&$value)
    };
}

Теперь $crate всегда ссылается на crate, где находится определение макроса.

Путь

На что указывает

crate::foo

крейт, где происходит раскрытие

$crate::foo

крейт, где определён макрос

Чтобы сделать macro_rules! доступным из другого crate, используется #[macro_export]:

mod macros {
    #[macro_export]
    macro_rules! hello {
        () => {
            println!("Hello!");
        };
    }
}

его можно импортировать снаружи как use my_lib::hello;.

У macro_rules! есть собственные правила области видимости. Локально объявленный макрос становится доступен после точки определения:

hello!(); // ошибка

macro_rules! hello {
    () => {
        println!("Hello!");
    };
}

hello!(); // ок

Это отличается от обычных элементов Rust, поэтому при работе с локальными макросами важно учитывать порядок их определения.

$crate решает только проблему выбора правильного crate, он не изменяет правила видимости Rust. Например:

mod logging {
    pub fn log(value: &impl std::fmt::Debug) {
        println!("{value:?}");
    }
}

#[macro_export]
macro_rules! log_value {
    ($value:expr) => {
        $crate::logging::log(&$value)
    };
}

При вызове из другого crate путь $crate::logging::log правильно указывает на библиотеку, но модуль logging сам является приватным. Поэтому такой макрос не сможет обратиться к нему извне. Для технических деталей реализации библиотечного макроса часто создают публичный, но скрытый из документации модуль:

#[doc(hidden)]
pub mod __private {
    pub fn log(value: &impl std::fmt::Debug) {
        println!("{value:?}");
    }
}

#[macro_export]
macro_rules! log_value {
    ($value:expr) => {
        $crate::__private::log(&$value)
    };
}

Здесь:

  • pub делает модуль и функцию доступными из другого crate

  • $crate гарантирует обращение именно к библиотеке

  • #[doc(hidden)] скрывает технический API из обычной документации (поговорим об этом чуть позже)

Та же проблема может возникнуть с внешними зависимостями. Например:

macro_rules! make_vec {
    () => {
        Vec::new() // может взяться реализация из внешнего модуля 
    };
}

В библиотечном макросе лучше явно указывать абсолютный путь к стандартной библиотеке:

macro_rules! make_vec {
    () => {
        ::std::vec::Vec::new()
    };
}

Начальный :: означает поиск от корня пространства имён. Это уменьшает зависимость раскрытого кода от локального окружения вызывающего крейта.

На практике для библиотечных макросов полезно придерживаться простого правила:

$crate::...  // собственный crate
::std::...   // стандартная библиотека
::core::...  // core

Так макрос меньше зависит от того, какие модули и имена существуют в месте его вызова.

Продвинутая техника: TT-мунчеры и рекурсия

macro_rules! не выполняет произвольный код во время раскрытия. Он сопоставляет входные токены с правилами и генерирует новые токены. При этом макрос может вызывать сам себя, что позволяет реализовывать простые парсеры и обрабатывать вход последовательно.

macro_rules! outer {
    ($e:expr) => {
        inner!($e)
    };
}
macro_rules! inner {
    ($a:tt + $b:tt) => {};
}
outer!(1 + 2);

Здесь inner! не сможет разобрать переданный $e как $a:tt + $b:tt, для него это уже готовый фрагмент expr. Исключение tt, ident и lifetime, которые сохраняют возможность сопоставления с токенами соответствующего типа. Поэтому в многоступенчатых макросах часто передают ещё не разобранный вход как tt, а более конкретные фрагменты (expr, ty, pat и т.д.) сопоставляют тогда, когда соответствующая часть синтаксиса уже определена. На этом принципе построен TT-мунчер - макрос последовательно разбирает вход, обрабатывает его начало и вызывает себя с оставшимися токенами.

Например, подсчёт количества переданных элементов:

macro_rules! count {
    () => { 0usize };
    ($head:tt $($tail:tt)*) => {
        1usize + count!($($tail)*)
    };
}

const N: usize = count!(a b c d); // 4

Здесь $head:tt забирает одно токен-дерево, а $($tail:tt)* - оставшуюся последовательность. Для входа a b c d раскрытие происходит следующим образом:

count!(a b c d)
    ↓
1 + count!(b c d)
    ↓
1 + 1 + count!(c d)
    ↓
1 + 1 + 1 + count!(d)
    ↓
1 + 1 + 1 + 1 + count!()
    ↓
1 + 1 + 1 + 1 + 0
    ↓
4

Откусывать можно не только по одному токену, но и целую конструкцию:

macro_rules! commands {
    () => {};
    (print $value:expr; $($rest:tt)*) => {
        println!("{}", $value);
        commands!($($rest)*);
    };
}

// Один шаг обрабатывает конструкцию `print $value;` целиком
// а остаток `$rest` уходит в следующий вызов
commands! {
    print "Hello";
    print "World";
}

Каждый рекурсивный вызов получает только необработанный остаток входа.

Более сложные мунчеры обычно используют дополнительные правила, которые представляют состояния разбора:

macro_rules! parser {
    (@parse ...) => {
        // ...
    };

    ($($input:tt)*) => {
        parser!(@parse $($input)*)
    };
}

@parse не имеет для macro_rules! специального значения. Это обычный токен, который автор использует как маркер состояния. Символ @ удобен тем, что с него не может начинаться осмысленный пользовательский ввод, так что перепутать внутренний вызов с внешним не выйдет.

Порядок правил здесь важен: правила проверяются сверху вниз и выбирается первое подходящее. Поэтому общее правило, принимающее произвольный tt, обычно ставят последним. Иначе оно может перехватить внутренний вызов parser!(@parse ...) и отправить его обратно в то же правило.

У рекурсивного раскрытия есть особенность, что макрос раскрывается снаружи внутрь и вложенный вызов ещё не раскрыт в момент, когда генерируется внешний код. Поэтому собрать результат по кусочкам напрямую не выйдет, результат передают через дополнительный параметр-накопитель, а выдают целиком в базовом правиле. Например, макрос, разворачивающий последовательность задом наперёд:

macro_rules! reverse {
    // Вход закончился, выдаём накопитель
    (@acc [$($acc:tt)*]) => {
        [$($acc),*]
    };

    // Переносим один токен из входа в начало накопителя
    (@acc [$($acc:tt)*] $head:tt $($tail:tt)*) => {
        reverse!(@acc [$head $($acc)*] $($tail)*)
    };

    // Запускаем рекурсию с пустым накопителем 
    ($($input:tt)*) => {
        reverse!(@acc [] $($input)*)
    };
}

let arr = reverse!(1 2 3); // [3, 2, 1]

На каждом шаге $head переносится в начало накопителя. Когда вход заканчивается, содержимое накопителя превращается в итоговый код. Накопитель здесь обычная группа токенов, макрос может изменять её содержимое при каждом рекурсивном вызове.

У рекурсивного раскрытия есть практическое ограничение. rustc ограничивает глубину рекурсивного раскрытия макросов, по умолчанию используется значение 128. При необходимости лимит можно увеличить на уровне crate #![recursion_limit = "256"]. Слишком глубокая рекурсия может быть признаком того, что макрос обрабатывает слишком большой объём данных или его стоит реализовать другим способом.

Если macro_rules! начинает превращаться в полноценный парсер с десятками состояний и сложными правилами, то это хороший сигнал посмотреть в сторону процедурного макроса. Там входной TokenStream разбирается обычным кодом, для синтаксического анализа есть syn, а ошибки можно выдавать с точной привязкой к месту в коде пользователя.

Отладка макросов

Отладка macro_rules! отличается от отладки обычного кода. Проблема обычно не в сгенерированном коде, а в том, что макрос раскрылся не так, как ожидалось: сработало не то правило, фрагмент захватился не целиком, рекурсия ушла не туда. Поэтому нужно уметь смотреть на результат раскрытия.

Один из самых удобных инструментов - cargo expand. Это отдельный проект, для установки нужно выполнить cargo install cargo-expand. Он выводит каким будет код после раскрытия макросов. Например для:

let v = vec![1, 2, 3];
println!("{v:?}")

cargo expand выведет:

let v = ::alloc::boxed::box_assume_init_into_vec_unsafe(
    ::alloc::intrinsics::write_box_via_move(
        ::alloc::boxed::Box::new_uninit(),
        [1, 2, 3],
    ),
);
{
    ::std::io::_print(format_args!("{0:?}\n", v));
}

Точный вид зависит от версии компилятора - внутренности vec! и println! менялись не раз, поэтому у вас вывод может немного отличаться.

По умолчанию cargo expand разворачивает цель целиком, что на большом файле неудобно. Можно ограничить вывод путём к модулю или элементу, а также выбрать конкретную цель:

cargo expand path::to::module   # только один модуль
cargo expand --lib              # библиотечная цель
cargo expand --bin my_app       # конкретный бинарник
cargo expand --test my_test     # тест

Результат раскрытия не всегда валидный код, он может содержать внутренние конструкции компилятора и обычно не компилируется, если скопировать его в проект. Кроме того, если код не проходит стадию раскрытия (например, макрос вообще не сопоставился), cargo expand покажет ту же ошибку, что и cargo build, а не частично развёрнутый код.

Когда интересен не результат, а сама последовательность раскрытий, например, при отладке рекурсивного макроса, то помогает trace_macros!:

#![feature(trace_macros)] // nightly-функция

macro_rules! hello {
    ($name:expr) => {
        println!("Hello, {}!", $name);
    };
}

trace_macros!(true);
hello!("Rust");
trace_macros!(false);

Компилятор выведет каждый шаг раскрытия:

note: trace_macro
  --> src/main.rs:11:5
   |
11 |     hello!("Rust");
   |     ^^^^^^^^^^^^^^
   |
   = note: expanding `hello! { "Rust" }`
   = note: to `println! ("Hello, {}!", "Rust");`
   = note: expanding `println! { "Hello, {}!", "Rust" }`
   = note: to `{ $crate :: io :: _print($crate :: format_args_nl! ("Hello, {}!", "Rust")); }`

trace_macros!(true) включает трассировку, trace_macros!(false) выключает. Так можно ограничить вывод одним интересующим вызовом. На TT-мунчере это особенно полезно, ведь сразу видно на каком шаге разбор пошёл не туда.

Также есть log_syntax! (тоже nightly). Он печатает переданные ему токены во время компиляции, что позволяет заглянуть внутрь макроса и посмотреть, что реально захватилось:

#![feature(log_syntax)]

macro_rules! debug_input {
    ($($tt:tt)*) => {
        log_syntax!("получено:", $($tt)*);
    };
}

Документация

Для документации макросов используются обычные документационные комментарии:

/// Создаёт `HashMap` из пар ключ-значение
macro_rules! map {
    // ...
}

То же самое можно записать через #[doc]:

#[doc = "Создаёт `HashMap` из пар ключ-значение"]
macro_rules! map {
    // ...
}

Примеры в документации можно использовать как doctest:

/// Создаёт `HashMap` из пар ключ-значение.
///
/// ```
/// # use crate::map;
/// let ages = map! {
///     "Аня" => 29,
///     "Борис" => 34,
/// };
///
/// assert_eq!(ages["Аня"], 29);
/// ```
macro_rules! map {
    // ...
}

При выполнении cargo test такой пример компилируется и запускается как тест. Поэтому документация одновременно служит примером использования макроса и проверкой его api.

Для внутренних элементов, к которым обращается макрос, используется #[doc(hidden)]:

#[doc(hidden)]
pub mod __private {
    pub fn helper() {}
}

Важно, что #[doc(hidden)] не делает элемент приватным. Он только скрывает его из сгенерированной документации.

Ограничения macro_rules!

macro_rules! мощный инструмент, но у него есть ряд ограничений. Главное из них заключается в том, что макрос сопоставляет токены и синтаксические фрагменты, но не получает произвольного доступа к AST и не выполняет семантический анализ.

Из этого следуют основные ограничения:

  • нельзя выполнять произвольную логику во время раскрытия макроса

  • нельзя напрямую получить информацию о типах выражений и результатах type inference

  • сложный синтаксический разбор приходится реализовывать вручную с помощью правил и рекурсии

  • сложные макросы быстро становятся трудными для чтения и отладки

  • существуют ограничения гигиены и разрешения имён, особенно при работе с элементами, импортами и модулями

Например, macro_rules! не может выбрать реализацию в зависимости от типа аргумента:

macro_rules! process {
    ($value:expr) => {
        // Тип $value здесь неизвестен макросу
    };
}

Макрос видит выражение как набор токенов, но не знает, является ли переданное значение i32, String или пользовательским типом.

Влияние на время компиляции. Само по себе раскрытие декларативных макросов дешевое: не нужен отдельный крейт, не нужны syn и quote, нет отдельной стадии сборки. В этом главное преимущество перед процедурными, но дорого обходится не раскрытие, а его результат: макрос, размноженный на сотню типов, порождает сотню наборов элементов и каждый из них компилятор разбирает, проверяет типы и оптимизирует как обычный код. Генерация десяти тысяч строк из десяти строк макроса может очень сильно повлиять на время сборки.

Когда требуется анализировать структуру входного кода, типы или реализовать сложную кодогенерацию, обычно используют процедурные макросы.

Макросы стандартной библиотеки

В стандартной библиотеке Rust есть множество готовых макросов для самых разных случаев:

Макрос

Назначение

Пример

print!, println!

Вывод в стандартный вывод

println!("Hello, {}!", "world");

dbg!

Отладочный вывод

dbg!(2 + 2);

stringify!

Преобразует токены в строковый литерал

stringify!(a + b)

concat!

Объединяет строковые литералы

concat!("Hello", " ", "world")

format!

Форматирует строку

format!("{} + {}", 2, 2)

include_str!, include_bytes!

Встраивают содержимое файла

include_str!("config.toml")

env!, option_env!

Получают переменные окружения при компиляции

env!("PATH")

thread_local!

Создаёт static с отдельным значением для каждого потока

thread_local! { static X: RefCell<u32> = RefCell::new(0); }

assert!, assert_eq!, assert_ne!

Проверки условий

assert_eq!(x, 42)

todo!, unimplemented!, unreachable!

Заглушки и недостижимый код

todo!("implement this")

panic!

Вызывает панику

panic!("Something went wrong")

cfg!

Возвращает условие конфигурации как bool

if cfg!(unix) { ... }

compile_error!

Генерирует ошибку компиляции

compile_error!("Not supported")

vec!

Создаёт Vec из элементов

vec![1, 2, 3]

matches!

Проверяет соответствие паттерну

matches!(x, Some(42))

write!, writeln!

Форматированный вывод в объект Write

write!(&mut s, "{}", x)

format_args!

Создаёт аргументы для форматирования

format_args!("{} {}", a, b)

line!, column!, file!, module_path!

Информация о месте вызова

line!()

Единственное уточнение, что некоторые макросы стандартной библиотеки реализованы компилятором и имеют возможности, недоступные обычному macro_rules!. Например, line!, file! и env! получают информацию об исходном коде/окружении, которую декларативный макрос не может определить самостоятельно.

Декларативные макросы 2.0

Многие перечисленные недостатки macro_rules! известны давно и для их исправления в 2016 году был предложен новый механиз - декларативные макросы 2.0. Идея состоит в том, чтобы сделать декларативные макросы более похожими на обычные элементы rust: с нормальной системой модулей, видимостью и более лучшей моделью гигиены. Однако фича так и не дошла до стабилизации: macro остаётся экспериментальной возможностью под #![feature(decl_macro)], а tracking issue по-прежнему содержит нерешённые вопросы, в том числе связанные с гигиеной, областями видимости и межкрейтовым разрешением имён (RFC 1584 и tracking issue #39412).

Синтаксически отличий немного: ключевое слово macro, запятая вместо ; в качестве разделителя правил и сокращённая форма для макросов с единственным правилом:

#![feature(decl_macro)]

macro hello {
    () => { println!("Привет, мир!"); },
    ($name:expr) => { println!("Привет, {}!", $name); },
}

// Сокращённая форма
pub macro square($x:expr) {
    $x * $x
}

Важные отличия:

  • macro - полноценный элемент языка, никакой текстовой области видимости и зависимости от порядка объявлений, работают pub и pub(crate), экспорт идёт через модульную систему. #[macro_export] и $crate становятся не нужны

  • Идентификаторы, созданные макросом, не утекают за пределы раскрытия. Макрос, объявляющий pub struct $name, с macro_rules! сделает тип видимым снаружи, а с macro - нет. Та же логика закрывает и обратную проблему, когда макрос ссылается на функцию по короткому имени, а в месте вызова оказывается чужая одноимённая реализация

Причина, по которой macro так и не дошёл до stable, состоит в сложности модели гигиены и разрешения имён. Необходимо согласовать поведение макросов с модулями, импортами, элементами, trait-методами и межкрейтовым разрешением имён. Эти вопросы до сих пор остаются частью незавершённого дизайна, поэтому полноценная стабилизация выглядит маловероятной в обозримом будущем из-за отсутствия ментейнеров.

Полноценный примеры макросов

Теперь объединим рассмотренные возможности macro_rules! в несколько практических примеров.

map!

Начнём с макроса, который создаёт HashMap из списка пар:

macro_rules! map {
    () => {
        ::std::collections::HashMap::new()
    };
    ($($key:expr => $value:expr),+ $(,)?) => {{
        let mut map = ::std::collections::HashMap::new();
        $(
            map.insert($key, $value);
        )+
        map
    }};
}

let ages = map! {
    "Аня" => 29,
    "Борис" => 34,
};

Первое правило обрабатывает пустой вызов map!(), второе через повторение $(...),+ вставляет пары в цикл, $(,)? разрешает висячую запятую, а двойные скобки {{ ... }} превращают раскрытие в блок-выражение, чтобы map! можно было присвоить переменной. Разделителем внутри пары делается через =>.

select!

Теперь пример посложнее, синхронный select! над каналами. Это механизм выбора между несколькими операциями над каналами, аналогичный по назначению select в Go. Он ждёт сразу на нескольких каналах и выполняет обработчик той ветки, чей канал первым отдаст сообщение. Пример:

let result = select! {
    v = rx1 => v * 2,
    v = rx2 => v + 1,
};

Прежде чем приступим к реализации, важно обозначить в чём здесь трудность. Каждое выражение-получатель нужно вычислить ровно один раз и держать в своей локальной переменной, иначе $rx пересчитывался бы на каждом витке цикла опроса. Значит на N веток нужно N разных локальных переменных, но macro_rules! не умеет создавать свежие имена. Поэтому нужно объявлять переменную с одним и тем же именем rx на каждом шаге отдельного рекурсивного раскрытия, каждая rx получит собственный гигиенический контекст. Для компилятора это разные переменные, хотя в исходнике макроса написано одно и то же имя. Отсюда рекурсия с накопителем. Важная деталь, что в накопитель складываются не разобранные части ветки, а уже готовый кусок кода. Так внутри накопителя оказываются только токены (tt), которые не нужно разбирать заново на каждом шаге.

Реализация:

macro_rules! select {
    // Входное правило, приводим ветки к единому виду
    // и запускаем рекурсию с пустым накопителем
    ($($pat:pat_param = $rx:expr => $body:expr),+ $(,)?) => {
        select!(@bind [] $($pat = $rx => $body,)+)
    };

    // Вычисляем один приёмник в локальную
    // переменную, а готовую проверку кладём в накопитель
    // Каждое раскрытие создаёт свою rx
    (@bind [$($checks:tt)*]
        $pat:pat_param = $rx:expr => $body:expr,
        $($rest:tt)*) => {{
        let rx = $rx;
        select!(
            @bind
            [
                $($checks)*
                {
                    if let ::core::result::Result::Ok(msg) = rx.try_recv() {
                        let $pat = msg;
                        break $body;
                    }
                }
            ]
            $($rest)*
        )
    }};

    // Все приёмники вычислены, крутим цикл опроса
    (@bind [$($checks:tt)*]) => {
        loop {
            $($checks)*
            ::std::thread::yield_now();
        }
    };
}

Использование:

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

let (tx1, rx1) = mpsc::channel::<i32>();
let (tx2, rx2) = mpsc::channel::<i32>();

thread::spawn(move || {
    thread::sleep(Duration::from_millis(100));
    let _ = tx1.send(10);
});
thread::spawn(move || {
    thread::sleep(Duration::from_millis(50));
    let _ = tx2.send(20);
});

let result = select! {
    v = rx1 => v * 2,
    v = rx2 => v + 1,
};

println!("{result}"); // 21

Разберём механику по шагам:

  • Входное правило приводит список веток к единому виду и запускает рекурсию с пустым накопителем []. Правила с маркером @bind является внутреним: @ не может начинать пользовательский вызов, так что случайно в них не попасть

  • Шаг рекурсии откусывает одну ветку, вычисляет её приёмник в локальную rx и дописывает в накопитель готовую проверку этого приёмника. Токен rx внутри проверки несёт гигиенический контекст своего раскрытия, поэтому каждая проверка ссылается на свою переменную, хотя имя у всех одинаковое. Раскрытие каждого шага это блок {{ ... }}, поэтому уровни вкладываются друг в друга и все rx остаются в области видимости

  • База рекурсии генерирует цикл опроса: на каждом ветке по очереди выполняются накопленные проверки, у первого непустого канала сообщение раскладывается по паттерну ветки, и цикл завершается значением обработчика. Если готовых нет, поток уступает время планировщику через yield_now и заходит на новый круг

От настоящего select! этот отличается рядом упрощений. Он опрашивает каналы в цикле, тогда как crossbeam регистрируется во всех каналах разом и усыпляет поток до появления сообщения. Опрос всегда идёт в порядке объявления веток, а у crossbeam выбор среди готовых каналов случайный, чтобы последние ветки не оставались без внимания. Нет ветки default, веток на отправку и обработки закрытых каналов: ветка _ => {} не различает пустой и закрытый канал, поэтому если все отправители уничтожены, цикл будет крутиться вечно. Приёмники rx1 и rx2 перемещаются в макрос (let rx = $rx; их поглощает), так что после вызова они уже недоступны. Но принцип тот же.

select! из crossbeam-channel тоже написан на macro_rules!, просто куда более монструозном - загляните в исходники, там страшно.

html!

Библиотека yew позволяет писать HTML-разметку прямо в коде:

html! { <div><p>{ "Привет" }</p></div> }

Сразу уточню, что настоящий html! из yew - это процедурный макрос. Синтаксис с угловыми скобками <div>...</div> требует отслеживать вложенность и сопоставлять открывающие теги с закрывающими, а macro_rules! не умеет считать глубину вложенности. Поэтому надёжно разобрать произвольный HTML на нём не выйдет.

Но если обозначать детей фигурными скобками, задача становится подъёмной: { ... } - это одно токен-дерево, и вложенность разбирается сама собой. Соберём его крошечную версию, собирающую html-строку:

macro_rules! html {
    // Узлы кончились
    (@nodes $s:ident,) => {};

    // Элемент: tag { ...дети... }
    (@nodes $s:ident, $tag:ident { $($inner:tt)* } $($rest:tt)*) => {
        $s.push('<');
        $s.push_str(::core::stringify!($tag));
        $s.push('>');
        html!(@nodes $s, $($inner)*);
        $s.push_str("</");
        $s.push_str(::core::stringify!($tag));
        $s.push('>');
        html!(@nodes $s, $($rest)*);
    };

    // Интерполяция выражения: ( выражение )
    (@nodes $s:ident, ( $e:expr ) $($rest:tt)*) => {
        $s.push_str(&::std::format!("{}", $e));
        html!(@nodes $s, $($rest)*);
    };

    // Статический текст
    (@nodes $s:ident, $lit:literal $($rest:tt)*) => {
        $s.push_str($lit);
        html!(@nodes $s, $($rest)*);
    };

    // Ничего не подошло
    (@nodes $s:ident, $($rest:tt)*) => {
        ::core::compile_error!(
            "html!: ожидается тег, строковый литерал или ( выражение )"
        );
    };

    // Точка входа
    ($($nodes:tt)*) => {{
        let mut __s = ::std::string::String::new();
        html!(@nodes __s, $($nodes)*);
        __s
    }};
}

Использование:

let name = "Аня";
let age = 29;

let page = html! {
    div {
        p { "Имя: " ( name ) }
        p { "Возраст: " ( age ) " лет" }
    }
};

// <div><p>Имя: Аня</p><p>Возраст: 29 лет</p></div>

[x * x for x in range(1, 6)] # [1, 4, 9, 16, 25]
[x for x in range(10) if x % 2 == 0] # [0, 2, 4, 6, 8]Как это работает:

  • Точка входа создаёт строку-накопитель __s и передаёт её имя внутреннему правилу @nodes вместе со всем содержимым. Как и в select!, маркер @ отделяет внутренние правила от публичного входа

  • Токен __s несёт гигиенический контекст того раскрытия, где переменная была объявлена и после захвата в $s продолжает указывать на неё же. Это тот же механизм, что и с rx в select!, только применённый наоборот: там гигиена разводила одноимённые переменные, здесь связывает одну через десяток вложенных раскрытий

  • Правила @nodes - это TT-мунчер, откусывающий от потока по одному узлу. Узлом может быть элемент tag { ... }, выражение в круглых скобках или строковый литерал. Фигурные и круглые скобки, будучи цельными токен-деревьями, дают мунчеру чёткие границы узлов, поэтому считать вложенность вручную не приходится

  • Правило для элемента дважды вызывает себя: сначала на детях, потом на остатке. Один и тот же накопитель протаскивается через всю рекурсию, так что промежуточные строки не создаются, а корневые и вложенные элементы обрабатываются одинаково

Примечания:

  • поддержки атрибутов (div class="box" { ... }) здесь нет

  • строки вставляются как есть, без экранирования. Для настоящего шаблонизатора это дыра (XSS), поэтому боевые библиотеки экранируют вывод

  • как только захочется писать именно <div>...</div>, придётся переходить на процедурный макрос. Поэтому yew на нём и написан

comp!

В Python есть компактная запись для построения списков:

[x * x for x in range(1, 6)]            # [1, 4, 9, 16, 25]
[x for x in range(10) if x % 2 == 0]    # [0, 2, 4, 6, 8]

В Rust то же самое делается цепочкой итераторов, но макросом можно реализовать нечто подобное:

macro_rules! comp {
    // выражение for паттерн in итератор 
    ($expr:expr, for $pat:pat_param in $iter:expr) => {
        ::std::iter::IntoIterator::into_iter($iter)
            .map(|$pat| $expr)
            .collect::<::std::vec::Vec<_>>()
    };
    // выражение for паттерн in итератор if условие 
    ($expr:expr, for $pat:pat_param in $iter:expr, if $cond:expr) => {
        ::std::iter::IntoIterator::into_iter($iter)
            .filter_map(|$pat| {
                if $cond {
                    ::std::option::Option::Some($expr)
                } else {
                    ::std::option::Option::None
                }
            })
            .collect::<::std::vec::Vec<_>>()
    };
}

let squares = comp![x * x, for x in 1..=5];          // [1, 4, 9, 16, 25]
let evens = comp![x, for x in 0..10, if x % 2 == 0]; // [0, 2, 4, 6, 8]

Ключевые слова for и if в образце - это обычные литеральные токены, как => в map!. Пользователь обязан написать их, а макрос лишь сопоставляет их в образце. Никакой связи с настоящими for и if языка тут нет, мы просто заимствовали знакомые слова, чтобы DSL читался естественно.

Запятые перед for и if - это требование для чёткого разделение передаваемых выражений. После фрагмента expr разрешены только =>, , и ;, поэтому написать comp![x for x in 0..10] в точности как в Python не получится. За $expr:expr не может сразу следовать токен for. Запятая являеься минимальным разделителем, который делает образец допустимым.

Паттерн захватывается как pat_param, а не pat. Дело в том, что pat сопоставляется в том числе с верхнеуровневым | (например, 1 | 2), и тогда подстановка в замыкание |$pat| развалилась бы на |1 | 2|. Спецификатор pat_param такие паттерны не пропускает, поэтому замыкание остаётся корректным.

Вариант с условием использует не filter(...).map(...), а один filter_map. Причина в том, что filter передаёт в замыкание ссылку &x и паттерн с условием работали бы уже с ней, а не со значением. filter_map отдаёт значение во владение, поэтому условие и выражение пишутся ровно как в Python, без разыменований.

Разумеется, до настоящего list comprehension макросу далеко. Результат всегда собирается в Vec, а несколько for подряд (вложенные comprehension) не поддерживаются.

Макросы в других языках

Теперь, когда устройство macro_rules! разобрано, полезно посмотреть шире: механизмы метапрограммирования есть во многих языках и решения Rust не возникли на пустом месте. Заодно станет понятнее, почему декларативные макросы устроены именно так.

C

Препроцессор C ничего не знает ни о синтаксисе, ни о типах, ни об областях видимости. Он заменяет одну последовательность символов другой ещё до того, как начнётся компиляция. Например:

#define SQR(x) x * x

SQR(2 + 3)   // 2 + 3 * 2 + 3, то есть 11
SQR(i++)     // i инкрементируется дважды

Отсюда весь классический набор проблем: скобки приходится расставлять вручную (#define SQR(x) ((x) * (x))), а аргумент с побочным эффектом вычисляется столько раз, сколько он встретился в теле.

В C умеет склеивать идентификаторов: оператор ## соединяет токены в новое имя и это позволяет генерировать функции с именами, производными от аргументов:

#define MAKE_GETTER(name, type) \
    type get_##name(const struct Config *c) { return c->name; }

MAKE_GETTER(port, int)          // int get_port(...)
MAKE_GETTER(host, const char *) // const char *get_host(...)

В macro_rules! так нельзя: из port получить get_port невозможно, имя должно прийти снаружи целиком. Макрос C может раскрываться во что угодно, включая незакрытые скобки и обрывки синтаксиса. Возможность сомнительная, но иногда ей пользуются, а в Rust она недоступна в принципе.

Что в C сделано хуже:

  • Нет гигиены - временная переменная внутри макроса перехватывает пользовательскую без предупреждения

  • Макросы не рекурсивны - если в раскрытии встречается имя самого макроса, оно повторно не раскрывается

  • Ошибки сложно читать - компилятор видит уже развёрнутый текст и ругается на него, не зная о макросе ничего

Lisp

Программа на Lisp записывается теми же вложенными списками, что и данные. Это свойство называется гомоиконностью. Макрос - это обычная функция, которая получает невычисленный код в виде списка, преобразует его любыми средствами языка и возвращает новый список.

(defmacro my-unless (condition &body body)
  `(if ,condition nil (progn ,@body)))

(defmacro my-swap (a b)
`(let ((tmp ,a)) ; если снаружи есть своя tmp - беда
(setf ,a ,b)
(setf ,b tmp)))Обратная кавычка задаёт шаблон, запятая подставляет значение, ,@ вклеивает список. Это ровно то, что делает quote! в процедурных макросах Rust. Разница в том, что quote! пришлось изобретать и тащить отдельным крейтом, а в Lisp квотирование встроено в язык, потому что подставлять нужно в те же самые списки.

Что в Lisp сделано лучше:

  • Нет отдельного языка макросов. В Rust, чтобы написать macro_rules!, приходится выучить вторую грамматику: метапеременные, спецификаторы фрагментов, повторения и т.д., а в Lisp макрос пишется на том же Lisp

  • Произвольные вычисления бесплатны. Например, count! потребовал рекурсии с базовым случаем и упирается в recursion_limit. в Lisp это вызов length. Никакого разделения на декларативные и процедурные нет, не требуя ни отдельного крейта (как у процедурных макросов), ни отдельной стадии сборки

  • Отладка интерактивная. macroexpand-1 раскрывает макрос на один шаг прямо в REPL и показывает результат как данные, которые можно разглядывать и обрабатывать дальше. cargo expand - это раскрытие всего файла целиком, без пошаговости

Что в Lisp сделано хуже:
1) defmacro не гигиеничен. Временная переменная внутри макроса запросто перехватывает пользовательскую:

(defmacro my-swap (a b)
  `(let ((tmp ,a))          ; если снаружи есть своя tmp - беда
     (setf ,a ,b)
     (setf ,b tmp)))

Лечится это вручную, генерацией уникального имени через gensym:

(defmacro my-swap (a b)
  (let ((tmp (gensym)))
    `(let ((,tmp ,a))
       (setf ,a ,b)
       (setf ,b ,tmp))))

Про gensym можно забыть и тогда получится баг, который проявится только у того пользователя, который назвал переменную неудачно. В Rust эта категория ошибок отсутствует: let tmp = ... внутри macro_rules! физически не может пересечься с tmp вызывающего кода

2) Ошибки всплывают поздно. Если макрос вернул некорректный код, то узнаём об этом при вычислении, в сообщении про что-то глубоко внутри раскрытия. В Rust результат раскрытия проходит полную проверку компилятором и ошибка ловится на сборке

Scheme

В Scheme есть syntax-rules - декларативная система на сопоставлении с образцом. Это прямой предок macro_rules!:

(define-syntax my-or
  (syntax-rules ()
    ((_)           #f)
    ((_ e)         e)
    ((_ e1 e2 ...) (let ((t e1)) (if t t (my-or e2 ...))))))

Правила сверху вниз, рекурсивный вызов себя, многоточие ... в роли $(...)*. Гигиена полная: она распространяется на все имена, а не только на локальные переменные. Более того, работает и обратное свойство - referential transparency: имена, на которые ссылается сам макрос, разрешаются в окружении определения, а не вызова.

Zig

Макросов в языке нет, вместо них comptime. То есть выполнение обычного кода на этапе компиляции, где типы являются полноценными значениями:

fn Wrapper(comptime T: type) type {
    return struct {
        value: T,
        pub fn get(self: @This()) T {
            return self.value;
        }
    };
}

Что это даёт:

  • Макросу доступны типы. Через @typeInfo код на этапе компиляции может посмотреть, какие у структуры поля, какого они типа, сколько вариантов у перечисления и сгенерировать разное в зависимости от этого

  • Нет гигиены. Ничего никуда не подставляется, а значит и нечему перехватывать имена

  • Отладка обычная. Ошибка указывает на реальную строчку реального кода, а не внутрь раскрытия. Не нужен cargo expand, чтобы понять, что получилось при генерации

Что мы теряем:

  • comptime работает со значениями и типами, но не может изобрести синтаксис. Всё, что мы писали в примерах: map! { "Аня" => 29 }, html! { div { ... } }, comp![x * x, for x in 1..=5], select! с ветками и т.д. На comptime это невыразимо: там нет способа принять на вход последовательность токенов, которая не является валидным кодом языка. Нет и способа сгенерировать функцию с новым именем

P.s.

  1. В rust тоже есть вычисления на этапе компиляции через ключевое слово const, но оно намного слабее, чем реализация в zig

  2. Ещё можно вспомнить Golang, в котором нет макросов. Метапрограммирование вынесено наружу в отдельные программы, запускаемые через go:generate. Это самый прозрачный из подходов: сгенерированный код лежит на диске, его можно открыть и прочитать, но также он и самый громоздкий. Для Rust это аналог build.rs.

Заключение

macro_rules! - мощный инструмент для генерации шаблонного кода и создания небольших DSL. С помощью паттернов, повторений, нескольких правил и рекурсии можно реализовать гораздо больше, чем может показаться на первый взгляд.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.