Макросы

Мы использовали макросы, такие как println!, на протяжении всей этой книги, но мы не полностью исследовали, что такое макрос и как он работает. Термин «макрос» относится к семейству возможностей в Rust — декларативные макросы с macro_rules! и три вида процедурных макросов:

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

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

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

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

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

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

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

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

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

Наиболее широко используемой формой макросов в Rust являются декларативные макросы. Их иногда также называют «макросами по примеру», «макросами macro_rules!» или просто «макросами». В своей основе декларативные макросы позволяют вам написать нечто похожее на выражение match в Rust. Как обсуждалось в главе 6, выражения match — это конструкции управления потоком, которые принимают выражение, сравнивают результирующее значение выражения с образцами и затем выполняют код, связанный с подходящим образцом. Макросы также сравнивают значение с образцами, связанными с определённым кодом: в этой ситуации значением является литеральный исходный код Rust, переданный макросу; образцы сравниваются со структурой этого исходного кода; и код, связанный с каждым образцом, при совпадении заменяет код, переданный макросу. Всё это происходит во время компиляции.

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

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

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

Листинг 20-35 показывает слегка упрощённое определение макроса vec!.

Файл: src/lib.rs

#![allow(unused)]
fn main() {
#[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 Reference.

Сначала мы используем набор круглых скобок, чтобы охватить весь образец. Мы используем знак доллара ($), чтобы объявить переменную в системе макросов, которая будет содержать код Rust, соответствующий образцу. Знак доллара делает ясным, что это переменная макроса, в отличие от обычной переменной Rust. Далее следует набор круглых скобок, который захватывает значения, соответствующие образцу внутри скобок, для использования в заменяющем коде. Внутри $() находится $x:expr, который соответствует любому выражению Rust и даёт выражению имя $x.

Запятая после $() указывает, что литеральный символ-разделитель запятой должен появляться между каждым экземпляром кода, который соответствует коду в $(). * указывает, что образец соответствует нулю или более того, что предшествует *.

Когда мы вызываем этот макрос с vec![1, 2, 3];, образец $x совпадает три раза с тремя выражениями 1, 2 и 3.

Теперь посмотрим на образец в теле кода, связанного с этой веткой: temp_vec.push() внутри $()* генерируется для каждой части, которая соответствует $() в образце, ноль или более раз в зависимости от того, сколько раз образец совпадает. $x заменяется каждым совпавшим выражением. Когда мы вызываем этот макрос с vec![1, 2, 3];, сгенерированный код, который заменяет этот вызов макроса, будет следующим:

#![allow(unused)]
fn main() {
{
    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», начатый Дэниелом Кипом и продолженный Лукасом Виртом.

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

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

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

Файл: src/lib.rs

#![allow(unused)]
fn main() {
use proc_macro::TokenStream;

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

Листинг 20-36: Пример определения процедурного макроса

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

Давайте рассмотрим различные виды процедурных макросов. Мы начнём с пользовательского 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

#![allow(unused)]
fn main() {
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_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

#![allow(unused)]
fn main() {
// [Этот код не компилируется!]
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 сначала преобразует входные данные из TokenStream в структуру данных, которую мы затем можем интерпретировать и выполнять операции. Здесь в игру вступает syn. Функция parse в syn принимает TokenStream и возвращает структуру DeriveInput, представляющую разобранный код Rust. Листинг 20-41 показывает соответствующие части структуры DeriveInput, которую мы получаем при разборе строки struct Pancakes;.

#![allow(unused)]
fn main() {
DeriveInput {
    // --пропуск--

    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 — это unit-структура с 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.

Файл: hello_macro_derive/src/lib.rs

#![allow(unused)]
fn main() {
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

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

[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)] добавил реализацию трейта.

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

Макросы, похожие на атрибуты

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

#![allow(unused)]
fn main() {
#[route(GET, "/")]
fn index() {
}

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

#![allow(unused)]
fn main() {
#[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!, который может вызываться так:

#![allow(unused)]
fn main() {
let sql = sql!(SELECT * FROM posts WHERE id=1);
}

Этот макрос будет разбирать SQL-запрос внутри него и проверять его синтаксическую корректность, что является гораздо более сложной обработкой, чем может выполнить макрос macro_rules!. Макрос sql! был бы определён так:

#![allow(unused)]
fn main() {
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
}

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

Заключение

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

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