Разбор внутренней работы и авторства макросов в Rust
Макросы Rust — это конструкции времени компиляции, которые работают с потоками токенов языка Rust.
Коротко о компиляции
Что такое «токены языка Rust»?
Когда компилятор начинает компилировать программу, он сначала читает файл исходного кода. Для простоты предположим, что компилятор хранит этот исходный код в строке. Следующий шаг — пройтись по строке, символ за символом, и разделить ее на «токены».
Например, фрагмент Rust, например:
let foo: u32 = 30;
Может быть «токенизирован» в:
[ KeywordLet, Identifier("foo"), Colon, Identifier("u32"), SingleEquals, NumericLiteral("30"), Semicolon, ]
(Обратите внимание, что это полностью воображаемый пример.)
Макрос принимает поток токенов, аналогичный приведенному выше, в качестве входных данных, а также выводит поток токенов. Это имеет некоторые важные последствия:
- Макросы Rust могут добавлять новый код: добавлять реализацию типажа, создавать новую структуру, писать новую функцию и т. д.
- Макросы Rust не могут взаимодействовать с логикой в коде (например, посмотреть, реализует ли тип трейт, вызвать функцию, объявленную в исходном коде, и т. д.), потому что логика на самом деле еще не создана.
Есть две основные категории макросов Rust: декларативные макросы и процедурные макросы.
Декларативные макросы
Декларативные макросы можно объявлять и использовать вместе с другим кодом. Они объявляются с использованием специальной конструкции macro_rules!
и имеют уникальный синтаксис:
macro_rules! my_macro { ($a: ident => $b: expr) => { fn $a() { println!("{}", $b); } }; ($a: ident, $b: expr) => { println!("{} {}", $a, $b); }; }
Декларативные макросы принимают токены Rust в качестве входных данных и выполняют сопоставление с ними шаблонов. В приведенном выше примере макрос my_macro
соответствует двум разным шаблонам:
- Идентификатор и выражение, разделенные жирной стрелкой
=>
, и - Идентификатор и выражение, разделенные запятой
,
.
Этот макрос может быть вызван следующим образом:
my_macro!(foo, 45); my_macro!(bar => "hello"); my_macro!(baz => 9 * 8);
Вложенные макросы и рекурсия
Один из самых популярных крейтов Rust, serde_json
, включает декларативный макрос json!()
, который позволяет вам писать JSON-подобный синтаксис в коде Rust. Он возвращает serde_json::Value
.
json!({ "id": 42, "name": { "first": "John", "last": "Zoidberg", }, });
Как оказалось, вы можете указать любое допустимое выражение Rust (которое дает значение, реализующее Serialize
) в качестве значения:
json!({ "id": 21 + 21, // Computed expression "name": { "first": "John", "last": "Zoidberg", }, });
И я имею в виду любое допустимое выражение Rust…
json!({ "id": 21 + 21, "name": json!({ // This is another macro invocation! "first": "John", "last": "Zoidberg", }), });
Эта возможность распространяется и на код, который генерирует ваш макрос. Например, мы можем написать базовый синтаксический анализатор, который рекурсивно переводит логические символы для И (∧; ^
в коде) и ИЛИ (∨; v
в коде) в эквиваленты Rust.
macro_rules! andor { ($a: ident ^ $b: ident $($tail: tt)*) => { $a && andor!($b $($tail)*) // Recursive invocation }; ($a: ident v $b: ident $($tail: tt)*) => { $a || andor!($b $($tail)*) // Recursive invocation }; ($($a: tt)*) => { $($a)* } } andor!(true ^ false v false ^ true) // true && false || false && true // => false
Поскольку это потенциально бесконечная операция, рекурсия макроса имеет максимальную глубину, определяемую компилятором Rust.
Процедурные макросы
Процедурные макросы записываются с использованием обычного кода Rust (не уникальный синтаксис), который компилируется, а затем запускается компилятором при вызове. По этой причине процедурные макросы также иногда называют «плагинами компилятора».
Поскольку крейты = единицы компиляции, для того, чтобы процедурный макрос был скомпилирован до его выполнения, процедурные макросы должны быть определены (и впоследствии экспортированы) в крейте, отличном от того, в котором они используются. Это должны быть библиотечные ящики со следующим в Cargo.toml
:
[lib] proc-macro = true
Процедурные макросы представлены в трех формах, каждая из которых вызывается по-разному:
- Подобный атрибуту.
Ввод: аннотированный элемент.
Вывод заменяет ввод. (Исходный ввод не существует в конечном потоке маркеров.)
#[my_attribute_macro] struct MyStruct; // This struct is the input to the macro struct AnotherStruct; // This struct is not part of the macro's input
- Пользовательское производное.
Ввод: аннотированный элемент.
Вывод добавляется к вводу. (Исходный ввод все еще существует в конечном потоке маркеров.)
#[derive(MyDeriveMacro)] struct MyStruct; // This struct is the input to the macro struct AnotherStruct; // This struct is not part of the macro's input
- Подобно функциям.
Ввод: закрытый поток токенов. Разделителями являются[]
,{}
или()
.
Вывод заменяет ввод. (Исходный ввод не существует в конечном потоке маркеров.)
my_function_like_macro!(arbitrary + token : stream 00); // is the same as my_function_like_macro![arbitrary + token : stream 00]; // is the same as my_function_like_macro!{arbitrary + token : stream 00};
В этом посте я расскажу о написании атрибутов и получении макросов.
Создание процедурных макросов
На первый взгляд, написание процедурного макроса с нуля может быть действительно сложной задачей:
use proc_macro::TokenStream; #[proc_macro_attribute] pub fn my_attribute_macro(attr: TokenStream, item: TokenStream) -> TokenStream { todo!("Good luck!") }
Этот макрос можно вызвать следующим образом:
#[my_attribute_macro] struct AnnotatedItem;
В этом случае поток токенов attr
будет пустым, а поток токенов item
будет содержать структуру AnnotatedItem
.
Если вы вызываете макрос следующим образом:
#[my_attribute_macro(attribute_tokens)] fn my_function() {}
В этом случае поток токенов attr
будет содержать attribute_tokens
, а поток токенов item
будет содержать функцию my_function
.
Прохладный. У нас настроена базовая инфраструктура, теперь мы просто анализируем потоки входных токенов.
Компилятор Rust еще не был настолько любезен, чтобы создать для нас синтаксическое дерево. Мы просто получаем поток токенов, и нам нужно каким-то образом разобрать его на что-то разумное (например, определение структуры, блок impl
и т. д.), каким-то образом манипулировать им, а затем синтезировать вывод, который компилятор может понять как действительный. код.
Это много работы!
Введите: syn
и quote
.
Взаимодействие с компилятором
syn
и quote
— это пара крейтов, которые упрощают манипулирование потоком токенов. syn
предоставляет утилиты для разбора потоков токенов в синтаксические деревья, а quote
— для преобразования Rust-подобного кода обратно в потоки токенов.
Основные варианты использования каждого из этих ящиков чрезвычайно просты — это очень хорошо спроектированные ящики!
Вот простой макрос атрибута, использующий syn
и quote
, который абсолютно ничего не делает (он возвращает свой ввод):
use proc_macro::TokenStream; use syn::{parse_macro_input, AttributeArgs, Item}; use quote::quote; #[proc_macro_attribute] pub fn my_attribute_macro(attr: TokenStream, item: TokenStream) -> TokenStream { let _attr = parse_macro_input!(attr as AttributeArgs); let item = parse_macro_input!(item as Item); quote!{ #item }.into() }
Макрос parse_macro_input
пытается преобразовать TokenStream
в структуру данных syn
и в случае неудачи выдает ошибку компилятора. Структуры данных и документация syn
заслуживают самостоятельного изучения. Они дадут вам довольно хорошее представление о том, как может выглядеть синтаксическое дерево.
Item
, проанализированное в приведенном выше примере, является перечислением, которое вы можете match
противопоставить:
match item { // The annotated item was parsed as... Item::Enum(e) => {}, // ...an enum Item::Fn(f) => {}, // ...a function Item::Impl(i) => {}, // ...an impl block Item::Struct(s) => {}, // ...a struct // ...and so on and so forth _ => todo!(), }
Макрос quote
создает proc_macro2::TokenStream
(которое можно легко преобразовать в обычное proc_macro::TokenStream
с помощью Into::into
) из некоторого ввода, похожего на код Rust. Он также поддерживает интерполяцию переменных с помощью синтаксиса #identifier
, показанного выше.
В мире авторов макросов syn
и quote
довольно распространены. Вот пример использования syn
и quote
в популярном ящике thiserror
.
Параметризация и конфигурация
Последний инструмент в нашем списке для создания поддерживаемых и полезных макросов — darling
. Заявленное описание ящика:
Библиотека proc-macro для чтения атрибутов в структурах при реализации пользовательских производных файлов.
Однако это полезно как для пользовательских производных макросов, так и для макросов атрибутов. syn
и quote
полезны для анализа и манипулирования потоками обычных токенов Rust, а darling
полезно для анализа входных потоков атрибутов и элементов в пользовательские структуры, присоединения пользовательской логики к процессу и сообщения об ошибках, что делает комбинацию этих трех ящиков мощной структурой. для авторства процедурных макросов.
Практический пример
Вот пример очень простого макроса вывода, использующего все три крейта, вместе с обработкой ошибок, необязательным параметром конфигурации и некоторыми автопереадресованными полями darling
(data
, generics
, ident
, fields
).
use darling::{FromDeriveInput, FromVariant}; use proc_macro2::TokenStream as TokenStream2; use quote::quote; use syn::{parse_macro_input, DeriveInput, Path}; #[derive(Debug, FromDeriveInput)] // The struct will be deserialized from a `#[display]` attribute on any kind of enum #[darling(attributes(display), supports(enum_any))] struct EnumMeta { // Try to optionally deserialize an item path pub transform: Option<Path>, // Forwarded attributes pub ident: syn::Ident, pub generics: syn::Generics, pub data: darling::ast::Data<VariantVisitor, ()>, } #[derive(Debug, FromVariant)] struct VariantVisitor { // The name of the enum variant pub ident: syn::Ident, pub fields: darling::ast::Fields<()>, } fn expand(meta: EnumMeta) -> Result<TokenStream2, darling::Error> { let EnumMeta { transform, data, generics, ident, } = meta; let variants = data.take_enum().unwrap(); let match_arms = variants.iter().map(|variant| { let i = &variant.ident; let name = i.to_string(); match variant.fields.style { darling::ast::Style::Tuple => { quote! { Self :: #i ( .. ) => #name , } } darling::ast::Style::Struct => { quote! { Self :: #i { .. } => #name , } } darling::ast::Style::Unit => { quote! { Self :: #i => #name , } } } }); // Properly includes generics in output let (imp, ty, wher) = generics.split_for_impl(); // Rust code output Ok(quote! { impl #imp std::fmt::Display for #ident #ty #wher { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", #transform ( match self { #(#match_arms)* } )) } } }) } // Declares the name of the macro and the attributes it supports #[proc_macro_derive(Display, attributes(display))] pub fn derive_display(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let input = parse_macro_input!(input as DeriveInput); FromDeriveInput::from_derive_input(&input) .and_then(expand) .map(Into::into) // Error handling .unwrap_or_else(|e| e.write_errors().into()) }
Этот код также доступен на GitHub.
Этот производный макрос создает реализацию Display
в целевом перечислении. Опционально он принимает поле атрибута transform
, которое представляет собой путь к функции, преобразующей имя варианта перед его записью.
Дальнейшее чтение
- Глава «Макросы в The Rust Book»
- Маленькая книга макросов Rust
- мастерская dtolnay по процедурным макросам
- Rust AST Explorer (кредит: matklad)
Я инженер-программист NEAR Protocol, руководитель отдела образования Blockchain Acceleration Foundation и аспирант Токийского технологического института.
Свяжитесь со мной в Твиттере.
Поправка от матклад:
Процесс оценки макросов… запутан, правильнее будет сказать, что «в компиляторе Rust синтаксический анализ, разрешение имен и раскрытие макросов являются взаимно рекурсивными процедурами, которые происходят одновременно». К счастью, я думаю, что для целей этого поста нам не нужно объяснять, когда происходит расширение макроса, достаточно сказать: «токены — это то, что используется в качестве ввода или вывода макроса. Макросы не имеют прямого доступа к проанализированному AST, но макрос может сам анализировать входные токены».
Первоначально опубликовано на https://geeklaunch.io.