Исследование макросов Rust в LabEx

RustRustBeginner
Практиковаться сейчас

This tutorial is from open-source community. Access the source code

💡 Этот учебник переведен с английского с помощью ИИ. Чтобы просмотреть оригинал, вы можете перейти на английский оригинал

Введение

Добро пожаловать в Macros. Эта лабораторная работа является частью Rust Book. Вы можете практиковать свои навыки Rust в LabEx.

В этой лабораторной работе мы исследуем концепцию макросов в Rust, включая декларативные макросы с macro_rules! и три вида процедурных макросов: пользовательские макросы #[derive], макросы, подобные атрибутам, и макросы, подобные функциям.

Макросы

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

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

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

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

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

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

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

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

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

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

Самая широко используемая форма макросов в Rust - это декларативный макрос. Эти макросы иногда также называются "макросами по примеру", "макросами macro_rules!" или просто "макросами". В основе declarative макросов лежит возможность писать что-то похожее на выражение match в Rust. Как обсуждалось в главе 6, выражения match - это управляющие конструкции, которые принимают выражение, сравнивают результирующее значение выражения с шаблонами и затем запускают код, связанный с совпадающим шаблоном. Макросы также сравнивают значение с шаблонами, связанными с определенным кодом: в этом случае значение - это исходный код Rust, переданный в макрос; шаблоны сравниваются с структурой этого исходного кода; и код, связанный с каждым шаблоном, при совпадении, заменяет код, переданный в макрос. Все это происходит во время компиляции.

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

let v: Vec<u32> = vec![1, 2, 3];

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

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

Filename: src/lib.rs

1 #[macro_export]
2 macro_rules! vec {
  3 ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
          4 $(
              5 temp_vec.push(6 $x);
            )*
          7 temp_vec
        }
    };
}

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

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

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

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

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

Валидный синтаксис шаблонов в определениях макросов отличается от синтаксиса шаблонов, рассмотренного в главе 18, потому что шаблоны макросов сопоставляются с структурой кода Rust, а не со значениями. Давайте разберем, что означают части шаблона в Листинге 19-28; для полного синтаксиса шаблонов макросов см. Rust Reference по адресу https://doc.rust-lang.org/reference/macros-by-example.html.

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

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

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

Теперь давайте посмотрим на шаблон в теле кода, связанном с этой веткой: temp_vec.push() [5] внутри $()* в [4] и [7] генерируется для каждой части, которая совпадает с()` в шаблоне ноль или более раз в зависимости от количества совпадений шаблона. `x[6] заменяется на каждое совпавшее выражение. Когда мы вызываем этот макрос с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" по адресу https://veykril.github.io/tlborm, начатый Даниэлем Кепом и продолженный Лукасом Виртом.

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

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

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

Filename: src/lib.rs

use proc_macro::TokenStream;

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

Filename: 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 и его ассоциированную функцию:

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

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

Для начала определения процедурного макроса поместите код из Листинга 19-31 в файл src/lib.rs для крейта hello_macro_derive. Обратите внимание, что этот код не скомпилируется, пока мы не добавим определение функции impl_hello_macro.

Filename: hello_macro_derive/src/lib.rs

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

#[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 (доступен по адресу https://crates.io/crates/syn), и quote (доступен по адресу https://crates.io/crates/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 по адресу https://docs.rs/syn/1.0/syn/struct.DeriveInput.html для получения дополнительной информации.

Вскоре мы определим функцию 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.

Filename: hello_macro_derive/src/lib.rs

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, который мы хотим вернуть. Компилятор ожидает etwas другое, чем прямой результат выполнения макроса quote!, поэтому нам нужно преобразовать его в TokenStream. Мы это делаем, вызывая метод into, который потребляет это промежуточное представление и возвращает значение нужного типа TokenStream.

Макрос quote! также предоставляет очень крутые механизмы шаблонизации: мы можем ввести #name, и quote! заменит его значением переменной name. Вы даже можете сделать некоторую повторяемость, подобную тому, как работают обычные макросы. Проверьте документацию по крейту quote по адресу https://docs.rs/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, чтобы увидеть, как работает процедурный макрос! Создайте новый бинарный проект в директории project с помощью cargo new pancakes. Мы должны добавить hello_macro и hello_macro_derive в качестве зависимостей в Cargo.toml крейта pancakes. Если вы публикуете свои версии hello_macro и hello_macro_derive на https://crates.io, они будут обычными зависимостями; если нет, вы можете указать их в качестве зависимостей по пути, как показано ниже:

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

Резюме

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