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! на протяжении всей книги, но еще не рассматривали полностью, что такое макрос и как он работает. Термин макрос относится к семейству возможностей в Rust: декларативным макросам с macro_rules! и трем видам процедурных макросов:

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

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

Различие между макросами и функциями

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

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

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

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

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

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

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

Листинг 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
        }
    };
}
Listing 20-35: Упрощенная версия определения макроса vec!

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

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

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

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

Допустимый синтаксис шаблонов в определениях макросов отличается от синтаксиса шаблонов, рассмотренного в главе 19, потому что шаблоны макросов сопоставляются со структурой кода Rust, а не со значениями. Давайте разберем, что означают части шаблона в листинге 20-35; полный синтаксис шаблонов макросов см. в 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.

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

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

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

Имя файла: src/lib.rs
use proc_macro::TokenStream;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
Listing 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();
}
Listing 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();
}
Listing 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();
}
Listing 20-39: Как это выглядело бы, если бы пользователи написали ручную реализацию трейта HelloMacro

Однако им пришлось бы писать блок реализации для каждого типа, который они хотели бы использовать с 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:

Имя файла: 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 {
    // 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)
}
Listing 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
            )
        }
    )
}
Listing 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 {
    // 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()
}
Listing 20-42: Реализация трейта HelloMacro с использованием разобранного кода Rust

Мы получаем экземпляр структуры Ident, содержащий имя (идентификатор) аннотированного типа, с помощью 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)] добавил реализацию трейта.

Далее посмотрим, чем другие виды процедурных макросов отличаются от пользовательских макросов 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, которые вы, вероятно, не будете использовать часто, но будете знать, что они доступны в очень конкретных обстоятельствах. Мы представили несколько сложных тем, чтобы, когда вы встретите их в предложениях из сообщений об ошибках или в коде других людей, вы могли распознать эти понятия и синтаксис. Используйте эту главу как справочник, который поможет вам найти решения.

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