Процедурные макросы: derive
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Процедурные макросы: derive
В прошлом уроке мы упёрлись в потолок
macro_rules!: он умеет только сопоставить токены с образцом и подставить шаблон. Он не может пройтись по полям структуры, перебрать вариантыenum, прочитать атрибут рядом с полем, вычислить что-то нетривиальное по входу. Как только нужна настоящая логика над кодом, декларативного макроса не хватает. Тут начинаются процедурные макросы, и идея у них радикально другая: на этапе компиляции компилятор запускает обычную программу на Rust, которая принимает код как поток токенов и возвращает другой поток токенов. Никаких образцов, ты пишешь функциюfn(TokenStream) -> TokenStreamи внутри делаешь что хочешь. В этом уроке мы разберём первый и самый частый вид,#[derive(...)]: соберём свой derive от начала до конца с помощьюsynиquoteи заглянем, как из той же кухни вырастаетserde::Serialize.
Процедурный макрос это программа над токенами
Процедурный макрос это не пара «образец, шаблон», а скомпилированная функция, которую компилятор вызывает прямо во время сборки твоего крейта. Её сигнатура буквально такая:
use proc_macro::TokenStream;
#[proc_macro_derive(FieldNames)]
pub fn derive_field_names(input: TokenStream) -> TokenStream {
// тут обычный код на Rust: разбираем input, генерируем выход
}
На вход приходит TokenStream, поток токенов аннотированного кода, на выход уходит другой TokenStream, который компилятор подставит в программу. Всё, что между ними, это твой код: цикл по полям, разбор атрибутов, генерация. Отсюда и название «процедурный»: ты пишешь процедуру, а не декларацию соответствия.
Почему это отдельный крейт
Первая особенность, которая удивляет: процедурный макрос обязан жить в отдельном крейте особого типа. В его Cargo.toml стоит флаг, а сам код собирается не в твою программу, а в плагин для компилятора:
# Cargo.toml крейта-макроса
[lib]
proc-macro = true
[dependencies]
syn = "2"
quote = "1"
Причина в том, что макрос исполняется на машине, которая компилирует код, а не на той, где код потом запустится. Это как сборочный инструмент: компилятор должен сначала собрать твой макрос в исполняемый плагин, загрузить его и только потом применять к остальному коду. Смешивать инструмент сборки и собираемую программу в одном крейте нельзя, поэтому процедурные макросы выносят в крейт вроде mylib-derive, а основной крейт его переэкспортирует. Именно так устроена пара serde и serde_derive.
Два помощника: syn и quote
Разбирать TokenStream руками это боль: голый поток токенов не знает, где имя структуры, а где тип поля. Поэтому почти никто не делает этого вручную. Есть два крейта, на которых стоит вся экосистема.
syn разбирает токены в типизированное дерево. Для derive главная точка входа это DeriveInput: структура с полями ident (имя типа), generics (его параметры) и data (что это, struct/enum/union, со списком полей или вариантов). После парсинга ты ходишь по полям обычным итератором.
quote делает обратное: собирает TokenStream из шаблона. Внутри quote! { ... } ты пишешь почти обычный Rust, а через # интерполируешь значения: #name подставит переменную, #(#fields),* развернёт итератор через запятую. Пара симметрична: syn разбирает вход в дерево, quote собирает выход из шаблона.
Свой derive от начала до конца
Соберём #[derive(FieldNames)], который дописывает структуре метод field_names(), возвращающий имена её полей. Полный код функции-макроса:
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields};
#[proc_macro_derive(FieldNames)]
pub fn derive_field_names(input: TokenStream) -> TokenStream {
// 1. syn парсит токены в типизированное дерево
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident; // имя структуры, например Point
// 2. достаём имена полей (обходим дерево обычным кодом)
let field_names = match &input.data {
Data::Struct(s) => match &s.fields {
Fields::Named(named) => named
.named
.iter()
.map(|f| f.ident.as_ref().unwrap().to_string())
.collect::<Vec<_>>(),
_ => Vec::new(),
},
_ => Vec::new(), // на enum/union пока не реагируем
};
// 3. quote собирает выходной impl из шаблона
let expanded = quote! {
impl #name {
pub fn field_names() -> &'static [&'static str] {
&[ #(#field_names),* ]
}
}
};
// 4. отдаём готовый код компилятору
expanded.into()
}
Применение со стороны пользователя выглядит так:
#[derive(FieldNames)]
struct Point { x: i32, y: i32 }
fn main() {
println!("{:?}", Point::field_names()); // ["x", "y"]
}
Разберём по шагам. parse_macro_input!(input as DeriveInput) это parse_macro_input!: он превращает токены в DeriveInput или, если разбор не удался, сам возвращает аккуратную ошибку компиляции. Дальше input.ident это имя структуры, а спуск через Data::Struct -> Fields::Named даёт список полей, по которым мы проходим итератором и собираем их имена в Vec<String>. Наконец quote! строит impl: #name подставляет Point, а #(#field_names),* разворачивает имена в массив &["x", "y"]. Готовый TokenStream уходит компилятору и встаёт рядом со структурой.
Ключевой момент, который часто упускают: derive ничего не меняет в самой структуре. Он только дописывает код рядом (новый impl, новый блок). Поле не добавит, тип не поправит. Это видно и в нашем примере: Point остался прежним, появился лишь отдельный impl Point.
Прокликай конвейер: тот же #[derive(FieldNames)], разложенный на пять стадий от исходника до результата. Обрати внимание, какие стадии помечены «внутри proc-macro функции»: всё, что между входным и выходным TokenStream, это и есть твой код.
Как под капотом работает serde::Serialize
Теперь то же самое, но в промышленном масштабе. Когда ты пишешь #[derive(Serialize)], крейт serde_derive запускает ровно такую функцию: парсит твой тип через syn, обходит поля и для каждого генерирует вызов метода сериализатора. Упрощённо для структуры выходит примерно вот что:
#[derive(Serialize)]
struct User { id: u64, name: String }
// derive генерирует (по сути) такой impl:
impl Serialize for User {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let mut st = s.serialize_struct("User", 2)?; // 2 это число полей
st.serialize_field("id", &self.id)?; // по строке на поле
st.serialize_field("name", &self.name)?;
st.end()
}
}
Число полей, их имена и порядок derive узнал из DeriveInput, обойдя поля так же, как мы обходили в своём FieldNames. На enum он сгенерирует match по вариантам, на кортежных структурах обратится к полям по индексу. А #[serde(rename = "...")] рядом с полем это helper-атрибут, который derive читает и учитывает в генерации (этот механизм мы подробно разберём в следующем уроке). Никакой рефлексии в рантайме у serde нет: всё, что выглядит как «библиотека знает структуру твоего типа», это код, сгенерированный процедурным макросом на этапе компиляции. Поэтому сериализация в Rust быстрая, и компилятор её проверяет.
Что макрос видит, а чего нет
Чтобы не биться головой о стену, надо знать границы. Процедурный макрос видит синтаксис, но не смысл.
- Он видит токены: имена, типы как они написаны, атрибуты рядом. Поэтому
name: Vec<String>для него это просто токеныVec < String >, а не «вектор строк» в смысле разрешённого типа. - Он не знает разрешения имён и типов: не может спросить «а
Fooэто на самом деле какой тип, где он объявлен, реализует ли он трейт». Эта информация появляется позже, на этапе проверки типов, которая идёт уже после раскрытия. - Он не видит остальной код: только тот элемент, к которому применён. Содержимое других модулей, другие файлы ему недоступны.
- Он не видит значений: всё статично, на этапе компиляции данных рантайма ещё нет.
Отсюда типичная ошибка: ждать от макроса «понимания» типов. Если хочешь по-особому обработать поле типа Option<T>, придётся сопоставлять написание токенов (f.ty это Option < ... >), а не спрашивать у компилятора. Это работает по тексту типа и потому хрупко: std::option::Option макрос как «Option» уже не опознает. Помни: твой вход это синтаксис, а не разрешённая программа.
Правило урока
Свернём механизм в одну мысль.
Процедурный макрос это функция
fn(TokenStream) -> TokenStream, которая выполняется на этапе компиляции и содержит произвольную логику над кодом, в отличие от образца-и-подстановкиmacro_rules!. Живёт он в отдельном крейте сproc-macro = true, потому что исполняется инструментом сборки, а не твоей программой. Разбирать вход помогаетsyn(парсит токены вDeriveInputс полямиident,generics,data), собирать выход помогаетquote!(шаблон с интерполяцией#nameи развёрткой#(#items),*).#[derive(...)]ничего не меняет в типе, только дописывает код рядом, и ровно так устроенserde::Serialize: обходит поля и генерирует вызовы сериализатора. Главное ограничение: макрос видит синтаксис, а не смысл, он не знает разрешения типов и не видит остального кода.
Типичная ошибка, которую снимает урок: пытаться спросить у макроса то, чего он не знает, например «реализует ли тип этого поля трейт Display» или «какой это на самом деле тип». Макрос работает с токенами до проверки типов, поэтому единственное, на что он может опереться, это как тип написан. Если логика начинает зависеть от настоящих типов, её место не в макросе, а в сгенерированном им коде: пусть проверку сделает компилятор уже после раскрытия, через границы трейтов в том самом impl, который ты генерируешь.
ДЗ
Дальше
Ты написал свой #[derive(...)] и увидел, что за serde стоит не магия, а обход полей и генерация кода на этапе компиляции. Но derive это только один из трёх видов процедурных макросов, и самый ограниченный: он умеет лишь дописывать код рядом, не трогая аннотированный элемент. Остаются два более мощных вида: attribute-like макрос (#[my_attr]), который получает два потока токенов и может переписать элемент целиком, и function-like макрос (my_macro!(...)), своя !-форма как у vec!, но с полной логикой внутри. И ещё мы должны вернуть долг: как именно derive читает helper-атрибуты вроде #[serde(...)] и как делать точные сообщения об ошибках с подчёркиванием нужного места через span. В следующем уроке разберём всё это на примере того, как работают #[tokio::main] и derive из clap.