Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Макросы

На протяжении книги мы пользовались макросами вроде println!, но так и не разобрались толком, что такое макрос и как он работает. Под словом макрос (macro) в Rust понимается целое семейство возможностей: декларативные макросы на macro_rules! и три вида процедурных макросов:

  • пользовательские макросы #[derive], задающие код, который добавляется атрибутом derive к структурам и перечислениям;
  • атрибутоподобные макросы, определяющие собственные атрибуты, которые можно применять к любым элементам;
  • функциеподобные макросы, которые выглядят как вызовы функций, но работают с токенами, переданными им в качестве аргумента.

Разберём каждый из них по очереди, но сначала поймём, зачем вообще нужны макросы, если есть функции.

Чем макросы отличаются от функций

По сути, макросы — способ писать код, который пишет другой код; это называется метапрограммированием (metaprogramming). В приложении C мы обсуждаем атрибут derive, который генерирует реализацию разных трейтов за вас. А на протяжении книги мы пользовались макросами println! и vec!. Все эти макросы разворачиваются (expand) в больший объём кода, чем вы написали вручную.

Метапрограммирование помогает уменьшить объём кода, который нужно писать и поддерживать, — но эту задачу решают и функции. Но у макросов есть возможности, которых у функций нет.

В сигнатуре функции нужно объявить число и типы её параметров. А макросы могут принимать переменное число параметров: можно вызвать println!("hello") с одним аргументом или println!("hello {}", name) с двумя. Кроме того, макросы разворачиваются раньше, чем компилятор разбирает смысл кода, поэтому макрос может, например, реализовать трейт для заданного типа. Функция так не может: она вызывается во время выполнения, а трейт нужно реализовать при компиляции.

Минус реализации макроса вместо функции в том, что определения макросов сложнее определений функций: вы пишете код на Rust, который пишет код на Rust. Из-за этой косвенности определения макросов обычно труднее читать, понимать и поддерживать, чем определения функций.

Ещё одно важное отличие: макросы нужно определить или подключить в область видимости до того, как вы вызываете их в файле, а функции можно определять где угодно и вызывать где угодно.

Декларативные макросы для общего метапрограммирования

Самая распространённая разновидность макросов в Rust — декларативные макросы (declarative macros). Их ещё называют «макросы по образцу» (macros by example), «макросы macro_rules!» или просто «макросы». В основе своей декларативные макросы позволяют написать что-то похожее на выражение match. Как говорилось в главе 6, выражения match — управляющие конструкции, которые берут выражение, сравнивают его значение с образцами и выполняют код, связанный с совпавшим образцом. Макросы тоже сравнивают значение с образцами, связанными с определённым кодом. Только значение здесь — сам исходный код на Rust, переданный макросу; образцы сравниваются со структурой этого исходного кода; а код, связанный с совпавшим образцом, заменяет код, переданный макросу. Всё это происходит при компиляции.

Макрос определяется конструкцией macro_rules!. Разберёмся, как ею пользоваться, на примере определения макроса vec!. В главе 8 мы видели, как макросом vec! создать новый вектор с определёнными значениями. Например, следующий макрос создаёт новый вектор с тремя целыми числами:

#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}

Макросом vec! можно также создать вектор из двух целых чисел или из пяти строковых срезов. Функцией так сделать не получилось бы: ни число, ни тип значений заранее неизвестны.

В листинге 20-35 — немного упрощённое определение макроса vec!.

Файл: src/lib.rs
#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}
Листинг 20-35: Упрощённая версия определения макроса vec!

Примечание: настоящее определение макроса vec! в стандартной библиотеке содержит код, который заранее выделяет нужный объём памяти. Это оптимизация, и мы её опустили, чтобы упростить пример.

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

Затем определение макроса начинается с macro_rules! и имени макроса — без восклицательного знака. За именем, в нашем случае vec, идут фигурные скобки с телом определения макроса.

Тело vec! устроено похоже на выражение match. Здесь одна ветка с образцом ( $( $x:expr ),* ), за которым идёт => и блок кода, связанный с этим образцом. Если образец совпал, выдаётся связанный с ним блок кода. Раз это единственный образец макроса, совпасть можно только одним способом; любой другой образец приведёт к ошибке. У более сложных макросов веток больше одной.

Синтаксис образцов в определениях макросов отличается от синтаксиса из главы 19: образцы макросов сопоставляются со структурой кода на Rust, а не со значениями. Разберём, что означают части образца в листинге 20-35; полный синтаксис образцов макросов — в справочнике Rust.

Сначала весь образец заключается в круглые скобки. Знаком доллара ($) объявляется переменная в системе макросов, в которую попадёт код на 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).

Процедурные макросы: генерация кода из атрибутов

Вторая разновидность макросов — процедурные макросы, которые больше похожи на функции (и сами являются разновидностью процедуры). Процедурные макросы (procedural macros) принимают на вход некоторый код, обрабатывают его и выдают на выходе другой код, а не сопоставляют с образцами и подменяют код другим, как декларативные. Три вида процедурных макросов — пользовательские derive, атрибутоподобные и функциеподобные — работают похожим образом.

Определения процедурных макросов должны находиться в отдельном крейте особого типа. Причины тому — сложные технические, и мы надеемся когда-нибудь от этого ограничения избавиться. В листинге 20-36 показано, как определить процедурный макрос; some_attribute — заполнитель для конкретной разновидности макроса.

Файл: src/lib.rs
use proc_macro::TokenStream;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
Листинг 20-36: Пример определения процедурного макроса

Функция, определяющая процедурный макрос, принимает на вход TokenStream и выдаёт на выходе TokenStream. Тип TokenStream определён в крейте proc_macro, который поставляется вместе с Rust, и представляет последовательность токенов. Это сердце макроса: исходный код, который обрабатывает макрос, — это входной TokenStream, а код, который макрос выдаёт, — выходной TokenStream. К функции также прикреплён атрибут, указывающий, какой вид процедурного макроса мы создаём. В одном крейте может быть несколько видов процедурных макросов.

Рассмотрим разные виды процедурных макросов. Начнём с пользовательского макроса derive, а затем объясним небольшие отличия остальных видов.

Пользовательские макросы derive

Создадим крейт hello_macro с трейтом HelloMacro, у которого одна ассоциированная функция hello_macro. Вместо того чтобы заставлять пользователей реализовывать трейт HelloMacro для каждого своего типа, дадим им процедурный макрос: пометив свой тип #[derive(HelloMacro)], они получат реализацию функции hello_macro по умолчанию. Она будет выводить Hello, Macro! My name is TypeName!, где TypeName — имя типа, для которого реализован трейт. Иначе говоря, мы напишем крейт, с помощью которого другой программист сможет написать код вроде листинга 20-37.

Файл: src/main.rs
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}
Листинг 20-37: Код, который сможет написать пользователь нашего крейта с помощью процедурного макроса

Когда мы закончим, этот код выведет Hello, Macro! My name is Pancakes!. Первый шаг — создать новый библиотечный крейт:

$ cargo new hello_macro --lib

Затем в листинге 20-38 определим трейт HelloMacro и его ассоциированную функцию.

Файл: src/lib.rs
pub trait HelloMacro {
    fn hello_macro();
}
Листинг 20-38: Простой трейт, который мы будем использовать с макросом derive

У нас есть трейт и его функция. Сейчас пользователь нашего крейта мог бы сам реализовать трейт и получить нужную функциональность, как в листинге 20-39.

Файл: src/main.rs
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();
}
Листинг 20-39: Как это выглядело бы, если бы пользователи вручную реализовывали трейт HelloMacro

Но им пришлось бы писать блок реализации для каждого типа, с которым они хотят использовать hello_macro; мы хотим избавить их от этой работы.

Кроме того, мы пока не можем дать функции hello_macro реализацию по умолчанию, которая выводила бы имя типа, для которого реализован трейт: в Rust нет рефлексии, и узнать имя типа во время выполнения он не может. Нужен макрос, который сгенерирует код при компиляции.

Следующий шаг — определить процедурный макрос. На момент написания книги процедурные макросы должны находиться в собственном крейте. Возможно, со временем это ограничение снимут. Соглашение об устройстве крейтов и крейтов с макросами такое: для крейта foo крейт с пользовательским процедурным макросом derive называется foo_derive. Создадим внутри проекта hello_macro новый крейт hello_macro_derive:

$ 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 следующее:

Файл: hello_macro_derive/Cargo.toml
[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"

Чтобы начать определять процедурный макрос, поместите код из листинга 20-40 в файл src/lib.rs крейта hello_macro_derive. Учтите, что код не скомпилируется, пока мы не добавим определение функции impl_hello_macro.

Файл: hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Строим представление кода на Rust в виде синтаксического дерева,
    // с которым можно работать.
    let ast = syn::parse(input).unwrap();

    // Генерируем реализацию трейта.
    impl_hello_macro(&ast)
}
Листинг 20-40: Код, который нужен большинству крейтов процедурных макросов, чтобы обрабатывать код на 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_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
            )
        }
    )
}
Листинг 20-41: Экземпляр DeriveInput, получаемый при разборе кода с атрибутом макроса из листинга 20-37

Поля этой структуры показывают, что разобранный код на Rust — единичная структура с 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; в коде для продакшена стоит давать более конкретные сообщения о том, что пошло не так, через panic! или expect.

Теперь, когда у нас есть код, превращающий размеченный код на Rust из TokenStream в экземпляр DeriveInput, сгенерируем код, реализующий трейт HelloMacro для размеченного типа, как в листинге 20-42.

Файл: hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Строим представление кода на Rust в виде синтаксического дерева,
    // с которым можно работать
    let ast = syn::parse(input).unwrap();

    // Генерируем реализацию трейта
    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()
}
Листинг 20-42: Реализуем трейт HelloMacro с помощью разобранного кода на Rust

Через ast.ident мы получаем экземпляр структуры Ident с именем (идентификатором) размеченного типа. Структура из листинга 20-41 показывает, что при запуске impl_hello_macro на коде из листинга 20-37 поле 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. В Cargo.toml крейта pancakes нужно добавить зависимости hello_macro и hello_macro_derive. Если вы публикуете свои версии 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)].

Теперь посмотрим, чем другие виды процедурных макросов отличаются от пользовательских макросов derive.

Атрибутоподобные макросы

Атрибутоподобные макросы похожи на пользовательские макросы derive, но вместо того, чтобы генерировать код для атрибута derive, позволяют создавать новые атрибуты. К тому же они гибче: derive работает только со структурами и перечислениями, а атрибуты можно применять и к другим элементам, например к функциям. Вот пример атрибутоподобного макроса. Допустим, в веб-фреймворке есть атрибут route, которым размечают функции:

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

Атрибут #[route] определил бы фреймворк в виде процедурного макроса. Сигнатура функции, определяющей макрос, выглядела бы так:

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

Здесь два параметра типа TokenStream. Первый — содержимое атрибута, то есть часть GET, "/". Второй — тело элемента, к которому прикреплён атрибут, в данном случае fn index() {} и всё остальное тело функции.

В остальном атрибутоподобные макросы работают так же, как пользовательские макросы 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 {

Это определение похоже на сигнатуру пользовательского макроса derive: мы получаем токены, стоящие внутри скобок, и возвращаем код, который хотели сгенерировать.

Итоги

Уф! Теперь в вашем арсенале есть возможности Rust, которыми вы, скорее всего, будете пользоваться нечасто, но будете знать, что они доступны в особых обстоятельствах. Мы познакомили вас с несколькими сложными темами, чтобы вы узнавали эти понятия и синтаксис, встретив их в подсказках сообщений об ошибках или в чужом коде. Используйте эту главу как справочник при поиске решений.

Дальше мы применим на практике всё, что обсуждали в книге, и сделаем ещё один проект!