Раздел 23 · Rust

Процедурные макросы: derive

senior~35 мин

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

Процедурные макросы: 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.