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

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

Комментарии (11)


  1. olegkusov
    30.03.2024 13:44
    +34

    Статья не очень по одной простой причине - не объясняется нормально синтаксис макросов. Нет примеров использования по каждому макросу в материале. Без всего этого статья для незнающего человека бесполезна. Вам может казаться что все просто и понятно, но для человека кто видит синтаксис впервые , увы, нет


  1. NeoCode
    30.03.2024 13:44
    +12

    Такое чувство что языки Rust и С++ соревнуются, у кого более непонятное и запутанное метапрограммирование. Между тем, синтаксические макросы сами по себе - простая концепция, и при правильной реализации достаточно одной единственной разновидности, а не четырех.


    1. Vlad2001MFS
      30.03.2024 13:44

      Не, зря ты так. Понятно, что если на Rust ты не пишешь, то понимать это все достаточно трудно, но если языком пользуешься, то у Rust код макросов достаточно хорошо читается и поддерживается.
      Какие-то простые вещи, возможно, с первого взгляда будут казаться и сложнее, чем в плюсах, однако чем сложнее логика макроса, тем сильнее начинает выигрывать Rust и выигрывать ощутимо.
      Но я все-таки не говорю, что макросы в Rust идеальны. Конечно, можно бы и еще получше :)


    1. mayorovp
      30.03.2024 13:44
      +1

      Увы, но не так-то просто эту самую единственную разновидность синтаксических макросов подружить с подсветкой синтаксиса в IDE и контекстными подсказками в ней же.


  1. yomayo
    30.03.2024 13:44
    +2

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


    1. NeoCode
      30.03.2024 13:44
      +3

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

      А макросы в Си - это вообще подстановка без выполнения кода при компиляции (т.е. чистая квазицитата). И как показывает практика, во многих случаях такие макросы более чем достаточны (другое дело что в Си они реализованы на лексическом, а не на синтаксическом уровне, отсюда они игнорируют структуру кода, области видимости, права доступа и т.п.). В Rust этому вроде как соответствуют декларативные макросы (и они таки синтаксические), но какой же кривой и мозгодробительный синтаксис!


    1. Starche
      30.03.2024 13:44
      +2

      В rust макросы могут быть не чистыми, и это активно используется. Например, крейт sqlx, имея подключение к базе данных, может проверять валидность sql запросов во время компиляции.


    1. mayorovp
      30.03.2024 13:44
      +1

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

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


    1. iliazeus
      30.03.2024 13:44
      +1

      То, что вы описываете, похоже скорее на const fn, которые в Rust тоже есть.


  1. tenzink
    30.03.2024 13:44

    Интересно, почему vec! не делает reserve. Размер ведь известен во время компиляции


    1. iliazeus
      30.03.2024 13:44
      +7

      Потому что это просто пример того, как это можно реализовать, написанный автором статьи. В самом Rust он реализован по-другому. Если в двух словах: в случае перечисления элементов, вектор создаётся из boxed slice - то есть, как раз за одну аллокацию нужного размера.