Макроси
Ми використовували макроси, такі як println!, протягом усієї цієї книги, але ще не повністю
розглянули, що таке макрос і як він працює. Термін macro означає сімейство
можливостей у Rust — декларативні макроси з macro_rules! і три види
процедурних макросів:
- Custom
#[derive]макроси, які визначають код, що додається за допомогою атрибутаderive, використаного на структурах і переліках - Макроси, схожі на атрибути, які визначають custom атрибути, придатні для використання на будь-якому item
- Макроси, схожі на функції, які виглядають як виклики функцій, але працюють із токенами, зазначеними як їхній аргумент
Ми поговоримо про кожен із них по черзі, але спочатку подивімося, навіщо нам узагалі потрібні макроси, якщо в нас уже є функції.
Різниця між макросами та функціями
У своїй основі макроси — це спосіб писати код, який пише інший код, що відомо як метапрограмування. В Додатку C ми обговорюємо атрибут derive,
який генерує реалізацію різних трейтів для вас. Ми також використовували макроси println! і vec! протягом усієї книги. Усі ці
макроси розгортаються, щоб створювати більше коду, ніж код, який ви написали вручну.
Метапрограмування корисне для зменшення обсягу коду, який вам доводиться писати й підтримувати, що також є однією з ролей функцій. Однак макроси мають деякі додаткові можливості, яких немає у функцій.
Сигнатура функції має оголошувати кількість і тип параметрів, які має
функція. Макроси, з іншого боку, можуть приймати змінну кількість
параметрів: Ми можемо викликати println!("hello") з одним аргументом або
println!("hello {}", name) з двома аргументами. Крім того, макроси розгортаються
до того, як компілятор інтерпретує значення коду, тож макрос може, наприклад,
реалізувати трейт для заданого типу. Функція не може цього зробити, тому що вона
викликається під час виконання, а трейт має бути реалізований під час компіляції.
Недолік реалізації макросу замість функції полягає в тому, що визначення макросів складніші за визначення функцій, тому що ви пишете код Rust, який пише код Rust. Через цю непрямість визначення макросів загалом важче читати, розуміти й підтримувати, ніж визначення функцій.
Ще одна важлива різниця між макросами та функціями полягає в тому, що ви маєте визначити макроси або імпортувати їх у область видимості до того, як ви викличете їх у файлі, на відміну від функцій, які ви можете визначити будь-де і викликати будь-де.
Декларативні макроси для загального метапрограмування
Найпоширенішою формою макросів у Rust є декларативний макрос. Їх також іноді називають “macros by example”, “macro_rules! macros,”
або просто “macros.” У своїй основі декларативні макроси дають змогу писати
щось подібне до виразу Rust match. Як обговорювалося в Главі 6,
вирази match — це керувальні структури, які приймають вираз, порівнюють
результатний значення виразу зі зразками, а потім виконують код, пов’язаний
із відповідним зразком. Макроси також порівнюють значення зі зразками, які
пов’язані з певним кодом: У цій ситуації значенням є буквальний вихідний код Rust, переданий
макросу; зразки порівнюються зі структурою цього вихідного коду; а код, пов’язаний
із кожним зразком, коли він збігається, замінює код, переданий макросу. Усе це відбувається під час
компіляції.
Щоб визначити макрос, ви використовуєте конструкцію macro_rules!. Дослідимо, як
використовувати macro_rules!, розглянувши, як визначено макрос vec!. У Главі 8
було описано, як ми можемо використовувати макрос vec! для створення нового вектора з певними значеннями.
Наприклад, такий макрос створює новий вектор, що містить три
цілі числа:
#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}
Ми також могли б використовувати макрос vec!, щоб створити вектор із двох цілих чисел або вектор
із п’яти рядкових зрізів. Ми не змогли б використати функцію для того самого,
тому що наперед ми не знали б кількість або тип значень.
Список 20-35 показує дещо спрощене визначення макросу vec!.
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
Примітка: Справжнє визначення макросу
vec!у стандартній бібліотеці містить код для попереднього виділення правильної кількості пам’яті наперед. Цей код є оптимізацією, яку ми тут не включаємо, щоб зробити приклад простішим.
Позначка #[macro_export] вказує, що цей макрос має бути
доступним щоразу, коли крейт, у якому визначено макрос, потрапляє в область видимості.
Без цієї позначки макрос не можна імпортувати в область видимості.
Потім ми починаємо визначення макросу з macro_rules! і назви
макросу, який ми визначаємо, без знака оклику. За назвою, у цьому випадку
vec, ідуть фігурні дужки, що позначають тіло визначення макросу.
Структура в тілі vec! подібна до структури виразу match.
Тут у нас є одна гілка зі зразком ( $( $x:expr ),* ),
за якою слідує => і блок коду, пов’язаний із цим зразком. Якщо
зразок збігається, пов’язаний блок коду буде згенерований. Оскільки це
єдиний зразок у цьому макросі, існує лише один дійсний спосіб зіставлення; будь-який
інший зразок призведе до помилки. Більш складні макроси матимуть більше ніж
одну гілку.
Синтаксис допустимих зразків у визначеннях макросів відрізняється від синтаксису зразків, розглянутого в Главі 19, тому що зразки макросів зіставляються зі структурою коду Rust, а не зі значеннями. Давайте розберемо, що означають елементи зразка в Списку 20-29; для повного синтаксису зразків макросів див. Rust Reference.
Спочатку ми використовуємо набір круглих дужок, щоб охопити весь зразок. Ми використовуємо
знак долара ($), щоб оголосити змінну в системі макросів, яка міститиме
код Rust, що відповідає зразку. Знак долара чітко показує, що це
змінна макросу, на відміну від звичайної змінної Rust. Далі йде набір
круглих дужок, який захоплює значення, що відповідають зразку всередині дужок,
для використання в коді заміни. Усередині $() міститься $x:expr, який відповідає будь-якому
виразу Rust і дає виразу ім’я $x.
Кома після $() вказує, що між кожним екземпляром коду, який відповідає коду в $(),
має з’являтися буквальний символ коми-роздільника.
* визначає, що зразок відповідає нулю або більше будь-чого, що стоїть
перед *.
Коли ми викликаємо цей макрос із vec![1, 2, 3];, зразок $x
збігається тричі з трьома виразами 1, 2 і 3.
Тепер подивімося на зразок у тілі коду, пов’язаного з цією гілкою:
temp_vec.push() у межах $()* генерується для кожної частини, що відповідає $()
у зразку, нуль або більше разів залежно від того, скільки разів
зразок збігається. $x замінюється кожним зіставленим виразом. Коли ми викликаємо цей
макрос із vec![1, 2, 3];, згенерований код, який замінює цей виклик
макросу, буде таким:
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}
Ми визначили макрос, який може приймати будь-яку кількість аргументів будь-якого типу і може генерувати код для створення вектора, що містить вказані елементи.
Щоб дізнатися більше про те, як писати макроси, зверніться до онлайн-документації або інших ресурсів, таких як “The Little Book of Rust Macros”, започаткованої Daniel Keep і продовженої Lukas Wirth.
Процедурні макроси для генерації коду з атрибутів
Друга форма макросів — це процедурний макрос, який поводиться більше як
функція (і є типом процедури). Процедурні макроси приймають певний код як вхідні дані,
опрацьовують цей код і видають певний код як вихідні дані замість
зіставлення зі зразками та заміни коду іншим кодом, як це роблять декларативні
макроси. Три види процедурних макросів — custom derive,
attribute-like і function-like, і всі працюють подібним чином.
Під час створення процедурних макросів визначення мають розташовуватися у власному крейті
зі спеціальним типом крейту. Це зумовлено складними технічними причинами, які ми сподіваємося
усунути в майбутньому. У Списку 20-36 ми показуємо, як визначити
процедурний макрос, де some_attribute є заповнювачем для використання конкретного
виду макросу.
use proc_macro::TokenStream;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
Функція, яка визначає процедурний макрос, приймає TokenStream як вхідні
дані та видає TokenStream як вихідні дані. Тип TokenStream визначено
крейтом proc_macro, який постачається разом із Rust і представляє послідовність
токенів. Це основа макросу: вихідний код, над яким макрос
працює, утворює вхідний TokenStream, а код, який макрос породжує,
є вихідним TokenStream. Функція також має прикріплений до неї атрибут,
який вказує, який саме вид процедурного макросу ми створюємо. У нас може бути
кілька видів процедурних макросів в одному крейті.
Давайте подивимося на різні види процедурних макросів. Ми почнемо з
custom derive макросу, а потім пояснимо невеликі відмінності, які роблять
інші форми іншими.
Як писати custom derive макрос
Створімо крейт під назвою hello_macro, який визначає трейт під назвою
HelloMacro з однією асоційованою функцією під назвою hello_macro. Замість того, щоб
змушувати наших користувачів реалізовувати трейт HelloMacro для кожного зі своїх типів,
ми надамо процедурний макрос, щоб користувачі могли анотувати свій тип за допомогою
#[derive(HelloMacro)], щоб отримати стандартну реалізацію
функції hello_macro. Стандартна реалізація виводитиме Hello, Macro! My name is TypeName!, де TypeName — це назва типу, на якому було
визначено цей трейт. Іншими словами, ми напишемо крейт, який дає змогу іншому
програмісту писати код на кшталт Списку 20-37, використовуючи наш крейт.
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;
#[derive(HelloMacro)]
struct Pancakes;
fn main() {
Pancakes::hello_macro();
}
Коли ми закінчимо, цей код виводитиме Hello, Macro! My name is Pancakes!. Перший
крок — створити новий бібліотечний крейт ось так:
$ cargo new hello_macro --lib
Далі, у Списку 20-38, ми визначимо трейт HelloMacro і його асоційовану
функцію.
pub trait HelloMacro {
fn hello_macro();
}
У нас є трейт і його функція. На цьому етапі користувач нашого крейту міг би реалізувати цей трейт, щоб досягти бажаної функціональності, як у Списку 20-39.
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 custom 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:
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
Щоб почати визначати процедурний макрос, помістіть код зі Списку 20-40 у
ваш файл src/lib.rs для крейту hello_macro_derive. Зверніть увагу, що цей код не
скомпілюється, доки ми не додамо визначення функції impl_hello_macro.
use proc_macro::TokenStream;
use quote::quote;
#[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)
}
Зверніть увагу, що ми розділили код на функцію hello_macro_derive, яка
відповідає за розбір TokenStream, і функцію impl_hello_macro, яка
відповідає за перетворення синтаксичного дерева: це робить написання процедурного макросу
зручнішим. Код у зовнішній функції (у цьому випадку hello_macro_derive) буде таким самим майже для кожного
крейту процедурного макросу, який ви побачите або створите. Код, який ви вкажете в тілі
внутрішньої функції (у цьому випадку impl_hello_macro), буде іншим
залежно від призначення вашого процедурного макросу.
Ми ввели три нові крейти: proc_macro, syn,
і 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. Список 20-41 показує відповідні частини структури 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
)
}
)
}
Поля цієї структури показують, що розібраний нами код Rust є unit-структурою
з ident (identifier, тобто ім’ям) Pancakes. У цій структурі є ще поля
для опису всіх видів коду Rust; для отримання додаткової інформації див. syn
документацію для DeriveInput.
Незабаром ми визначимо функцію impl_hello_macro, де ми побудуємо
новий код Rust, який хочемо включити. Але перед тим, як це зробити, зауважте, що вихід
нашого макросу derive також є TokenStream. Повернений TokenStream додається
до коду, який пишуть користувачі нашого крейту, тож коли вони компілюватимуть свій крейт,
вони отримають додаткову функціональність, яку ми надаємо в зміненому
TokenStream.
Ви могли помітити, що ми викликаємо unwrap, щоб викликати паніку у функції
hello_macro_derive, якщо тут не вдасться викликати функцію syn::parse. Для нашого процедурного макросу
необхідно викликати паніку при помилках, тому що
функції proc_macro_derive мають повертати TokenStream, а не Result, щоб
відповідати API процедурного макросу. Ми спростили цей приклад, використовуючи
unwrap; у production-коді вам слід надавати точніші повідомлення про помилку
щодо того, що пішло не так, використовуючи panic! або expect.
Тепер, коли ми маємо код, щоб перетворювати анотований код Rust із TokenStream
на екземпляр DeriveInput, згенеруємо код, який реалізує
трейт HelloMacro для анотованого типу, як показано в Списку 20-42.
use proc_macro::TokenStream;
use quote::quote;
#[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)
}
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let generated = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
generated.into()
}
Ми отримуємо екземпляр структури Ident, що містить ім’я (identifier) анотованого типу, використовуючи ast.ident.
Структура в Списку 20-41 показує, що коли ми запустимо функцію
impl_hello_macro на коді зі Списку 20-37, отриманий ident матиме поле ident
зі значенням "Pancakes". Отже,
змінна name у Списку 20-42 міститиме екземпляр структури Ident,
який, коли його буде надруковано, стане рядком "Pancakes", назвою структури в
Списку 20-37.
Макрос quote! дає змогу нам визначити код Rust, який ми хочемо повернути. Компілятор очікує
дещо інше, ніж прямий результат виконання макросу quote!, тому нам потрібно
перетворити його на TokenStream. Ми робимо це, викликаючи метод into, який
споживає це проміжне представлення і повертає значення потрібного типу TokenStream.
Макрос quote! також надає дуже зручні механізми шаблонізації: Ми можемо
вставити #name, і quote! замінить його значенням у змінній
name. Ви навіть можете робити певні повторення, подібні до того, як працюють звичайні макроси.
Зазирніть у документацію крейту 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. Давайте під’єднаємо ці крейти до коду в Списку
20-37, щоб побачити процедурний макрос у дії! Створіть новий бінарний проєкт у
вашому каталозі projects за допомогою cargo new pancakes. Нам потрібно додати
hello_macro і hello_macro_derive як залежності в Cargo.toml крейту
pancakes. Якщо ви публікуєте свої версії hello_macro і
hello_macro_derive на crates.io, вони
були б звичайними залежностями; якщо ні, ви можете вказати їх як
path-залежності так:
[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
Помістіть код зі Списку 20-37 у src/main.rs і запустіть cargo run: Це
має вивести Hello, Macro! My name is Pancakes!. Реалізація
трейту HelloMacro з процедурного макросу була включена без того, щоб
крейт pancakes мав реалізовувати її сам; #[derive(HelloMacro)] додав
реалізацію трейту.
Далі давайте дослідимо, чим інші види процедурних макросів відрізняються від custom
derive макросів.
Макроси, схожі на атрибути
Макроси, схожі на атрибути, подібні до custom derive макросів, але замість
генерування коду для атрибута derive вони дають змогу створювати нові
атрибути. Вони також гнучкіші: derive працює лише для структур і
переліків; атрибути можна застосовувати й до інших items, таких як функції.
Ось приклад використання макросу, схожого на атрибут. Припустімо, у вас є атрибут
під назвою route, який анотує функції під час використання вебфреймворку:
#[route(GET, "/")]
fn index() {
Цей атрибут #[route] буде визначено фреймворком як процедурний
макрос. Сигнатура функції визначення макросу виглядала б так:
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
Тут у нас є два параметри типу TokenStream. Перший призначений для
вмісту атрибута: частина GET, "/". Другий — для тіла item, до якого
прикріплено атрибут: у цьому випадку fn index() {} і решта
тіла функції.
Крім цього, макроси, схожі на атрибути, працюють так само, як custom derive
макроси: Ви створюєте крейт з типом крейту proc-macro і реалізуєте
функцію, яка генерує код, який ви хочете!
Макроси, схожі на функції
Макроси, схожі на функції, визначають макроси, які виглядають як виклики функцій.
Подібно до макросів macro_rules!, вони гнучкіші за функції; наприклад, вони
можуть приймати невідому кількість аргументів. Однак макроси macro_rules! можуть
бути визначені лише за допомогою синтаксису, схожого на match, який ми обговорювали в розділі “Декларативні
макроси для загального метапрограмування” раніше.
Макроси, схожі на функції, приймають параметр TokenStream, і їхнє визначення
змінює цей TokenStream за допомогою коду Rust, як і дві інші форми
процедурних макросів. Прикладом макросу, схожого на функцію, є макрос sql!, який
можна викликати так:
let sql = sql!(SELECT * FROM posts WHERE id=1);
Цей макрос розбере SQL-оператор усередині себе і перевірить, що він
синтаксично правильний, що є набагато складнішим обробленням, ніж може зробити
макрос macro_rules!. Макрос sql! було б визначено так:
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
Це визначення подібне до сигнатури custom derive макросу: Ми отримуємо
токени, що містяться в дужках, і повертаємо код, який хотіли
згенерувати.
Підсумок
Ух! Тепер у вас є в наборі інструментів деякі можливості Rust, які ви, ймовірно, не використовуватимете часто, але знатимете, що вони доступні за дуже певних обставин. Ми представили кілька складних тем, щоб, коли ви натрапите на них у підказках до повідомлень про помилки або в коді інших людей, ви могли розпізнати ці концепції та синтаксис. Використовуйте цю главу як довідник, щоб спрямувати себе до рішень.
Далі ми застосуємо на практиці все, про що говорили протягом усієї книги, і зробимо ще один проєкт!