Макроси

Ми використовували макроси на кшталт println! по всій книзі, але досі повністю не розкривали, що таке макроси і як вони працюють. Термін макрос стосується родини особливостей Rust: декларативні макроси за допомогою macro_rules! і три типи *процедурних * макросів:

  • Користувацькі макроси #[derive], які визначають код, що додається з атрибутом derive, застосованим на структурах та енумах
  • Атрибутоподібні макроси, що визначають користувацькі атрибути, застосовані до будь-чого
  • Функцієподібні макроси, що виглядають як функції, але оперують переданими ним як аргумент мовними конструкціями

Ми поговоримо про кожен з них по черзі, але спершу погляньмо, чому нам взагалі потрібні макроси, коли ми вже маємо функції.

Відмінність між макросами та функціями

Засадничо макроси є способом писати код, що пише інший код, що також відомо як метапрограмування. У Додатку C ми обговорюємо атрибут derive, який генерує для вас реалізацію різних трейтів. Ми також використовували макроси println! і vec! по всій книзі. Всі ці макроси розгортаються, виробляючи більше коду, ніж написаний вами вручну.

Метапрограмування є корисним для зменшення кількості коду, що вам треба писати та підтримувати, що також є однією з ролей функцій. Однак макроси мають деякі додаткові здібності, яких бракує функціям.

Сигнатура функції має проголосити число і тип її параметрів. Макрос, з іншого боку, може приймати довільне число параметрів: ми можемо викликати println!("hello") з одним аргументом чи println!("hello {}", name) з двома. Також макроси розгортаються до того, як компілятор інтерпретує значення коду, тож макрос може, наприклад, реалізувати трейт на заданому типі. Функція не може такого, бо її викликають під час виконання, а трейт має бути реалізованим під час компіляції.

Недоліком реалізації макросу замість функції є те, що визначення макросів складніші, ніж визначення функцій, бо ви пишете код на Rust, що пише код на Rust. Через таку опосередкованість визначення макросів у цілому складніше читати, розуміти та підтримувати, ніж визначення функцій.

Ще однією важливою відмінністю між макросами та функціями є те, що ви маєте визначити макроси або принести їх в область видимості до їхнього виклику у файлі, на відміну від функцій, які ви можете визначити будь-де і викликати звідки завгодно.

Декларативні макроси, проголошені за допомогою macro_rules!, для загального метапрограмування

Найчастіше використана форма макросів у Rust - декларативні макроси. Їх також іноді називають "макросами за прикладом", "макросами macro_rules!" чи просто "макросами." За своєю суттю декларативні макроси дозволяють вам написати щось подібне до виразу 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! для створення вектора з двох цілих чисел або вектора з 5 стрічкових слайсів. Ми б не змогли скористатися функцією, щоб зробити те саме, оскільки не знали б кількості або типу значень наперед.

Блок коду 19-28 показує трохи спрощене визначення макросу vec!.

Файл: src/lib.rs

#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}

Блок коду 19-28: спрощена версія визначення макросу vec!

Примітка: Справжнє визначення макросу vec! зі стандартної бібліотеки містить спершу код для розподілу необхідної кількості пам'яті. Цей код є оптимізацією, яку ми не включаємо тут для спрощення прикладу.

Анотація #[macro_export] вказує, що цей макрос слід зробити доступним кожного разу, коли крейт, у якому його визначено вводиться до області видимості. Без цієї анотації макрос не було б введено до області видимості.

Потім ми почнемо визначення макросу за допомогою macro_rules! і назви макросу, який ми визначаємо, без знаку оклику. За назвою, у цьому випадку vec, слідують фігурні дужки, що позначають тіло визначення макросу.

Структура тіла vec! подібна до структури виразу match. Тут ми маємо один рукав із шаблоном ( $( $x:expr ),* ), за яким іде => і блок коду, пов'язаний із цим шаблоном. Якщо шаблон зіставляється, буде видано пов'язаний блок коду. Оскільки це є єдиним шаблоном у цьому макросі, є лише один коректний спосіб зіставлення; будь-який інший шаблон призведе до помилки. Складніші макроси матимуть більше ніж один рукав.

Правильний синтаксис шаблону у макросах відрізняється від синтаксису шаблону, розглянутого у Розділі 18, оскільки шаблони макросів зіставляються зі структурою коду Rust, а не значеннями. Розберімо, що означають фрагменти шаблону в Блоці коду 19-28; повний синтаксис шаблонів макросів ви можете подивитися в 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
}

Ми визначили макрос, який може прийняти будь-яку кількість аргументів будь-якого типу і може згенерувати код для створення вектора, що містить зазначені елементи.

Щоб дізнатися більше про те, як писати макроси, зверніться до документації в Інтернеті або інших ресурсів, таких як "Маленька книжка макросів Rust", яку розпочав Деніел Кіп та продовжує Лукас Вірт.

Процедурні макроси для генерації коду з атрибутів

Друга форма макросів - це процедурні макроси, які працюють більш схоже на функції (і є типом процедур). Процедурні макроси беруть певний код на вході, працюють над цим кодом і виробляють певний код на виході замість зіставлення з шаблонами і заміни коду іншим кодом, як роблять декларативні макроси. Три види процедурних макросів це користувацькі вивідні, атрибутоподібні та функцієподібні макроси, і всі працюють схожим чином.

При створені процедурних макросів визначення мають розміщуватися у їхньому власному крейті з особливим типом крейта. Так зроблено зі складних технічних причин, які ми сподіваємося усунути в майбутньому. У Блоці коду 19-29 ми показуємо, як визначити процедурний макрос, де some_attribute є заповнювачем для використання конкретного різновиду макросу.

Файл: src/lib.rs

use proc_macro;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}

Блок коду 19-29: приклад визначення процедурного макросу

Функція, що визначає процедурний макрос, приймає TokenStream на вхід і продукує TokenStream на виході. Тип TokenStream визначений у крейті proc_macro, що постачається разом із Rust, і являє собою послідовність токенів. Це основа макросу: початковий код, з яким працює макрос, є вхідним TokenStream, а код, що макрос продукує, є вихідним TokenStream. Функція також має атрибут, що визначає, який вид процедурного макросу ми створюємо. Можна мати багато видів процедурних макросів в одному крейті.

Подивімося на різні види процедурних макросів. Ми почнемо з користувальницького вивідного макросу, а потім пояснимо невеликі розбіжності, що роблять інші форми відмінними.

Як писати користувацький макрос derive

Створімо крейт з назвою hello_macro, що визначає трейт з назвою HelloMacro з однією асоційованою функцією з назвою hello_macro. Замість примушувати користувачів крейту реалізовувати трейт HelloMacro для кожного з їхніх типів, ми надамо процедурний макрос, щоб користувачі могли анотувати свої типи #[derive(HelloMacro)] і отримувати реалізацію функції hello_macro за замовчуванням. Реалізація за замовчуванням виведе Hello, Macro! My name is TypeName!, де TypeName - це назва типу, для якого цей трейт визначено. Іншими словами, ми створимо крейт, за допомогою якого інші програмісти зможуть писати код на кшталт Блоку коду 19-30.

Файл: 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 і асоційовану функцію:

Файл: 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, крейт з користувацьким вивідним макросом має зватися foo_derive. Почнімо новий крейт, що зветься hello_macro_derive, усередині нашого проєкту hello_macro:

$ cargo new hello_macro_derive --lib

Наші два крейти тісно пов'язані, тому ми створюємо крейт для процедурного макросу в каталозі нашого крейта hello_macro. Якщо ми змінимо визначення трейту в hello_macro, то мусимо також змінити реалізацію процедурного макросу в hello_macro_derive. Два крейти треба буде публікувати окремо, і програмістам, що використовують ці крейти, доведеться додавати обидва як залежності і вводити обидва до області видимості. Ми могли б натомість додати hello_macro_derive як залежність у hello_macro і реекспортувати код процедурного макросу. Однак те, як ми структурували проєкт, надає програмістам можливість використовувати hello_macro навіть якщо вони не хочуть мати функціонал derive.

Нам треба оголосити крейт hello_macro_derive як крейт процедурного макросу. Нам також знадобиться функціонал крейтів syn та quote, як ви побачите за хвилину, тому ми маємо додати їх як залежності. Додайте наступне у файл Cargo.toml для hello_macro_derive:

Файл: 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.

Файл: 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, а 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_макро_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 для детальнішої інформації.

Незабаром ми визначатимемо функцію impl_hello_macro, де ми зберемо новий код Rust, який ми хочемо додати. Але перед тим зауважте, що вихід для нашого макросу - це також TokenStream. TokenStream, що повертається, додається до коду, написаного користувачами нашого крейту, тод коли вони компілюватимуть свої крейти, то отримають додатковий функціонал, наданий нами в зміненому TokenStream.

Ви могли не звернути увагу, що ми викликаємо unwrap, щоб функція hello_macro_derive запанікувала, якщо виклик функції syn::parse буде невдалим. Необхідно, щоб наш процедурний макрос панікував при помилках, бо функції proc_macro_derive мають повертати TokenStream, а не Result, щоб відповідати API процедурних макросів. Ми спростили цей приклад, використовуючи unwrap; у реальному коді ви маєте забезпечувати конкретніші повідомлення про помилки, описуючи, що саме пішло не так за допомогою panic! або expect.

Тепер, коли у нас є код, що перетворює анотований код Rust з TokenStream на екземпляр DeriveInput, згенеруймо код, що реалізує трейт HelloMacro на анотованому типі, як показано у Блоці коду 19-33.

Файл: 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)
}

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, який ми хочемо повернути. Компілятор очікує на щось відмінне від безпосереднього результату виконання макросу 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. Під'єднаймо ці крейти до коду з Блок коду 19-30, щоб побачити процедурні макроси в дії! Створіть новий двійковий проєкт у каталозі projects командою cargo new pancakes. Нам треба додати hello_macro та hello_macro_derive як залежності до файлу Cargo.toml крейту pancakes. Якщо ви публікуєте ваші версії hello_macro та hello_macro_derive на crates.io, вони будуть звичайними залежностями; якщо ж ні, ви можете зазначити як залежності із path, ось так:

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 вони дозволяють вам створювати нові атрибути. Вони також гнучкіші: derive працює лише зі структурами та енумами; атрибути можна застосовувати також і до інших елементів, наприклад функцій. Ось приклад використання атрибутоподібного макросу: скажімо, ви маєте атрибут з назвою route, що анотує функції при використанні фреймворку вебзастосунків:

#[route(GET, "/")]
fn index() {

Цей атрибут #[route] буде визначено фреймворком як процедурний макрос. Сигнатура функції, що визначає макрос, виглядатиме ось так:

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {

Тут ми маємо два параметри типу TokenStream. Перший для вмісту атрибута, тобто частини GET, "/". Другий - це тіло елементу, до якого застосований атрибут, у цьому випадку fn index() {} і решта тіла функції.

Окрім цього, атрибутоподібні макроси працюють так само, як і користувацькі вивідні макроси: ви створюєте крейт із типом крейту proc-macro і реалізуєте функцію, що створює потрібний вам код!

Функцієподібні макроси

Функцієподібні макроси визначають макроси, що виглядають, як виклики функцій. Подібно до макросів macro_rules!, вони гнучкіші за функції; наприклад, вони можуть приймати довільну кількість аргументів. Однак macro_rules! можуть бути визначені лише за допомогою синтаксису, схожого на match, про який ми говорили раніше у підрозділі Декларативні макроси, проголошені за допомогою macro_rules!, для загального метапрограмування . Функцієподібні макроси приймають параметр 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 {

Це визначення схоже на сигнатуру користувацького вивідного макросу: ми отримаємо токени, що знаходяться в дужках, і повертаємо код, який нам треба створити.

Підсумок

Хух! Тепер у вашому інструментарії є функціонал Rust, який ви навряд чи часто використовуватимете, але ви знатимете, що він доступний за певних обставин. Ми ознайомили вас із кількома складними темами, щоб зустрівши їх у пропозиціях у повідомленнях про помилки або в коді інших людей ви могли розпізнати ці концепції та синтаксис. Використовуйте цей розділ як довідник, що приведе вас до рішення.

Далі ми покажемо на практиці все, що ми обговорювали протягом усієї книги, і зробимо ще один проєкт!