Макросы в Rust

Привет, Хабр! Тема, рассматриваемая в этой статье подойдёт тем, кто только знакомиться с синтаксисом и особенностями Rust. При написании программ на Rust вам так или иначе придётся использовать макросы, в различных их видах, о которых мы поговорим ниже.
Для чего вообще существуют макросы?
Макросы - это не "магическая сущность", а лишь способ разработки программ. Макросы относят к мета программированию. Мета программирование - это написание программного кода, который на этапе компиляции анализирует, изменяет или генерирует другой код. При использовании макроса можно генерировать различные типажи, при этом не реализуя всё "в ручную", что помогает сделать код значительно чище.В отличие от обычных функций, которые работают с данными в момент выполнения программы (в рантайме), макросы работают с синтаксическим деревом программы ещё до её запуска. Процесс чтения и обработки макросов запускается компилятором ещё до понимания логики или чтения типов - на этапе компиляции.У макросов есть свои преимущества, к примеру то, что макросы имеют переменную длину принимаемых аргументов, а не фиксированную (println!("hello!") или println!("hello, {}!", name)), как при реализации стандартных функций. Также важным отличием является то, что объявлять макросы или добавить их в область видимости прежде, чем вызывать их в файле, в отличие от функций, которые можно объявлять где угодно и вызывать из любого места.Недостатком реализации макроса, вместо функции является то, что определения макросов обычно более сложные, чем определение функций, что снижает читаемость кода. Это связанно с тем, что вы создаёте Rust-код, который записывает другой Rust-код.
Виды макросов, их отличия и применения
В Rust существует два основных вида макросов: декларативные и три вида процедурных:
Пользовательские - макросы, которые указывают код, добавленный с помощью атрибута
derive, используемые для структур и перечислений.Макросы подобные атрибутам, которые определяют настраиваемые атрибуты, используемые для любого элемента языка.
Похожие на функции, которые выглядят как вызовы функций, но работают с
TokenStream.
Декларативные макросы
Декларативные макросы - это одни из наиболее часто используемых типов макросов. В целом, декларативные макросы позволяют писать нечто похожее на match в Rust. Эти макросы сравнивают значение с шаблонами, а затем запускают код для сопоставляемой ветки. Макросы также могут сравнивать значения с шаблонами кода Rust, переданными в макрос. Шаблоны сравниваются со структурами этого кода и при совпадении код, связанный с каждым шаблоном, заменяет код переданный макросу.
Наиболее простым примером использования декларативных макросов является простое сопоставление:
macro_rules! test_macros{
() => {
test1()
};
($func:ident) => {
$func();
}
}
fn test1() {
println!("Func test1 is running!");
}
fn test2() {
println!("Func test2 is running!");
}
fn test3() {
println!("Func test3 is running!");
}
fn main() {
test_macros!();
test_macros!(test2);
test_macros!(test3);
}Вывод будет следующим:
Func test1 is running!
Func test2 is running!
Func test3 is running!Краеугольным камнем декларативных макросов является макрос macro_rules! с помощью которого создаются другие макросы. Синтаксис внутри него строится на правилах сопоставления: слева описывается шаблон, а справа - код, на который этот шаблон будет заменён. Также используются спецификаторы, которые приведены ниже:
ident- Идентификатор - имя переменной, функции, структуры или модуля. К примеру может использоватьсяmy_var,User,print_hello.expr- Выражение, любой код, который возвращает значение. К примеру5 + 5,user.age,true,foo().ty- Тип данных. К примеруi32,String,Vec<u8>.stmt- Действие, которое обычно заканчивается точкой с запятой. К примеруlet x = 5;,x += 1;.block- Блок кода, обёрнутый в фигурные скобки. К примеру{let a = 1; a + 2}.path- Путь к модулю, типу или функции. К примеруstd::collections::HashMap.item- Глобальные конструкции на уровне модуля. К примеруfn main() {},struct User{}.trait MyTrait{}.literal- Константное значение. К примеру"строка",42,true.tt- Дерево токенов. Это самый мощный спецификатор, принимает вообще любой кусок валидного синтаксиса Rust.
Также стоит сказать, что помимо спецификаторов, в шаблоне можно писать обычные символы, которые пользователь будет обязан написать при вызове макроса.
Далее необходимо указать важные моменты, касательно декларативных макросов. Во-первых, макросы в Rust являются частично гигиеничными. Это значит, что локальные переменные, созданные внутри макроса не "утекают" наружу и не конфликтуют с переменными в том месте, где был вызван макрос.Во-вторых, порядок объявления имеет значение. В Rust вы можете вызывать функцию до того, как она объявлена в файле, с макросами так не работает. Макрос обязательно должен быть определён до того места, где он используется. Чтобы использовать макрос в других модулях или крейтах, над ним обязательно нужно поставить атрибут #[macro_export]. В третьих, макросы не просто склеивают текст, они работают с синтаксическим деревом (AST). Вы обязаны указывать, что именно вы ожидаете получить. Также, я настоятельно рекомендую установить утилиту cargo-expand для написания макросов, чтобы посмотреть, во что именно развернулся ваш макрос перед тем, как код начал компилироваться.
Процедурные макросы
Вторая форма макросов - процедурные, которые действую как функции и являются типом процедуры. Процедурные макросы принимают какой-либо код в качестве входных данных, работают над этим кодом и создают некоторый код в качестве вывода, а не выполняют сопоставления с шаблонами и замену кода другим кодом, как это делают декларативные макросы.
Перед тем, как продолжить, я обязан сразу предостеречь читателя и указать на важный технический нюанс при работе с процедурными макросами. Процедурные макросы обязаны находиться в отдельном крейте, в файле Cargo.toml которого будет пометка proc-macro = true. Также, этот крейт не имеет право экспортировать ничего, кроме самих макросов.
Такая логика с первого взгляда кажется достаточно глупой, но если рассмотреть по ближе, становится понятно, почему разработчики Rust пришли именно к такому компромиссу. К примеру вы разрабатываете программу для умной духовки. Вы разрабатываете её на последнем Mac с ARM процессором. Обычный ваш код (структуры, трейты) компилятор переводит в инструкции для духовки (target), но процедурный макрос - это программа, которая должна запуститься прямо сейчас, во время компиляции на вашем Mac (host), чтобы сгенерировать код.
Если бы Rust разрешил смешивать обычные структуры и процедурные макросы в одном крейте, компилятору бы пришлось компилировать один и тот же файл под две совершенно разные архитектуры одновременно, пытаясь понять, какие куски кода нужны для запуска на Mac, а какие "полетят в духовку". Чтобы не "сойти с ума" от этого, разработчики Rust и приняли решение о жёстком разделении ответственностей.
Ограничение на экспорт типов здесь тоже понятно, так как крейт с proc-macro = true компилируется как плагин для компилятора, внешние крейты не могут импортировать из него обычные типы, структуры или функции. Вы можете писать в таком крейте вспомогательные функции для парсинга, но экспортировать наружу (pub) можно только функции, помеченные атрибутами #[proc_macro], #[proc_macro_derive], #[proc_macro_attribute].
Теперь, когда важный технический аспект был рассмотрен, можно переходить к трём видам процедурных макросов.
Derive-макросы (пользовательские)
Это самый частый и безопасный вид процедурных макросов. Вы вешаете атрибут #[derive(...)] над структурами, перечислениями или union, чтобы автоматически сгенерировать для них какой-то стандартный код (чаще всего реализацию трейта).
Ключевой особенностью является то, что derive-макросы работают строго в режиме дополнения. Они получают на вход код вашей структуры, могут прочитать её название и список полей, но они не могут изменить саму структуру. Всё, что они возвращают, компилятор просто дописывает ниже исходного кода.
Для примера работы derive-макроса, ниже представлен простой код, который будет добавлять структуре метод hello() для печати её имени.
1. Подготовка Cargo.toml в крейте с макросом:
[package]
name = "derive-macro"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
syn = { version = "2.0", features = ["derive"] }
quote = "1.0"2. Написание логики работы самого макроса:
// ФАЙЛ lib.rs!!!
extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(Hello)]
pub fn hello_derive(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
let expanded = quote! {
impl #name {
pub fn hello(&self) {
println!("Hello, my name is {}!", stringify!(#name));
}
}
};
expanded.into()
}3. Подключаем макрос в основной Cargo.toml проекта:
[package]
name = "test-macro"
version = "0.1.0"
edition = "2024"
[dependencies]
# Подключаем как зависимость, указываем ПРАВИЛЬНЫЙ путь до макроса:
hello_macro = { path = "../derive-macro" }4. Интегрируем макрос в код основной программы, подключаем его к структурам:
use derive_macro::Hello;
#[derive(Hello)]
struct User;
#[derive(Hello)]
struct Admin;
fn main() {
let user = User;
let admin = Admin;
// Запускаем методы из макросов:
user.hello();
admin.hello();
}После запуска должен быть вывод:
У кого-то из читателей может сложиться ощущение, что этот пример кода - бред, поскольку в зависимости при реализации макроса были добавлены два сторонних крейта. Но дело в том, что самому с нуля писать парсер кода syn просто не имеет смысла, разве что для того, чтобы потешить своё эго. Все основные тонкие места уже были учтены в этих крейтах и по этому с помощью них можно написать действительно стоящие, рабочие макросы без тонны кода.
Но это лишь простой пример, в реальном production-коде есть некоторые усложнения, из-за которых этот "карточный домик" может разлететься. Проблемой может стать использование generic и ограничения по времени в нашей структуре. Если использовать текущую реализацию макроса со структурой, в которой используются generic или временные ограничения, то макрос сгенерирует такой код:
impl User{
pub fn hello(&self) {
println!("Hello, my name is User!")
}
}Но чтобы код скомпилировался мы обязаны будем генерировать правильную сигнатуру с указанием generic и времени. Вытаскивать <T> и where в ручную из AST это неоправданно долго и сложно, но крейт syn решает эту проблему с помощью метода ast.generics.split_for_impl(). Он берёт все дженерики структуры и разбивает их на три готовых куска, которые мы просто вставляем в quote!. Таким образом, исправленный вариант для production будет выглядеть так:
#[proc_macro_derive(Hello)]
pub fn hello_derive(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl();
let expanded = quote! {
impl #impl_generics #name #ty_generics #where_clause {
pub fn hello(&self) {
println!("Hello, my name is {}!", stringify!(#name));
}
}
};
expanded.into()
}Теперь этот макрос можно использовать и в структурах с generic и с ограничениями по времени.
Атрибутный макрос
Ключевой особенностью этого типа макросов является то, что он полностью поглощает и изменяет существующий код. Если атрибутный макрос примет функцию, сгенерирует println!("Hello"); и вернёт только его, оригинальная функция буквально исчезнет из программы. Компилятор вставит вместо неё то, что вернул макрос. Это даёт большую власть, но требует осторожности.Ещё одним отличием от derive-макроса является то, что атрибутный макрос можно вешать на что угодно (функции, модули, трейты, impl-блоки), в отличие от derive, который работает только с типами данных.Также, в отличие от derive, атрибутный макрос принимает на вход два потока токенов. Для демонстрации работы необходимо рассмотреть следующий пример, который будет логировать вызов и завершение функции, на которую он был "повешан":
1. Реализация самого макроса:
extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};
#[proc_macro_attribute]
pub fn log_all(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input_fn = parse_macro_input!(item as ItemFn);
let fn_vis = &input_fn.vis;
let fn_sig = &input_fn.sig;
let fn_block = &input_fn.block;
let fn_name = &fn_sig.ident;
let expanded = quote! {
#fn_vis #fn_sig {
println!(">>> Входим в функцию: {}", stringify!(#fn_name));
let __result = (|| #fn_block)();
println!("<<< Выходим из функции: {}", stringify!(#fn_name));
__result
}
};
expanded.into()
}2. Использование в коде:
use custom_macro::log_all;
#[log_all]
pub fn sum(a: i32, b: i32) -> i32{
a + b
}
fn main() {
let res = sum(2, 2);
println!("Результат: {}", res);
}Результат выполнения должен быть таким:
>>> Входим в функцию: sum
<<< Выходим из функции: sum
Результат: 4Интересным моментом в этом коде является ItemFn. Как можно было заметить по примеру кода из derive-атрибута, там использовался DeriveInput, потому что ждали структуру. В syn есть множество различных сущностей, но это тема уже для отдельной статьи...
Также в этом примере был проигнорирован аргументы атрибута attr, хотя в реальном коде обычно его используют. Чтобы макрос мог принимать параметры нужно отдельно парсить поток attr. В syn для этого также есть методы, которые позволяют удобно разбирать аргументы в скобках.
И напоследок, вам стоит правильно обрабатывать ошибки, в моих примерах я этим пренебрёг, чтобы сделать примеры максимально доступными.
Макросы, похожие на функции
Это заключительный тип процедурных макросов. Хоть в Rust уже есть декларативные макросы, которые отлично справляются с генерацией простого, но это лишь просто умный "поиск и замена" по шаблону. Он может работать только с валидным синтаксисом Rust. Макросы, как функции же принимают абсолютно любые токены внутри скобок и может запускать любой Rust-код для их анализа. Именно благодаря им существуют различные крейты, к примеру, которые с помощью макроса html! могут внутри генерировать реальный HTML-код.
Ниже я предоставлю пример реализации такого макроса, суть которого будет генерация простого SQL-запроса:
1. Пишем реализацию макроса:
extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{parse_macro_input, Ident, Token};
struct SelectQuery {
table: Ident,
_arrow: Token![=>],
columns: Punctuated<Ident, Token![,]>,
}
impl Parse for SelectQuery {
fn parse(input: ParseStream) -> syn::Result<Self> {
Ok(SelectQuery {
table: input.parse()?,
_arrow: input.parse()?,
columns: Punctuated::parse_terminated(input)?,
})
}
}
#[proc_macro]
pub fn select_from(input: TokenStream) -> TokenStream {
let query = parse_macro_input!(input as SelectQuery);
let table_name = query.table.to_string();
let column_names: Vec<String> = query.columns
.iter()
.map(|col| col.to_string())
.collect();
let cols_joined = column_names.join(", ");
let sql_string = format!("SELECT {} FROM {}", cols_joined, table_name);
let expanded = quote! {
#sql_string
};
expanded.into()
}2. Использование в коде программы:
use custom_macro::select_from;
fn main() {
let query = select_from!(users => id, username, email);
println!("SQL: {}", query);
}Результат вывода будет таким:
SQL: SELECT id, username, email FROM usersПоскольку компилятор передаёт макросу всё, что находится внутри скобок, как сырой токен у нас появилась возможность использовать стрелку =>, хотя в стандартном Rust такой функции нет. Проверка происходит прямо во время компиляции, по этому если пользователь совершит ошибку в синтаксисе (не =>, а =), то код просто не скомпилируется.
Заключение
Я искренне надеюсь, что моя статья была для вас полезна и интересна. После прочтения статьи у вас должно было сформироваться чёткое понимание различия разных видов макросов, макросы для вас должны стать не чем-то "чужеродным", а как базовая концепция языка Rust. Жду ваших комментариев!
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.