Макросы

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

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

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

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

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

В листинге 19-28 приведено несколько упрощённое определение макроса vec!.

Файл: src/lib.rs

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

Листинг 19-28: Упрощённая версия определения макроса vec!

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

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

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

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

Допустимый синтаксис шаблона в определениях макросов отличается от синтаксиса шаблона рассмотренного в главе 18, потому что шаблоны макроса сопоставляются со структурами кода Rust, а не со значениями. Давайте пройдёмся по тому, какие части шаблона в листинге 19-28 что означают; полный синтаксис шаблонов макроса можно найти в Справочнике по Rust.

Во-первых, мы используем набор скобок, чтобы охватить весь шаблон. Мы используем знак доллара ( $) для объявления переменной в системе макросов, которая будет содержать код на 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» , начатая Дэниелом Кипом и продолженная Лукасом Виртом.

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

Вторая форма макросов - это процедурные макросы (procedural macros), которые действуют как функции (и являются типом процедуры). Процедурные макросы принимают некоторый код в качестве входных данных, работают над этим кодом и создают некоторый код в качестве вывода, а не выполняют сопоставления с шаблонами и замену кода другим кодом, как это делают декларативные макросы. Процедурные макросы могут быть трёх типов: "пользовательского вывода" (custom-derive), "похожие на атрибут" (attribute-like) и "похожие на функцию" (function-like), все они работают схожим образом.

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

Файл: src/lib.rs

use proc_macro;

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

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

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

Давайте посмотрим на различные виды процедурных макросов. Начнём с пользовательского, выводимого (derive) макроса и затем объясним небольшие различия, делающие другие формы отличающимися.

Как написать пользовательский derive макрос

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

Файл: src/main.rs

use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}

Листинг 19-30: Код, который сможет писать пользователь нашего крейта при использовании нашего процедурного макроса

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

$ cargo new hello_macro --lib

Далее, мы определим типаж HelloMacro и ассоциированную с ним функцию:

Файл: src/lib.rs

pub trait HelloMacro {
    fn hello_macro();
}

У нас есть типаж и его функция. На этом этапе пользователь крейта может реализовать типаж для достижения желаемой функциональности, так:

use hello_macro::HelloMacro;

struct Pancakes;

impl HelloMacro for Pancakes {
    fn hello_macro() {
        println!("Hello, Macro! My name is Pancakes!");
    }
}

fn main() {
    Pancakes::hello_macro();
}

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

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

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

Чтобы начать определение процедурного макроса, поместите код листинга 19-31 в ваш файл 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)
}

Листинг 19-31: Код, который потребуется в большинстве процедурных макро крейтов для обработки Rust кода

Обратите внимание, что мы разделили код на функцию hello_macro_derive, которая отвечает за синтаксический анализ TokenStream и функцию impl_hello_macro, которая отвечает за преобразование синтаксического дерева: это делает написание процедурного макроса удобнее. Код во внешней функции ( hello_macro_derive в данном случае) будет одинаковым для почти любого процедурного макрос крейта, который вы видите или создаёте. Код, который вы указываете в теле внутренней функции (в данном случае impl_hello_macro ) будет отличаться в зависимости от цели вашего процедурного макроса.

Мы представили три новых крейта: proc_macro syn и quote. Макрос proc_macro поставляется с Rust, поэтому нам не нужно было добавлять его в зависимости внутри Cargo.toml. Макрос proc_macro - это API компилятора, который позволяет нам читать и манипулировать Rust кодом из нашего кода.

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

Функция hello_macro_derive будет вызываться, когда пользователь нашей библиотеки указывает своему типу #[derive(HelloMacro)]. Это возможно, потому что мы аннотировали функцию hello_macro_derive с помощью proc_macro_derive и указали имя HelloMacro, которое соответствует имени нашего типажа; это соглашение, которому следует большинство процедурных макросов.

Функция hello_macro_derive сначала преобразует input из TokenStream в структуру данных, которую мы можем затем интерпретировать и над которой выполнять операции. Здесь крейт syn вступает в игру. Функция parse в syn принимает TokenStream и возвращает структуру DeriveInput, представляющую разобранный код Rust. Листинг 19-32 показывает соответствующие части структуры DeriveInput, которые мы получаем при разборе строки struct Pancakes;:

DeriveInput {
    // --snip--

    ident: Ident {
        ident: "Pancakes",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}

Листинг 19-32: Экземпляр DeriveInput получаемый, когда разбирается код имеющий атрибут макроса из листинга 19-30

Поля этой структуры показывают, что код Rust, который мы разобрали, является блок структуры с ident (идентификатором, означающим имя) Pancakes. В этой структуре есть больше полей для описания всех видов кода Rust; проверьте документацию syn о структуре DeriveInput для получения дополнительной информации.

Вскоре мы определим функцию impl_hello_macro, в которой построим новый, дополнительный код Rust. Но прежде чем мы это сделаем, обратите внимание, что выводом для нашего выводимого (derive) макроса также является TokenStream. Возвращаемый TokenStream добавляется в код, написанный пользователями макроса, поэтому, когда они соберут свой крейт, они получат дополнительную функциональность, которую мы предоставляем в изменённом TokenStream.

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

Теперь, когда у нас есть код для преобразования аннотированного Rust кода из TokenStream в экземпляр DeriveInput, давайте сгенерируем код реализующий типаж HelloMacro у аннотированного типа, как показано в листинге 19-33.

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

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation
    impl_hello_macro(&ast)
}

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let gen = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    gen.into()
}

Листинг 19-33: Реализация типажа HelloMacro с использованием проанализированного кода Rust.

Мы получаем экземпляр структуры Ident содержащий имя (идентификатор) аннотированного типа с использованием ast.ident. Структура в листинге 19-32 показывает, что когда мы запускаем функцию impl_hello_macro для кода из листинга 19-30, то получаемый ident будет иметь поле ident со значением "Pancakes". Таким образом, переменная name в листинге 19-33 будет содержать экземпляр структуры Ident, что при печати выдаст строку "Pancakes", что является именем структуры в листинге 19-30.

Макрос quote! позволяет определить код Rust, который мы хотим вернуть. Компилятор ожидает что-то отличное от прямого результата выполнения макроса quote!, поэтому нужно преобразовать его в TokenStream. Мы делаем это путём вызова метода into, который использует промежуточное представление и возвращает значение требуемого типа TokenStream.

Макрос quote! также предоставляет очень классную механику шаблонов: мы можем ввести #name и quote! заменит его значением из переменной name. Вы можете даже сделать некоторое повторение, подобное тому, как работают обычные макросы. Проверьте документацию крейта quote для подробного введения.

Мы хотим, чтобы наш процедурный макрос генерировал реализацию нашего типажа HelloMacro для типа, который аннотировал пользователь, который мы можем получить, используя #name. Реализация типажа имеет одну функцию hello_macro, тело которой содержит функциональность, которую мы хотим предоставить: напечатать Hello, Macro! My name is с именем аннотированного типа.

Макрос stringify! используемый здесь, встроен в Rust. Он принимает Rust выражение, такое как 1 + 2 и во время компиляции компилятор превращает выражение в строковый литерал, такой как "1 + 2". Он отличается от макросов format! или println!, которые вычисляют выражение, а затем превращают результат в виде типа String. Существует возможность того, что введённый #name может оказаться выражением для печати буквально как есть, поэтому здесь мы используем stringify!. Использование stringify! также экономит выделение памяти путём преобразования #name в строковый литерал во время компиляции.

На этом этапе команда cargo build должна завершиться успешно для обоих hello_macro и hello_macro_derive. Давайте подключим эти крейты к коду в листинге 19-30, чтобы увидеть процедурный макрос в действии! Создайте новый бинарный проект в каталоге ваших проектов с использованием команды cargo new pancakes. Нам нужно добавить hello_macro и hello_macro_derive в качестве зависимостей для крейта pancakes в файл Cargo.toml. Если вы публикуете свои версии hello_macro и hello_macro_derive на сайт crates.io, они будут обычными зависимостями; если нет, вы можете указать их как path зависимости следующим образом:

hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

Поместите код в листинге 19-30 в src/main.rs и выполните cargo run: он должен вывести Hello, Macro! My name is Pancakes!. Реализация типажа HelloMacro из процедурного макроса была включена без необходимости его реализации крейтом pancakes; #[derive(HelloMacro)] добавил реализацию типажа.

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

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

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

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

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

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

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

Кроме того, атрибутные макросы работают так же как и пользовательские выводимые макросы: вы создаёте крейт с типом proc-macro и реализуете функцию, которая генерирует код, который хотите!

Макросы, похожие на функции

Макросы, похожие на функции, выглядят подобно вызову функций. Подобно макросам macro_rules! они являются более гибкими, чем функции; например, они могут принимать неизвестное количество аргументов. Тем не менее, макросы macro_rules! можно объявлять только с использованием синтаксиса подобного сопоставлению, который мы обсуждали ранее в разделе "Декларативные макросы macro_rules! для общего мета программирования". Макросы, похожие на функции, принимают параметр TokenStream и их определение манипулирует этим TokenStream, используя код Rust, как это делают два других типа процедурных макроса. Примером подобного функционально подобного макроса является макрос sql!, который можно вызвать так:

let sql = sql!(SELECT * FROM posts WHERE id=1);

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

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {

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

Итоги

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

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