Attribute-like и function-like макросы
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Attribute-like и function-like макросы
В прошлом уроке ты написал свой
#[derive(...)]и увидел: derive умеет только дописывать код рядом с типом, не трогая сам тип. Это самый ограниченный из трёх видов процедурных макросов. Остаются два мощнее. Attribute-like (#[my_attr]) получает два потока токенов и может переписать элемент целиком: так#[tokio::main]превращаетasync fn mainв обычную функцию с рантаймом. Function-like (my_macro!(...)) это своя!-форма, какvec!, но с полной логикой внутри: так работаетsqlx::query!, проверяющий SQL на этапе компиляции. В этом уроке мы разберём оба, вернём долг про helper-атрибуты (как derive читает#[serde(...)]), научимся делать точные сообщения об ошибках через span и закроем блок макросов честным разговором о цене.
Attribute-like: два входа и право переписать
Attribute-like макрос помечается атрибутом #[proc_macro_attribute], и его функция принимает не один, а два потока токенов:
#[proc_macro_attribute]
pub fn my_attr(attr: TokenStream, item: TokenStream) -> TokenStream {
// attr это аргументы атрибута: то, что в скобках после имени
// item это сам элемент, к которому атрибут применён
// возвращаемый TokenStream ПОЛНОСТЬЮ заменяет item
}
Первый аргумент, attr, это то, что написано в скобках самого атрибута: для #[route(GET, "/users")] это токены GET, "/users". Для #[tokio::main] без скобок он пустой. Второй аргумент, item, это весь элемент, к которому прицепили атрибут: функция, структура, модуль целиком. И главное отличие от derive: возвращённый поток заменяет исходный элемент, а не дописывается рядом. Значит, attribute-like макрос может переписать функцию, обернуть её тело, поменять сигнатуру, да хоть выкинуть элемент совсем.
Покрути стенд: переключай между attribute-like и function-like примерами и смотри, кто сколько потоков получает на входе и что отдаёт. Обрати внимание, как #[tokio::main] переписывает async fn main в синхронную функцию, а #[route(...)] показывает оба входа сразу.
Function-like: своя !-форма с полной логикой
Function-like макрос помечается #[proc_macro] и выглядит при вызове как старый знакомый vec! или println!: my_macro!(...). Но внутри это не образец-и-подстановка, а полноценный код:
#[proc_macro]
pub fn repeat(input: TokenStream) -> TokenStream {
// input это ОДИН поток: всё, что в скобках вызова
// грамматику разбираешь сам, через syn
}
В отличие от attribute-like, на вход приходит один поток токенов: всё, что стоит в скобках вызова. Зато ты волен задать там свою грамматику, какой в языке нет. Function-like процедурный макрос отличается от macro_rules! именно этим: macro_rules! ограничен сопоставлением с образцом, а тут полный Rust, можно проверять содержимое и даже лезть на файловую систему на этапе компиляции. Самый яркий пример это sqlx::query!("SELECT ..."): макрос на этапе сборки подключается к базе данных, проверяет, что SQL корректен и типы колонок совпадают, и генерирует строго типизированный код. Ошибка в запросе становится ошибкой компиляции. Так делают мини-DSL, встроенные прямо в Rust.
Три вида процедурных макросов теперь видны в ряд: derive дописывает код рядом с типом, attribute-like переписывает элемент и видит два потока, function-like вводит свою форму вызова с произвольной грамматикой.
Helper-атрибуты: как derive читает #[serde(...)]
Вернём долг из прошлого урока. Когда ты пишешь #[serde(rename = "user_id")] рядом с полем, это не отдельный макрос. Это helper-атрибут, который derive-макрос объявляет за собой в своей сигнатуре:
#[proc_macro_derive(Serialize, attributes(serde))] // регистрируем helper-атрибут serde
pub fn derive_serialize(input: TokenStream) -> TokenStream { /* ... */ }
Запись attributes(serde) говорит компилятору: «терпи атрибут #[serde(...)] рядом с полями, он мой, я его прочитаю». Сам по себе такой атрибут инертен, он ничего не делает и не вызывает кода. Его смысл целиком в том, что derive-функция при обходе полей достаёт его из field.attrs и учитывает в генерации: увидела rename = "user_id", значит, в сгенерированном serialize_field подставит именно эту строку. Так serde, clap, thiserror и десятки других крейтов настраивают поведение «по месту», прямо на поле, без отдельного синтаксиса. Помни связь: helper-атрибут существует только потому, что его зарегистрировал derive, и читается тоже только этим derive.
Span: откуда берутся хорошие ошибки
Теперь то, что отличает приятный макрос от мучительного. Когда макрос натыкается на проблему (поле не того вида, неподдерживаемый enum), он должен показать ошибку в нужном месте кода пользователя, а не где-то у себя. За «местоположение в исходнике» отвечает span: метка «какой файл, строка, столбец» на каждом токене. Именно span решает, что компилятор подчеркнёт волнистой линией.
Правильный способ сообщить об ошибке из макроса это не panic!, а построить ошибку, привязанную к нужному узлу, и превратить её в код:
use syn::{Error, spanned::Spanned};
// поймали неподдерживаемый случай: ругаемся РОВНО на этот узел
return Error::new_spanned(&field.ty, "поле этого типа пока не поддерживается")
.to_compile_error()
.into();
Error::new_spanned(node, msg) берёт span у переданного узла, а to_compile_error() превращает ошибку в TokenStream, который компилятор покажет как обычную ошибку компиляции с подчёркиванием под тем самым полем. Аналогично quote_spanned! { field.span() => ... } приклеивает нужный span к сгенерированному коду, чтобы ошибки типов в нём указывали на исходное поле, а не на потроха макроса. Разница между макросом, который пишет проверьте ваш код без указания места, и макросом, который подчёркивает конкретную строку, это целиком работа со span. Хорошая практика разобрана в статье про обработку ошибок в proc-макросах.
Разбор: #[tokio::main] и derive из clap
Сложим всё на двух примерах из реальной жизни.
#[tokio::main] это attribute-like макрос. Он берёт твою async fn main, снимает с неё async, и оборачивает тело в запуск асинхронного рантайма:
#[tokio::main]
async fn main() { work().await; }
// разворачивается примерно в:
fn main() {
tokio::runtime::Runtime::new().unwrap().block_on(async { work().await; });
}
Тут видно право attribute-like переписать элемент: async fn стала обычной fn, потому что точка входа программы не может быть асинхронной, рантайм надо где-то завести руками, и макрос делает это за тебя. Параметры вроде #[tokio::main(flavor = "current_thread")] это как раз первый поток токенов, attr, который макрос читает и подставляет в настройку рантайма.
derive из clap это derive-макрос с обильными helper-атрибутами. Ты описываешь аргументы командной строки как поля структуры:
#[derive(Parser)]
struct Args {
#[arg(short, long)] // helper-атрибут: флаг -n / --name
name: String,
#[arg(default_value_t = 1)]
count: u32,
}
#[derive(Parser)] обходит поля, читает helper-атрибуты #[arg(...)] рядом с каждым и генерирует код, который строит парсер: какой флаг, короткий или длинный, какой тип, какое значение по умолчанию. Тип поля (String, u32, Option<T>, Vec<T>) определяет, как аргумент парсится и обязателен ли он. Вся «магия» декларативного описания CLI это снова обход полей и генерация на этапе компиляции, ровно тот же механизм, что и в твоём FieldNames, только в промышленном масштабе.
Экосистема и тестируемость
Две детали, без которых проф-макросы не пишут. Первая: тип proc_macro::TokenStream живёт только внутри компилятора, его нельзя создать в обычном тесте. Поэтому вся логика опирается на его зеркало, proc-macro2: на нём построены syn и quote, и именно его TokenStream ты гоняешь в юнит-тестах, конвертируя в proc_macro::TokenStream лишь на самой границе функции. Вторая: проверять, что макрос выдаёт правильные сообщения об ошибках, помогает trybuild: он компилирует тестовые файлы и сверяет вывод компилятора с эталоном, так фиксируют, что на плохой вход макрос ругается именно так, как задумано. Хороший разбор организации макро-крейта как пайплайна «разбор, анализ, генерация» с тестами на каждом этапе лежит в воркшопе dtolnay.
Частый сценарий для attribute-like макроса это распределённая регистрация. Ставишь над функцией атрибут вроде #[command] или #[test_case], а макрос разворачивает его в запись, которую крейт inventory кладёт в общую линкер-секцию. Тогда главная программа собирает все такие записи в один реестр, не называя ни одного поставщика по имени: так устроены реестры тестов, бенчмарков и плагинов. Макрос тут лишь прячет ручную регистрацию за коротким атрибутом. Зачем выполнять этот код ещё до main и какова цена приёма, разбирает урок про внедрение зависимостей.
Правило урока
Свернём весь блок процедурных макросов в одну мысль.
Процедурные макросы бывают трёх видов. derive (
#[derive]) дописывает код рядом с типом. Attribute-like (#[proc_macro_attribute]) получает два потока, аргументы атрибута и сам элемент, и может переписать элемент целиком, как#[tokio::main]. Function-like (#[proc_macro]) вводит свою!-форму с произвольной грамматикой, какsqlx::query!. Настройки «по месту» derive читает через helper-атрибуты, объявленные вattributes(...). Точные сообщения об ошибках это работа со span:Error::new_spanned(node, msg).to_compile_error()подчёркивает нужное место. Логику строят наproc-macro2, чтобы тестировать вне компилятора, а диагностики проверяют черезtrybuild.
Типичная ошибка, которую снимает урок, и заодно граница злоупотребления. Процедурный макрос это самый тяжёлый инструмент в языке: он добавляет крейт-зависимость (syn тянет ощутимое время компиляции), слабо дружит с IDE, и читающий вызов не видит, что тот порождает. Поэтому правило простое: сначала функция или дженерик, потом macro_rules!, и только если и того не хватает, процедурный макрос. Он на месте, когда нужна логика над синтаксисом многих типов сразу (derive для целой библиотеки), переписывание элемента (#[tokio::main]) или встроенная DSL с проверкой на этапе компиляции (sqlx::query!). Если же ты тянешься к проц-макросу, чтобы сэкономить пару строк в одном месте, это почти наверняка злоупотребление: цена в сборке и читаемости не окупится.
ДЗ
Дальше
Блок макросов закрыт. Ты прошёл путь от macro_rules! (образец и подстановка по токенам) через гигиену и рекурсию к процедурным макросам всех трёх видов: derive, attribute-like и function-like. Главный вывод не в синтаксисе, а в иерархии инструментов: дженерик обобщает значения, macro_rules! обобщает синтаксис по образцу, а процедурный макрос запускает на этапе компиляции настоящую программу над кодом. Каждый следующий уровень мощнее и дороже, и зрелость инженера в том, чтобы брать самый слабый из достаточных. Дальше в разделе начинается первый большой проект на Rust, эмулятор учебного процессора с аппаратным слоем абстракции, где всё накопленное (биты и память, трейты, unsafe, а теперь и макросы для генерации таблицы инструкций) сойдётся в одном работающем коде.