Разбор внутренней работы и авторства макросов в Rust

Макросы Rust — это конструкции времени компиляции, которые работают с потоками токенов языка Rust.

Коротко о компиляции

Что такое «токены языка Rust»?

Когда компилятор начинает компилировать программу, он сначала читает файл исходного кода. Для простоты предположим, что компилятор хранит этот исходный код в строке. Следующий шаг — пройтись по строке, символ за символом, и разделить ее на «токены».

Например, фрагмент Rust, например:

let foo: u32 = 30;

Может быть «токенизирован» в:

[
  KeywordLet,
  Identifier("foo"),
  Colon,
  Identifier("u32"),
  SingleEquals,
  NumericLiteral("30"),
  Semicolon,
]

(Обратите внимание, что это полностью воображаемый пример.)

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

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

Есть две основные категории макросов Rust: декларативные макросы и процедурные макросы.

Декларативные макросы

Декларативные макросы можно объявлять и использовать вместе с другим кодом. Они объявляются с использованием специальной конструкции macro_rules! и имеют уникальный синтаксис:

macro_rules! my_macro {
    ($a: ident => $b: expr) => {
        fn $a() {
            println!("{}", $b);
        }
    };
    ($a: ident, $b: expr) => {
        println!("{} {}", $a, $b);
    };
}

Декларативные макросы принимают токены Rust в качестве входных данных и выполняют сопоставление с ними шаблонов. В приведенном выше примере макрос my_macro соответствует двум разным шаблонам:

  1. Идентификатор и выражение, разделенные жирной стрелкой =>, и
  2. Идентификатор и выражение, разделенные запятой ,.

Этот макрос может быть вызван следующим образом:

my_macro!(foo, 45);
my_macro!(bar => "hello");
my_macro!(baz => 9 * 8);

Вложенные макросы и рекурсия

Один из самых популярных крейтов Rust, serde_json, включает декларативный макрос json!(), который позволяет вам писать JSON-подобный синтаксис в коде Rust. Он возвращает serde_json::Value.

json!({
    "id": 42,
    "name": {
        "first": "John",
        "last": "Zoidberg",
    },
});

Как оказалось, вы можете указать любое допустимое выражение Rust (которое дает значение, реализующее Serialize) в качестве значения:

json!({
    "id": 21 + 21, // Computed expression
    "name": {
        "first": "John",
        "last": "Zoidberg",
    },
});

И я имею в виду любое допустимое выражение Rust…

json!({
    "id": 21 + 21,
    "name": json!({ // This is another macro invocation!
        "first": "John",
        "last": "Zoidberg",
    }),
});

Эта возможность распространяется и на код, который генерирует ваш макрос. Например, мы можем написать базовый синтаксический анализатор, который рекурсивно переводит логические символы для И (∧; ^ в коде) и ИЛИ (∨; v в коде) в эквиваленты Rust.

macro_rules! andor {
    ($a: ident ^ $b: ident $($tail: tt)*) => {
        $a && andor!($b $($tail)*) // Recursive invocation
    };
    ($a: ident v $b: ident $($tail: tt)*) => {
        $a || andor!($b $($tail)*) // Recursive invocation
    };
    ($($a: tt)*) => {
        $($a)*
    }
}

andor!(true ^ false v false ^ true) // true && false || false && true
// => false

Поскольку это потенциально бесконечная операция, рекурсия макроса имеет максимальную глубину, определяемую компилятором Rust.

Процедурные макросы

Процедурные макросы записываются с использованием обычного кода Rust (не уникальный синтаксис), который компилируется, а затем запускается компилятором при вызове. По этой причине процедурные макросы также иногда называют «плагинами компилятора».

Поскольку крейты = единицы компиляции, для того, чтобы процедурный макрос был скомпилирован до его выполнения, процедурные макросы должны быть определены (и впоследствии экспортированы) в крейте, отличном от того, в котором они используются. Это должны быть библиотечные ящики со следующим в Cargo.toml:

[lib]
proc-macro = true

Процедурные макросы представлены в трех формах, каждая из которых вызывается по-разному:

  • Подобный атрибуту.
    Ввод: аннотированный элемент.
    Вывод заменяет ввод. (Исходный ввод не существует в конечном потоке маркеров.)
#[my_attribute_macro]
struct MyStruct; // This struct is the input to the macro

struct AnotherStruct; // This struct is not part of the macro's input
  • Пользовательское производное.
    Ввод: аннотированный элемент.
    Вывод добавляется к вводу. (Исходный ввод все еще существует в конечном потоке маркеров.)
#[derive(MyDeriveMacro)]
struct MyStruct; // This struct is the input to the macro

struct AnotherStruct; // This struct is not part of the macro's input
  • Подобно функциям.
    Ввод: закрытый поток токенов. Разделителями являются [], {} или ().
    Вывод заменяет ввод. (Исходный ввод не существует в конечном потоке маркеров.)
my_function_like_macro!(arbitrary + token : stream 00);
// is the same as
my_function_like_macro![arbitrary + token : stream 00];
// is the same as
my_function_like_macro!{arbitrary + token : stream 00};

В этом посте я расскажу о написании атрибутов и получении макросов.

Создание процедурных макросов

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

use proc_macro::TokenStream;

#[proc_macro_attribute]
pub fn my_attribute_macro(attr: TokenStream, item: TokenStream) -> TokenStream {
    todo!("Good luck!")
}

Этот макрос можно вызвать следующим образом:

#[my_attribute_macro]
struct AnnotatedItem;

В этом случае поток токенов attr будет пустым, а поток токенов item будет содержать структуру AnnotatedItem.

Если вы вызываете макрос следующим образом:

#[my_attribute_macro(attribute_tokens)]
fn my_function() {}

В этом случае поток токенов attr будет содержать attribute_tokens, а поток токенов item будет содержать функцию my_function.

Прохладный. У нас настроена базовая инфраструктура, теперь мы просто анализируем потоки входных токенов.

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

Это много работы!

Введите: syn и quote.

Взаимодействие с компилятором

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

Основные варианты использования каждого из этих ящиков чрезвычайно просты — это очень хорошо спроектированные ящики!

Вот простой макрос атрибута, использующий syn и quote, который абсолютно ничего не делает (он возвращает свой ввод):

use proc_macro::TokenStream;
use syn::{parse_macro_input, AttributeArgs, Item};
use quote::quote;

#[proc_macro_attribute]
pub fn my_attribute_macro(attr: TokenStream, item: TokenStream) -> TokenStream {
    let _attr = parse_macro_input!(attr as AttributeArgs);
    let item = parse_macro_input!(item as Item);

    quote!{
        #item
    }.into()
}

Макрос parse_macro_input пытается преобразовать TokenStream в структуру данных syn и в случае неудачи выдает ошибку компилятора. Структуры данных и документация syn заслуживают самостоятельного изучения. Они дадут вам довольно хорошее представление о том, как может выглядеть синтаксическое дерево.

Item, проанализированное в приведенном выше примере, является перечислением, которое вы можете match противопоставить:

match item { // The annotated item was parsed as...
    Item::Enum(e) => {}, // ...an enum
    Item::Fn(f) => {}, // ...a function
    Item::Impl(i) => {}, // ...an impl block
    Item::Struct(s) => {}, // ...a struct

    // ...and so on and so forth
    _ => todo!(),
}

Макрос quote создает proc_macro2::TokenStream (которое можно легко преобразовать в обычное proc_macro::TokenStream с помощью Into::into) из некоторого ввода, похожего на код Rust. Он также поддерживает интерполяцию переменных с помощью синтаксиса #identifier, показанного выше.

В мире авторов макросов syn и quote довольно распространены. Вот пример использования syn и quote в популярном ящике thiserror.

Параметризация и конфигурация

Последний инструмент в нашем списке для создания поддерживаемых и полезных макросов — darling. Заявленное описание ящика:

Библиотека proc-macro для чтения атрибутов в структурах при реализации пользовательских производных файлов.

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

Практический пример

Вот пример очень простого макроса вывода, использующего все три крейта, вместе с обработкой ошибок, необязательным параметром конфигурации и некоторыми автопереадресованными полями darling (data, generics, ident, fields).

use darling::{FromDeriveInput, FromVariant};
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Path};

#[derive(Debug, FromDeriveInput)]
// The struct will be deserialized from a `#[display]` attribute on any kind of enum
#[darling(attributes(display), supports(enum_any))]
struct EnumMeta {
    // Try to optionally deserialize an item path
    pub transform: Option<Path>,

    // Forwarded attributes
    pub ident: syn::Ident,
    pub generics: syn::Generics,
    pub data: darling::ast::Data<VariantVisitor, ()>,
}

#[derive(Debug, FromVariant)]
struct VariantVisitor {
    // The name of the enum variant
    pub ident: syn::Ident,
    pub fields: darling::ast::Fields<()>,
}

fn expand(meta: EnumMeta) -> Result<TokenStream2, darling::Error> {
    let EnumMeta {
        transform,
        data,
        generics,
        ident,
    } = meta;

    let variants = data.take_enum().unwrap();

    let match_arms = variants.iter().map(|variant| {
        let i = &variant.ident;
        let name = i.to_string();
        match variant.fields.style {
            darling::ast::Style::Tuple => {
                quote! { Self :: #i ( .. ) => #name , }
            }
            darling::ast::Style::Struct => {
                quote! { Self :: #i { .. } => #name , }
            }
            darling::ast::Style::Unit => {
                quote! { Self :: #i  => #name , }
            }
        }
    });

    // Properly includes generics in output
    let (imp, ty, wher) = generics.split_for_impl();

    // Rust code output
    Ok(quote! {
        impl #imp std::fmt::Display for #ident #ty #wher {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                write!(f, "{}", #transform (
                    match self { #(#match_arms)* }
                ))
            }
        }
    })
}

// Declares the name of the macro and the attributes it supports
#[proc_macro_derive(Display, attributes(display))]
pub fn derive_display(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
    let input = parse_macro_input!(input as DeriveInput);

    FromDeriveInput::from_derive_input(&input)
        .and_then(expand)
        .map(Into::into)
        // Error handling
        .unwrap_or_else(|e| e.write_errors().into())
}

Этот код также доступен на GitHub.

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

Дальнейшее чтение

Я инженер-программист NEAR Protocol, руководитель отдела образования Blockchain Acceleration Foundation и аспирант Токийского технологического института.

Свяжитесь со мной в Твиттере.

Поправка от матклад:

Процесс оценки макросов… запутан, правильнее будет сказать, что «в компиляторе Rust синтаксический анализ, разрешение имен и раскрытие макросов являются взаимно рекурсивными процедурами, которые происходят одновременно». К счастью, я думаю, что для целей этого поста нам не нужно объяснять, когда происходит расширение макроса, достаточно сказать: «токены — это то, что используется в качестве ввода или вывода макроса. Макросы не имеют прямого доступа к проанализированному AST, но макрос может сам анализировать входные токены».

Первоначально опубликовано на https://geeklaunch.io.