Как написать пользовательский макрос derive
Создадим крейт под названием hello_macro
, в котором определим трейт под названием HelloMacro
с одной ассоциированной функцией под названием hello_macro
. Вместо того, чтобы заставлять наших пользователей реализовывать трейт HelloMacro
для каждого их типа, мы предоставим процедурный макрос, чтобы пользователи могли аннотировать свой тип с помощью #[derive(HelloMacro)]
, чтобы получить стандартную реализацию функции hello_macro
. Стандартная реализация будет выводить Hello, Macro! My name is
TypeName!
, где TypeName - это имя типа, для которого определен этот трейт. Другими словами, мы напишем крейт, который позволит другому программисту написать код, подобный Листингу 19-30, используя наш крейт.
Filename: src/main.rs
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;
#[derive(HelloMacro)]
struct Pancakes;
fn main() {
Pancakes::hello_macro();
}
Листинг 19-30: Код, который сможет написать пользователь нашего крейта, используя наш процедурный макрос
Этот код выведет Hello, Macro! My name is Pancakes!
, когда мы закончим. Первым шагом будет создать новый библиотечный крейт, как это:
cargo new hello_macro --lib
Далее мы определим трейт HelloMacro
и его ассоциированную функцию:
Filename: src/lib.rs
pub trait HelloMacro {
fn hello_macro();
}
У нас есть трейт и его функция. На этом этапе пользователь нашего крейта мог бы реализовать трейт, чтобы достичь желаемой функциональности, вот так:
use hello_macro::HelloMacro;
struct Pancakes;
impl HelloMacro for Pancakes {
fn hello_macro() {
println!("Hello, Macro! My name is Pancakes!");
}
}
fn main() {
Pancakes::hello_macro();
}
Однако они должны были бы написать блок реализации для каждого типа, который они хотели бы использовать с hello_macro
; мы хотим избавить их от этой работы.
此外, мы еще не можем предоставить функцию hello_macro
с стандартной реализацией, которая будет выводить имя типа, для которого реализован трейт: Rust не имеет возможностей рефлексии, поэтому не может определить имя типа во время выполнения. Нам нужен макрос, чтобы сгенерировать код во время компиляции.
Следующим шагом будет определить процедурный макрос. На момент написания этой статьи процедурные макросы должны находиться в отдельном крейте. Возможно, в будущем это ограничение будет снято. Конвенция по структурированию крейтов и крейтов с макросами следующая: для крейта с именем foo пользовательский процедурный макрос derive
называется foo_derive
. Давайте создадим новый крейт под названием hello_macro_derive
внутри нашего проекта hello_macro
:
cargo new hello_macro_derive --lib
Наши два крейта тесно связаны, поэтому мы создаем крейт с процедурным макросом внутри директории нашего крейта hello_macro
. Если мы изменим определение трейта в hello_macro
, мы также должны изменить реализацию процедурного макроса в hello_macro_derive
. Два крейта будут необходимо опубликовать отдельно, и программисты, использующие эти крейты, должны будут добавить оба в качестве зависимостей и импортировать их в область видимости. Вместо этого мы могли бы заставить крейт hello_macro
использовать hello_macro_derive
в качестве зависимости и переэкспортировать код процедурного макроса. Однако, структура нашего проекта позволяет программистам использовать hello_macro
, даже если они не нуждаются в функциональности derive
.
Мы должны объявить крейт hello_macro_derive
как крейт с процедурным макросом. Мы также будем использовать функциональность из крейтов syn
и quote
, как вы вскоре увидите, поэтому нам нужно добавить их в качестве зависимостей. Добавьте следующее в файл Cargo.toml
для hello_macro_derive
:
Filename: hello_macro_derive/Cargo.toml
[lib]
proc-macro = true
[dependencies]
syn = "1.0"
quote = "1.0"
Для начала определения процедурного макроса поместите код из Листинга 19-31 в файл src/lib.rs
для крейта hello_macro_derive
. Обратите внимание, что этот код не скомпилируется, пока мы не добавим определение функции impl_hello_macro
.
Filename: hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn;
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree
// that we can manipulate
let ast = syn::parse(input).unwrap();
// Build the trait implementation
impl_hello_macro(&ast)
}
Листинг 19-31: Код, который требуется большинству крейтов с процедурными макросами для обработки кода Rust
Обратите внимание, что мы разделили код на функцию hello_macro_derive
, которая отвечает за разбор TokenStream
, и функцию impl_hello_macro
, которая отвечает за преобразование синтаксического дерева: это делает написание процедурного макроса более удобным. Код в внешней функции (в этом случае hello_macro_derive
) будет одинаковым для почти каждого крейта с процедурным макросом, который вы увидите или создадите. Код, который вы укажете в теле внутренней функции (в этом случае impl_hello_macro
), будет различаться в зависимости от назначения вашего процедурного макроса.
Мы представили три новых крейта: proc_macro
, syn
(доступен по адресу https://crates.io/crates/syn), и quote
(доступен по адресу https://crates.io/crates/quote). Крейт proc_macro
входит в состав Rust, поэтому нам не нужно добавлять его в зависимости в Cargo.toml
. Крейт proc_macro
- это API компилятора, которое позволяет нам читать и манипулировать кодом Rust из нашего кода.
Крейт syn
разбирает код Rust из строки в структуру данных, с которой мы можем производить операции. Крейт quote
превращает структуры данных syn
обратно в код Rust. Эти крейты значительно упрощают процесс разбора любого вида кода Rust, который мы хотим обработать: написание полного парсера для кода Rust - это не простая задача.
Функция hello_macro_derive
будет вызываться, когда пользователь нашего библиотеки укажет #[derive(HelloMacro)]
для типа. Это возможно, потому что мы аннотировали функцию hello_macro_derive
здесь с помощью proc_macro_derive
и указали имя HelloMacro
, которое соответствует нашему имени трейта; это соглашение, которое следуют большинство процедурных макросов.
Функция hello_macro_derive
сначала преобразует input
из TokenStream
в структуру данных, которую мы затем можем интерпретировать и на которой производить операции. Именно здесь на сцену вступает syn
. Функция parse
в syn
принимает TokenStream
и возвращает структуру DeriveInput
, представляющую разобранный код Rust. Листинг 19-32 показывает соответствующие части структуры DeriveInput
, которую мы получаем при разборе строки struct Pancakes;
.
DeriveInput {
--snip--
ident: Ident {
ident: "Pancakes",
span: #0 bytes(95..103)
},
data: Struct(
DataStruct {
struct_token: Struct,
fields: Unit,
semi_token: Some(
Semi
)
}
)
}
Листинг 19-32: Экземпляр DeriveInput
, который мы получаем при разборе кода, имеющего атрибут макроса в Листинге 19-30
Поля этой структуры показывают, что разобранный нами код Rust - это единичная структура с ident
(идентификатором, то есть именем) Pancakes
. На этой структуре есть и другие поля для описания всех видов кода Rust; проверьте документацию syn
для DeriveInput
по адресу https://docs.rs/syn/1.0/syn/struct.DeriveInput.html для получения дополнительной информации.
Вскоре мы определим функцию impl_hello_macro
, где мы будем создавать новый код Rust, который мы хотим включить. Но перед этим обратите внимание, что выходом для нашего макроса derive
также является TokenStream
. Возвращаемый TokenStream
добавляется к коду, который пишут пользователи нашего крейта, поэтому при компиляции их крейта они получат дополнительную функциональность, которую мы предоставляем в модифицированном TokenStream
.
Вы, возможно, заметили, что мы вызываем unwrap
, чтобы заставить функцию hello_macro_derive
завершиться с ошибкой, если вызов функции syn::parse
здесь завершится неудачно. Необходимо, чтобы наш процедурный макрос завершался с ошибкой при возникновении ошибок, потому что функции proc_macro_derive
должны возвращать TokenStream
, а не Result
, чтобы соответствовать API процедурного макроса. Мы упростили этот пример, используя unwrap
; в продакшен-коде вы должны предоставлять более конкретные сообщения об ошибках о том, что пошло не так, используя panic!
или expect
.
Теперь, когда у нас есть код для преобразования аннотированного кода Rust из TokenStream
в экземпляр DeriveInput
, давайте сгенерируем код, который реализует трейт HelloMacro
для аннотированного типа, как показано в Листинге 19-33.
Filename: hello_macro_derive/src/lib.rs
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let gen = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!(
"Hello, Macro! My name is {}!",
stringify!(#name)
);
}
}
};
gen.into()
}
Листинг 19-33: Реализация трейта HelloMacro
с использованием разобранного кода Rust
Мы получаем экземпляр структуры Ident
, содержащий имя (идентификатор) аннотированного типа, используя ast.ident
. Структура в Листинге 19-32 показывает, что при запуске функции impl_hello_macro
на коде из Листинга 19-30 ident
, который мы получим, будет иметь поле ident
со значением "Pancakes"
. Таким образом, переменная name
в Листинге 19-33 будет содержать экземпляр структуры Ident
, который, при выводе, будет строкой "Pancakes"
, именем структуры в Листинге 19-30.
Макрос quote!
позволяет нам определить код Rust, который мы хотим вернуть. Компилятор ожидает etwas другое, чем прямой результат выполнения макроса quote!
, поэтому нам нужно преобразовать его в TokenStream
. Мы это делаем, вызывая метод into
, который потребляет это промежуточное представление и возвращает значение нужного типа TokenStream
.
Макрос quote!
также предоставляет очень крутые механизмы шаблонизации: мы можем ввести #name
, и quote!
заменит его значением переменной name
. Вы даже можете сделать некоторую повторяемость, подобную тому, как работают обычные макросы. Проверьте документацию по крейту quote
по адресу https://docs.rs/quote для более подробного ознакомления.
Мы хотим, чтобы наш процедурный макрос генерировал реализацию нашего трейта HelloMacro
для типа, который пользователь аннотировал, что мы можем получить, используя #name
. Реализация трейта имеет одну функцию hello_macro
, тело которой содержит функциональность, которую мы хотим предоставить: вывод "Hello, Macro! My name is"
и затем имя аннотированного типа.
Макрос stringify!
, используемый здесь, встроен в Rust. Он принимает выражение Rust, такое как 1 + 2
, и во время компиляции преобразует выражение в строковый литерал, такой как "1 + 2"
. Это отличается от макросов format!
или println!
, которые вычисляют выражение и затем преобразуют результат в String
. Возможно, что вход #name
может быть выражением, которое нужно вывести буквально, поэтому мы используем stringify!
. Использование stringify!
также экономит память, преобразуя #name
в строковый литерал во время компиляции.
На этом этапе cargo build
должен успешно завершиться в обоих крейтах hello_macro
и hello_macro_derive
. Присоединим эти крейты к коду из Листинга 19-30, чтобы увидеть, как работает процедурный макрос! Создайте новый бинарный проект в директории project
с помощью cargo new pancakes
. Мы должны добавить hello_macro
и hello_macro_derive
в качестве зависимостей в Cargo.toml
крейта pancakes
. Если вы публикуете свои версии hello_macro
и hello_macro_derive
на https://crates.io, они будут обычными зависимостями; если нет, вы можете указать их в качестве зависимостей по пути, как показано ниже:
[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
Вставьте код из Листинга 19-30 в src/main.rs
и запустите cargo run
: должно быть выведено Hello, Macro! My name is Pancakes!
Реализация трейта HelloMacro
из процедурного макроса была включена, не требуя от pancakes
крейта ее реализации; атрибут #[derive(HelloMacro)]
добавил реализацию трейта.
Далее давайте рассмотрим, в чем отличаются другие виды процедурных макросов от пользовательских макросов derive
.