Макросы Rust: от препроцессора к метапрограммированию
Что вы узнаете: как работают макросы Rust, когда использовать их вместо функций или обобщений и как они заменяют препроцессор C/C++. К концу главы вы сможете писать собственные макросы
macro_rules!и понимать, что скрывается за#[derive(Debug)].
Макросы — одна из первых вещей, с которой вы сталкиваетесь в Rust (println!("hello") в первой же строке), но одна из последних, что объясняют большинство курсов. Эта глава восполняет этот пробел.
Зачем нужны макросы
Функции и обобщения покрывают большую часть повторного использования кода в Rust. Макросы закрывают пробелы, до которых не дотягивается система типов:
| Потребность | Функция/обобщение? | Макрос? | Почему |
|---|---|---|---|
| Вычислить значение | ✅ fn max<T: Ord>(a: T, b: T) -> T | — | Это делает система типов |
| Принимать переменное число аргументов | ❌ В Rust нет вариативных функций | ✅ println!("{} {}", a, b) | Макросы принимают любое число токенов |
Генерировать однотипные блоки impl | ❌ Одними обобщениями не получится | ✅ macro_rules! | Макросы генерируют код на этапе компиляции |
| Выполнять код на этапе компиляции | ❌ const fn ограничен | ✅ Процедурные макросы | Полноценный код Rust выполняется на этапе компиляции |
| Условно включать код | ❌ | ✅ #[cfg(...)] | Макросы-атрибуты управляют компиляцией |
Если вы пришли из C/C++, думайте о макросах как о единственной правильной замене препроцессора — с той разницей, что они работают с синтаксическим деревом, а не с сырым текстом, поэтому они гигиеничны (нет случайных коллизий имён) и учитывают типы.
Для разработчиков на C: макросы Rust полностью заменяют
#define. Текстового препроцессора нет. Полное соответствие препроцессора и Rust см. в ch18.
Декларативные макросы с macro_rules!
Декларативные макросы (также называемые «макросами по примеру») — самая распространённая форма макросов в Rust. Они используют сопоставление с образцом по синтаксису, похожее на match по значениям.
Базовый синтаксис
macro_rules! say_hello {
() => {
println!("Hello!");
};
}
fn main() {
say_hello!(); // Раскрывается в: println!("Hello!");
}
! после имени — это то, что сообщает (и вам, и компилятору), что это вызов макроса.
Сопоставление с образцом по аргументам
Макросы сопоставляют деревья токенов с помощью спецификаторов фрагментов:
macro_rules! greet {
// Образец 1: без аргументов
() => {
println!("Hello, world!");
};
// Образец 2: один аргумент-выражение
($name:expr) => {
println!("Hello, {}!", $name);
};
}
fn main() {
greet!(); // "Hello, world!"
greet!("Rust"); // "Hello, Rust!"
}
Справочник спецификаторов фрагментов
| Спецификатор | Соответствует | Пример |
|---|---|---|
$x:expr | Любому выражению | 42, a + b, foo() |
$x:ty | Типу | i32, Vec<String>, &str |
$x:ident | Идентификатору | foo, my_var |
$x:pat | Образцу | Some(x), _, (a, b) |
$x:stmt | Оператору | let x = 5; |
$x:block | Блоку | { println!("hi"); 42 } |
$x:literal | Литералу | 42, "hello", true |
$x:tt | Одному дереву токенов | Что угодно — универсальный шаблон |
$x:item | Элементу (fn, struct, impl и т. д.) | fn foo() {} |
Повторение — ключевая возможность
Макросы C/C++ не умеют циклов. Макросы Rust умеют повторять образцы:
macro_rules! make_vec {
// Сопоставляем ноль или более выражений, разделённых запятыми
( $( $element:expr ),* ) => {
{
let mut v = Vec::new();
$( v.push($element); )* // Повторяем для каждого сопоставленного элемента
v
}
};
}
fn main() {
let v = make_vec![1, 2, 3, 4, 5];
println!("{v:?}"); // [1, 2, 3, 4, 5]
}
Синтаксис $( ... ),* означает «сопоставить ноль или более таких образцов, разделённых запятыми». Конструкция $( ... )* в раскрытии повторяет тело один раз для каждого совпадения.
Именно так реализован
vec![]в стандартной библиотеке. Реальный исходный код:#![allow(unused)] fn main() { macro_rules! vec { () => { Vec::new() }; ($elem:expr; $n:expr) => { vec::from_elem($elem, $n) }; ($($x:expr),+ $(,)?) => { <[_]>::into_vec(Box::new([$($x),+])) }; } }Конструкция
$(,)?в конце разрешает необязательную завершающую запятую.
Операторы повторения
| Оператор | Значение | Пример |
|---|---|---|
$( ... )* | Ноль или более | vec![], vec![1], vec![1, 2, 3] |
$( ... )+ | Один или более | Требуется хотя бы один элемент |
$( ... )? | Ноль или один | Необязательный элемент |
Практический пример: конструктор hashmap!
В стандартной библиотеке есть vec![], но нет hashmap!{}. Давайте создадим его:
macro_rules! hashmap {
( $( $key:expr => $value:expr ),* $(,)? ) => {
{
let mut map = std::collections::HashMap::new();
$( map.insert($key, $value); )*
map
}
};
}
fn main() {
let scores = hashmap! {
"Alice" => 95,
"Bob" => 87,
"Carol" => 92, // Завершающая запятая допустима благодаря $(,)?
};
println!("{scores:?}");
}
Практический пример: макрос проверки диагностики
Шаблон, распространённый во встраиваемом и диагностическом коде, — проверить условие и вернуть ошибку:
#![allow(unused)]
fn main() {
use thiserror::Error;
#[derive(Error, Debug)]
enum DiagError {
#[error("Check failed: {0}")]
CheckFailed(String),
}
macro_rules! diag_check {
($cond:expr, $msg:expr) => {
if !($cond) {
return Err(DiagError::CheckFailed($msg.to_string()));
}
};
}
fn run_diagnostics(temp: f64, voltage: f64) -> Result<(), DiagError> {
diag_check!(temp < 85.0, "GPU too hot");
diag_check!(voltage > 0.8, "Rail voltage too low");
diag_check!(voltage < 1.5, "Rail voltage too high");
println!("All checks passed");
Ok(())
}
}
Сравнение с C/C++:
// Препроцессор C — текстовая подстановка, без проверки типов, без гигиены #define DIAG_CHECK(cond, msg) \ do { if (!(cond)) { log_error(msg); return -1; } } while(0)Версия на Rust возвращает корректный тип
Result, не рискует двойным вычислением, а компилятор проверяет, что$condдействительно является выражением типаbool.
Гигиена: почему макросы Rust безопасны
Ошибки макросов C/C++ часто возникают из-за коллизий имён:
// C: опасно — `x` может затенить `x` вызывающего кода
#define SQUARE(x) ((x) * (x))
int x = 5;
int result = SQUARE(x++); // Неопределённое поведение: x инкрементируется дважды!
Макросы Rust гигиеничны — переменные, созданные внутри макроса, не утекают наружу:
macro_rules! make_x {
() => {
let x = 42; // Этот `x` ограничен раскрытием макроса
};
}
fn main() {
let x = 10;
make_x!();
println!("{x}"); // Выводит 10, а не 42 — гигиена предотвращает коллизию
}
Компилятор считает x макроса и x вызывающего кода разными переменными, даже несмотря на одинаковые имена. С препроцессором C это невозможно.
Распространённые макросы стандартной библиотеки
Вы используете их с первой главы — вот что они делают на самом деле:
| Макрос | Что делает | Раскрывается в (упрощённо) |
|---|---|---|
println!("{}", x) | Форматирует и выводит в stdout с переводом строки | std::io::_print(format_args!(...)) |
eprintln!("{}", x) | Выводит в stderr с переводом строки | То же, но в stderr |
format!("{}", x) | Форматирует в String | Выделяет память и возвращает String |
vec![1, 2, 3] | Создаёт Vec с элементами | Vec::from([1, 2, 3]) (примерно) |
todo!() | Отмечает незавершённый код | panic!("not yet implemented") |
unimplemented!() | Отмечает намеренно не реализованный код | panic!("not implemented") |
unreachable!() | Отмечает код, недостижимость которого компилятор не может доказать | panic!("unreachable") |
assert!(cond) | Вызывает panic, если условие ложно | if !cond { panic!(...) } |
assert_eq!(a, b) | Вызывает panic, если значения не равны | При ошибке показывает оба значения |
dbg!(expr) | Выводит выражение и его значение в stderr, возвращает значение | eprintln!("[file:line] expr = {:#?}", &expr); expr |
include_str!("file.txt") | Встраивает содержимое файла как &str на этапе компиляции | Читает файл во время компиляции |
include_bytes!("data.bin") | Встраивает содержимое файла как &[u8] на этапе компиляции | Читает файл во время компиляции |
cfg!(condition) | Условие времени компиляции как bool | true или false в зависимости от цели |
env!("VAR") | Читает переменную окружения на этапе компиляции | Компиляция завершается ошибкой, если её нет |
concat!("a", "b") | Объединяет литералы на этапе компиляции | "ab" |
dbg! — макрос для отладки, которым вы будете пользоваться каждый день
fn factorial(n: u32) -> u32 {
if dbg!(n <= 1) { // Выводит: [src/main.rs:2] n <= 1 = false
dbg!(1) // Выводит: [src/main.rs:3] 1 = 1
} else {
dbg!(n * factorial(n - 1)) // Выводит промежуточные значения
}
}
fn main() {
dbg!(factorial(4)); // Выводит все рекурсивные вызовы с файлом и строкой
}
dbg! возвращает значение, которое оборачивает, поэтому его можно вставить куда угодно, не меняя поведение программы. Он выводит в stderr (а не в stdout), поэтому не мешает выводу программы. Перед коммитом удаляйте все вызовы dbg!.
Синтаксис строк формата
Поскольку println!, format!, eprintln! и write! используют один и тот же механизм форматирования, вот краткий справочник:
#![allow(unused)]
fn main() {
let name = "sensor";
let value = 3.14159;
let count = 42;
println!("{name}"); // Переменная по имени (Rust 1.58+)
println!("{}", name); // Позиционно
println!("{value:.2}"); // 2 знака после запятой: "3.14"
println!("{count:>10}"); // Выравнивание по правому краю, ширина 10: " 42"
println!("{count:0>10}"); // Дополнение нулями: "0000000042"
println!("{count:#06x}"); // Шестнадцатеричный с префиксом: "0x002a"
println!("{count:#010b}"); // Двоичный с префиксом: "0b00101010"
println!("{value:?}"); // Формат Debug
println!("{value:#?}"); // Формат Debug с красивым выводом
}
Для разработчиков на C: думайте об этом как о типобезопасном
printf— компилятор проверяет, что{:.2}применяется к числу с плавающей точкой, а не к строке. Ошибок несоответствия%s/%dне бывает.Для разработчиков на C++: это заменяет
std::cout << std::fixed << std::setprecision(2) << valueодной читаемой строкой формата.
Макросы derive
Вы видели #[derive(...)] почти у каждой структуры в этой книге:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
struct Point {
x: f64,
y: f64,
}
}
#[derive(Debug)] — это макрос derive, особый вид процедурного макроса, который автоматически генерирует реализации трейтов. Вот что он создаёт (упрощённо):
#![allow(unused)]
fn main() {
// То, что #[derive(Debug)] генерирует для Point:
impl std::fmt::Debug for Point {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Point")
.field("x", &self.x)
.field("y", &self.y)
.finish()
}
}
}
Без #[derive(Debug)] вам пришлось бы писать такой блок impl вручную для каждой структуры.
Распространённые производные трейты
| Derive | Что генерирует | Когда использовать |
|---|---|---|
Debug | Форматирование {:?} | Почти всегда — позволяет выводить для отладки |
Clone | Метод .clone() | Когда нужно дублировать значения |
Copy | Неявное копирование при присваивании | Небольшие типы только на стеке (целые, [f64; 3]) |
PartialEq / Eq | Операторы == и != | Когда нужно сравнение на равенство |
PartialOrd / Ord | Операторы <, >, <=, >= | Когда нужен порядок |
Hash | Хеширование для ключей HashMap/HashSet | Типы, используемые как ключи словаря |
Default | Конструктор Type::default() | Типы с разумными нулевыми/пустыми значениями |
serde::Serialize / Deserialize | Сериализация в JSON/TOML и т. д. | Типы данных, пересекающие границы API |
Дерево решений по derive
Стоит ли выводить этот трейт?
│
├── Содержит ли мой тип только типы, которые реализуют трейт?
│ ├── Да → #[derive] сработает
│ └── Нет → Напишите реализацию вручную (или пропустите)
│
└── Будут ли пользователи моего типа разумно ожидать такого поведения?
├── Да → Выводите (Debug, Clone, PartialEq почти всегда разумны)
└── Нет → Не выводите (например, не выводите Copy для типа с файловым дескриптором)
Сравнение с C++:
#[derive(Clone)]похож на автоматическую генерацию корректного конструктора копирования.#[derive(PartialEq)]похож на автоматически сгенерированныйoperator==, который сравнивает каждое поле — то, что оператор «космического корабля»= defaultиз C++20 наконец предоставляет.
Макросы-атрибуты
Макросы-атрибуты преобразуют элемент, к которому они прикреплены. Некоторые вы уже использовали:
#![allow(unused)]
fn main() {
#[test] // Помечает функцию как тест
fn test_addition() {
assert_eq!(2 + 2, 4);
}
#[cfg(target_os = "linux")] // Условно включает эту функцию
fn linux_only() { /* ... */ }
#[derive(Debug)] // Генерирует реализацию Debug
struct MyType { /* ... */ }
#[allow(dead_code)] // Подавляет предупреждение компилятора
fn unused_helper() { /* ... */ }
#[must_use] // Предупреждает, если возвращаемое значение отброшено
fn compute_checksum(data: &[u8]) -> u32 { /* ... */ }
}
Распространённые встроенные атрибуты:
| Атрибут | Назначение |
|---|---|
#[test] | Пометить как тестовую функцию |
#[cfg(...)] | Условная компиляция |
#[derive(...)] | Автоматическая генерация реализаций трейтов |
#[allow(...)] / #[deny(...)] / #[warn(...)] | Управление уровнями линтера |
#[must_use] | Предупреждать о неиспользованных возвращаемых значениях |
#[inline] / #[inline(always)] | Подсказка компилятору встроить функцию |
#[repr(C)] | Использовать раскладку памяти, совместимую с C (для FFI) |
#[no_mangle] | Не искажать имя символа (для FFI) |
#[deprecated] | Пометить как устаревший с необязательным сообщением |
Для разработчиков на C/C++: атрибуты заменяют смесь директив препроцессора (
#pragma,__attribute__((...))) и расширений, специфичных для компилятора. Они входят в грамматику языка, а не являются приклеенными расширениями.
Процедурные макросы (концептуальный обзор)
Процедурные макросы («proc macros») — это макросы, написанные как отдельные программы на Rust, которые выполняются на этапе компиляции и генерируют код. Они мощнее, чем macro_rules!, но и сложнее.
Существует три вида:
| Вид | Синтаксис | Пример | Что делает |
|---|---|---|---|
| Функциональный | my_macro!(...) | sql!(SELECT * FROM users) | Разбирает пользовательский синтаксис, генерирует код Rust |
| Derive | #[derive(MyTrait)] | #[derive(Serialize)] | Генерирует реализацию трейта по определению структуры |
| Атрибут | #[my_attr] | #[tokio::main], #[instrument] | Преобразует помеченный элемент |
Вы уже используете процедурные макросы
#[derive(Error)]изthiserror— генерирует реализацииDisplayиFromдля перечислений ошибок#[derive(Serialize, Deserialize)]изserde— генерирует код сериализации#[tokio::main]— превращаетasync fn main()в настройку среды выполнения + block_on#[test]— регистрируется тестовым харнессом (встроенный процедурный макрос)
Когда стоит писать собственный процедурный макрос
Скорее всего, в ходе этого курса писать процедурные макросы не понадобится. Они полезны, когда:
- Нужно анализировать поля структур или варианты перечислений на этапе компиляции (макросы derive)
- Вы создаёте предметно-ориентированный язык (функциональные макросы)
- Нужно преобразовывать сигнатуры функций (макросы-атрибуты)
Для большинства кода достаточно macro_rules! или обычных функций.
Сравнение с C++: процедурные макросы выполняют ту роль, которую в C++ играют генераторы кода, метапрограммирование на шаблонах и внешние инструменты вроде
protoc. Разница в том, что процедурные макросы — часть конвейера сборки cargo: никаких внешних шагов сборки, никаких пользовательских команд CMake.
Когда что использовать: макросы, функции или обобщения
Нужно генерировать код?
│
├── Нет → Используйте функцию или обобщённую функцию
│ (проще, лучшие сообщения об ошибках, поддержка IDE)
│
└── Да ─┬── Переменное число аргументов?
│ └── Да → macro_rules! (например, println!, vec!)
│
├── Однотипные блоки impl для многих типов?
│ └── Да → macro_rules! с повторением
│
├── Нужно анализировать поля структуры?
│ └── Да → Макрос derive (процедурный макрос)
│
├── Нужен собственный синтаксис (DSL)?
│ └── Да → Функциональный процедурный макрос
│
└── Нужно преобразовать функцию/структуру?
└── Да → Процедурный макрос-атрибут
Общее правило: если можно обойтись функцией или обобщением, не используйте макрос. У макросов хуже сообщения об ошибках, нет автодополнения IDE внутри тела макроса, и их труднее отлаживать.
Упражнения
🟢 Упражнение 1: макрос min!
Напишите макрос min!, который:
min!(a, b)возвращает меньшее из двух значенийmin!(a, b, c)возвращает наименьшее из трёх значений- Работает с любым типом, реализующим
PartialOrd
Подсказка: в вашем macro_rules! понадобятся два варианта ветвления.
Решение (нажмите, чтобы развернуть)
macro_rules! min {
($a:expr, $b:expr) => {
if $a < $b { $a } else { $b }
};
($a:expr, $b:expr, $c:expr) => {
min!(min!($a, $b), $c)
};
}
fn main() {
println!("{}", min!(3, 7)); // 3
println!("{}", min!(9, 2, 5)); // 2
println!("{}", min!(1.5, 0.3)); // 0.3
}
Примечание: для продакшн-кода лучше использовать std::cmp::min или a.min(b). Это упражнение демонстрирует устройство макросов с несколькими ветвями.
🟡 Упражнение 2: hashmap! с нуля
Не глядя на пример выше, напишите макрос hashmap!, который:
- Создаёт
HashMapиз парключ => значение - Поддерживает завершающие запятые
- Работает с любым хешируемым типом ключа
Проверьте на:
#![allow(unused)]
fn main() {
let m = hashmap! {
"name" => "Alice",
"role" => "Engineer",
};
assert_eq!(m["name"], "Alice");
assert_eq!(m.len(), 2);
}
Решение (нажмите, чтобы развернуть)
use std::collections::HashMap;
macro_rules! hashmap {
( $( $key:expr => $val:expr ),* $(,)? ) => {{
let mut map = HashMap::new();
$( map.insert($key, $val); )*
map
}};
}
fn main() {
let m = hashmap! {
"name" => "Alice",
"role" => "Engineer",
};
assert_eq!(m["name"], "Alice");
assert_eq!(m.len(), 2);
println!("Tests passed!");
}
🟡 Упражнение 3: assert_approx_eq! для сравнения чисел с плавающей точкой
Напишите макрос assert_approx_eq!(a, b, epsilon), который вызывает panic, если |a - b| > epsilon. Он полезен для тестирования вычислений с плавающей точкой, где точное равенство не выполняется.
Проверьте на:
#![allow(unused)]
fn main() {
assert_approx_eq!(0.1 + 0.2, 0.3, 1e-10); // Должно пройти
assert_approx_eq!(3.14159, std::f64::consts::PI, 1e-4); // Должно пройти
// assert_approx_eq!(1.0, 2.0, 0.5); // Должно вызвать panic
}
Решение (нажмите, чтобы развернуть)
macro_rules! assert_approx_eq {
($a:expr, $b:expr, $eps:expr) => {
let (a, b, eps) = ($a as f64, $b as f64, $eps as f64);
let diff = (a - b).abs();
if diff > eps {
panic!(
"assertion failed: |{} - {}| = {} > {} (epsilon)",
a, b, diff, eps
);
}
};
}
fn main() {
assert_approx_eq!(0.1 + 0.2, 0.3, 1e-10);
assert_approx_eq!(3.14159, std::f64::consts::PI, 1e-4);
println!("All float comparisons passed!");
}
🔴 Упражнение 4: impl_display_for_enum!
Напишите макрос, который генерирует реализацию Display для простых перечислений в стиле C. Дано:
#![allow(unused)]
fn main() {
impl_display_for_enum! {
enum Color {
Red => "red",
Green => "green",
Blue => "blue",
}
}
}
Он должен сгенерировать и определение enum Color { Red, Green, Blue }, И impl Display for Color, который сопоставляет каждый вариант с его строкой.
Подсказка: понадобятся и повторение $( ... ),*, и несколько спецификаторов фрагментов.
Решение (нажмите, чтобы развернуть)
use std::fmt;
macro_rules! impl_display_for_enum {
(enum $name:ident { $( $variant:ident => $display:expr ),* $(,)? }) => {
#[derive(Debug, Clone, Copy, PartialEq)]
enum $name {
$( $variant ),*
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
$( $name::$variant => write!(f, "{}", $display), )*
}
}
}
};
}
impl_display_for_enum! {
enum Color {
Red => "red",
Green => "green",
Blue => "blue",
}
}
fn main() {
let c = Color::Green;
println!("Color: {c}"); // "Color: green"
println!("Debug: {c:?}"); // "Debug: Green"
assert_eq!(format!("{}", Color::Red), "red");
println!("All tests passed!");
}