Привет, Хабр!
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)
NeoCode
30.03.2024 13:44+12Такое чувство что языки Rust и С++ соревнуются, у кого более непонятное и запутанное метапрограммирование. Между тем, синтаксические макросы сами по себе - простая концепция, и при правильной реализации достаточно одной единственной разновидности, а не четырех.
Vlad2001MFS
30.03.2024 13:44Не, зря ты так. Понятно, что если на Rust ты не пишешь, то понимать это все достаточно трудно, но если языком пользуешься, то у Rust код макросов достаточно хорошо читается и поддерживается.
Какие-то простые вещи, возможно, с первого взгляда будут казаться и сложнее, чем в плюсах, однако чем сложнее логика макроса, тем сильнее начинает выигрывать Rust и выигрывать ощутимо.
Но я все-таки не говорю, что макросы в Rust идеальны. Конечно, можно бы и еще получше :)
mayorovp
30.03.2024 13:44+1Увы, но не так-то просто эту самую единственную разновидность синтаксических макросов подружить с подсветкой синтаксиса в IDE и контекстными подсказками в ней же.
yomayo
30.03.2024 13:44+2Макросы — это чистые функции, исполняемые во время компиляции. Достаточно иметь в языке функции, чистота которых гарантирована, и это известно во время компиляции. Тогда такие функции, имея константные аргументы, могут быть выполнены во время компиляции. И тогда макросам вообще не нужен специальный синтаксис.
NeoCode
30.03.2024 13:44+3Чистые функции это самая очевидная, но не единственно возможная реализация. Нет ничего плохого в том, чтобы создавать во время компиляции объекты, сохраняющие состояние между вызовами макросов и т.п. (и даже взаимодействовать с внешним миром - к примеру читать и писать в файлы и базы данных). Другое дело что такой код сложнее отлаживать, и ошибки в нем могут привести к некорректной работе компилятора.
А макросы в Си - это вообще подстановка без выполнения кода при компиляции (т.е. чистая квазицитата). И как показывает практика, во многих случаях такие макросы более чем достаточны (другое дело что в Си они реализованы на лексическом, а не на синтаксическом уровне, отсюда они игнорируют структуру кода, области видимости, права доступа и т.п.). В Rust этому вроде как соответствуют декларативные макросы (и они таки синтаксические), но какой же кривой и мозгодробительный синтаксис!
Starche
30.03.2024 13:44+2В rust макросы могут быть не чистыми, и это активно используется. Например, крейт sqlx, имея подключение к базе данных, может проверять валидность sql запросов во время компиляции.
mayorovp
30.03.2024 13:44+1Ну и как вы без специального синтаксиса сделаете макрос, добавляющий ну вот хотя бы возможность сериализации структуры? Вот чтобы в цикле пройтись по всем полям и сериализовать их, учитывая их тип данных?
Или вот взять макрос intrusive_adapter из крейта intrusive_collections, добавляющий новую структуру с особой небезопасной реализацией конкретного трейта. Как его делать просто на чистых функциях?
tenzink
30.03.2024 13:44Интересно, почему
vec!
не делаетreserve
. Размер ведь известен во время компиляцииiliazeus
30.03.2024 13:44+7Потому что это просто пример того, как это можно реализовать, написанный автором статьи. В самом Rust он реализован по-другому. Если в двух словах: в случае перечисления элементов, вектор создаётся из boxed slice - то есть, как раз за одну аллокацию нужного размера.
olegkusov
Статья не очень по одной простой причине - не объясняется нормально синтаксис макросов. Нет примеров использования по каждому макросу в материале. Без всего этого статья для незнающего человека бесполезна. Вам может казаться что все просто и понятно, но для человека кто видит синтаксис впервые , увы, нет