Делаем макросы в Rust

17941b74b364b002edbf12b8e3f50f58.png

Привет, Хабр!

Rust имеет два основных типа макросов: декларативные и процедурные. Каждый из этих типов служит различным целям и предоставляет различные возможности манипуляции с кодом.

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

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

В этой статье мы как раз и рассмотрим то, как их пишут на Rust.

Начнем с декларативных!

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

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

Пример определения макроса vec!:

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

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

Рассмотрим макрос power!, который может вычислять квадрат или куб числа:

macro_rules! power {
    ($value:expr, squared) => { $value.pow(2_i32) };
    ($value:expr, cubed) => { $value.pow(3_i32) };
}

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

Макросы могут принимать переменное количество входных данных. Например, вызов vec![2] или vec![1, 2, 3] использует операторы повторения подобно Regex. Чтобы добавить n чисел, можно использовать следующую конструкцию:

macro_rules! adder {
    ($($right:expr),+) => {{
    let mut total: i32 = 0;
    $( 
        total += $right;
    )+
    total
}};

В данном случае мы суммируем все переданные значения. + после фрагмента кода указывает, что данный фрагмент может повторяться один или более раз​​.

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

macro_rules! no_trailing {
    ($($e:expr),*) => {}
};

macro_rules! with_trailing {
    ($($e:expr,)*) => {}
};

macro_rules! either {
    ($($e:expr),* $(,)*) => {}
};

Для выполнения нескольких действий внутри макроса, как вы уже наверное заметили, используются двойные фигурные скобки:

macro_rules! etwas {
    ($value:expr, squared) => {{ 
        let x: u32 = $value;
        x.pow(2)
    }}
};

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

macro_rules! operations {
    (add $($addend:expr),+; mult $($multiplier:expr),+) => {{
        let mut sum = 0;
        $(
            sum += $addend;
         )*

         let mut product = 1;
         $(
              product *= $multiplier;
          )*

          println!("Sum: {} | Product: {}", sum, product);
    }} 
};

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

Процедурные макросы в Rust должны быть определены в отдельных крейтах с типом proc-macro в Cargo.toml файле. Процедурные макросы делятся на три основных типа: функциональные, пользовательские derive и атрибутивные​​​​​​.

Для работы с процедурными макросами нужно будет создать отдельный крейт с типом proc-macro в Cargo.toml файле:

[lib]
proc-macro = true

И добавить зависимости syn и quote для парсинга входящих TokenStream и генерации выходного кода.

Функциональные макросы

Функциональные макросы в Rust позволяют создавать расширения языка, которые могут принимать код Rust в качестве входных данных и генерировать код Rust в качестве выходных данных. Они похожи на функции в том смысле, что вызываются с использованием оператора ! и выглядят как вызовы функций. Пример простого функционального макроса:

extern crate proc_macro;
use proc_macro::TokenStream;

#[proc_macro]
pub fn my_fn_like_proc_macro(input: TokenStream) -> TokenStream {
    // логика обработки входного TokenStream
    // и генерации нового TokenStream.
    input
}

Макрос my_fn_like_proc_macro принимает TokenStream в качестве входных данных (который представляет собой код, переданный в макрос) и возвращает TokenStream в качестве выходных данных (который является кодом Rust, сгенерированным макросом).

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

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Ident};

#[proc_macro]
pub fn var_name(input: TokenStream) -> TokenStream {
    let input_parsed = parse_macro_input!(input as Ident);
    
    let name = input_parsed.to_string();
    
    let expanded = quote! {
        {
            let my_var_name = stringify!(#input_parsed);
            println!("Переменная: {}, значение: {}", #name, my_var_name);
        }
    };

    TokenStream::from(expanded)
}

Юзаем крейты syn для парсинга входных данных макроса в AST и quote для генерации кода Rust на основе этого AST. Макрос var_name принимает имя переменной и генерирует код, который выводит имя этой переменной и её значение.

Чтобы использовать этот макрос, нужно написать в коде:

let my_variable = 42;
var_name!(my_variable);

Это вызовет макрос var_name, который сгенерирует код для печати имени и значения переменной my_variable.

Пользовательские derive макросы

Пользовательские derive макросы в Rust позволяют автоматически реализовывать определенные трейты для структур или перечислений.

Создадим простой derive макрос, который будет реализовывать трейт Description для структуры или перечисления, предоставляя им метод describe(), возвращающий строковое представление:

// в crate для процедурных макросов, в lib.rs

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(Describe)]
pub fn derive_describe(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    
    let name = input.ident;
    let gen = quote! {
        impl Description for #name {
            fn describe() -> String {
                format!("This is a {}", stringify!(#name))
            }
        }
    };

    gen.into()
}

Юзаем крейты syn для парсинга входящего TokenStream в структуру DeriveInput, которая предоставляет информацию о типе, к которому применяется макрос. Используем quote! для генерации кода, который реализует трейт Description.

Используем макрос:

// в основном crate

#[derive(Describe)]
struct MyStruct;

trait Description {
    fn describe() -> String;
}

fn main() {
    println!("{}", MyStruct::describe());
}

После добавления макроса #[derive(Describe)] к MyStruct, можно метод describe(), который был автоматически реализован для MyStruct благодаря процедурному макросу.

Атрибутивные макросы

С атрибутивными макросами можно определять пользовательские атрибуты, которые можно применять к различным элементам кода, таким как функции, структуры, модули и т.д. Эти макросы принимают два аргумента: набор токенов атрибута и токен TokenStream элемента, к которому применяется атрибут. Результатом работы атрибутивного макроса является новый TokenStream, который заменяет исходный элемент.

Создадим атрибутивный макрос log_function, который будет добавлять логирование при входе и выходе из функции:

extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};

#[proc_macro_attribute]
pub fn log_function(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let input_fn = parse_macro_input!(item as ItemFn);
    let fn_name = &input_fn.sig.ident;
    let fn_body = &input_fn.block;
    
    let result = quote! {
        fn #fn_name() {
            println!("Entering {}", stringify!(#fn_name));
            #fn_body
            println!("Exiting {}", stringify!(#fn_name));
        }
    };

    result.into()
}

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

use my_proc_macros::log_function;

#[log_function]
fn my_function() {
    println!("Function body execution");
}

После добавления атрибута #[log_function] к функции my_function, при ее вызове в консоли будут отображаться соответствующие сообщения о входе и выходе.

Про самые популярные ЯП и практические инструменты мои коллеги из OTUS рассказывают в рамках практических онлайн-курсов. По ссылке вы можете ознакомиться с полным каталогом курсов, а также зарегистрироваться на бесплатные вебинары.

© Habrahabr.ru