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

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

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

Наиболее широко используемая форма макросов в Rust — это декларативный макрос. Они также иногда называются “макросами по примеру”, “макросами macro_rules!” или просто “макросами”. По своей сути декларативные макросы позволяют вам писать что-то похожее на выражение match в Rust. Как обсуждалось в Главе 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!.

Filename: 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-29; полный синтаксис шаблонов макросов см. в Справочнике 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
}

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

Чтобы узнать больше о том, как писать макросы, обратитесь к онлайн-документации или другим ресурсам, таким как “Маленькая книга макросов Rust”, начатая Дэниелом Кипом и продолженная Лукасом Виртом.

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

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

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

Filename: src/lib.rs
use proc_macro;

#[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, используя наш крейт.

Filename: 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

Далее мы определим трейт HelloMacro и его связанную функцию:

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

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

Filename: 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:

Filename: 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.

Filename: 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 (идентификатором, означающим имя) 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.

Filename: 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-33 показывает, что когда мы запускаем функцию impl_hello_macro для кода в Листинге 20-31, ident, который мы получим, будет иметь поле ident со значением "Pancakes". Таким образом, переменная name в Листинге 20-34 будет содержать экземпляр структуры 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-31, чтобы увидеть процедурный макрос в действии! Создайте новый бинарный проект в вашем каталоге 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" }

Поместите код из Листинга 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, который мы обсуждали в разделе “Декларативные макросы с 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 {

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

Резюме

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

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