初次尝试 Rust Macro 宏
逛 Reddit 上的帖子,看到有人说在面试中被问到了 Rust 宏的使用,但在实际开发中发现公司使用宏为了减少 CRUD 代码(ps. 虽然还不知道怎么使用宏来减少 CRUD 的,挺好奇的🤔),并没有太多深入使用宏,那就稍微浅尝辄止一下,了解 Rust 中的宏。
宏有什么用?
- 减少代码量:
- 提高代码可读性
- 提高代码可维护性
// 不用宏
let mut list = Vec::new();
list.push(1);
list.push(2);
list.push(3);
// 用宏,一行代码创建了一个 Vec 类型的实例,并且向其中添加了三个元素
let list = vec![1, 2, 3];
// 试想有个需求:为每个前端请求的接口增加一个统计耗时的记录,每个接口都要加想想就头大,如果使用宏就可以简化这个过程
// 定义一个属性宏,再为调用函数加上#[timeit] 注解,就可以实现记录耗时的功能
以下都是标准库中内置的宏
- vec!: 用于创建 Vec 类型的实例
- println!: 用于打印格式化的字符串
- format!: 用于格式化字符串并返回一个字符串
宏的抽象层次很高,掌握起来不容易。Rust 中宏分为macro_rules!
声明宏和三种过程宏。
声明宏
使用 macro_rules!
关键字定义,通过match
模式匹配,匹配到不同的模式执行相关代码。实现一个精简的vec!
宏
#[macro_export]
macro_rules! my_vec {
() => {
Vec::new()
};
($($x:expr),*) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
这个宏可以创建一个空的 Vec 实例,也可以创建一个带有初始元素的 Vec 实例。
let v1 = my_vec!();
let v2 = my_vec![1, 2, 3];
- 第一个模式:
()
,匹配空括号,创建一个空的 Vec 实例 - 第二个模式:
($($x:expr),*)
,匹配括号内的任意数量的表达式,创建一个带有初始元素的 Vec 实例。 $x:expr
匹配一个表达式,将其存储在变量x
中*
匹配零个或多个$x:expr
模式$()
中的代码块会被重复执行,每次执行都会将$x
表达式添加到temp_vec
中
宏 hygiene
Rust 宏是有自己的作用域的,不会污染外部作用域。
#[macro_export]
macro_rules! my_vec {
($x:expr) => {
{
let mut temp_vec = Vec::new();
temp_vec.push($x);
temp_vec
}
};
}
fn main() {
let x = 1;
let v = my_vec!(x);
println!("{:?}", v); // [1]
}
在这个例子中,宏内部的 temp_vec
和外部的 x
是不同的变量,不会发生冲突。
宏的作用域规则与函数作用域规则相同,宏内部的变量和外部的变量不会发生冲突。
声明式宏 token 类型
- $ident: 匹配一个标识符,如变量名、函数名、结构体名等
- $expr: 匹配一个表达式,如数字、字符串、函数调用等
- $pat: 匹配一个模式,如变量模式、结构体模式等
- $tt: 匹配一个 token tree,如标识符、表达式、模式等
- $block: 匹配一个块表达式,如
{ ... }
- $stmt: 匹配一个语句,如
let x = 1;
- $item: 匹配一个项,如函数、结构体、枚举等
- $meta: 匹配一个元数据,如属性、宏调用等
- $vis: 匹配一个可见性修饰符,如
pub
、pub(crate)
、pub(in path)
等
过程宏
过程宏必须在自己的 crate 里,然后在其他 crate 使用。(ps. 这里费了很多时间,终于知道如何在主项目中创建 creat 并调用,一开始以为是用mod
模块的形式来让其他模块来调用宏🤦♂️)
过程宏需要导入两个依赖,用于解析TokenStream
,并生成新的TokenStream
。syn
和quote
,可以使用 cargo add syn
和 cargo add quote
命令添加。
TokenStream
是一个迭代器,用于遍历宏的输入,syn
提供了解析TokenStream
的方法,quote
提供了生成TokenStream
的方法。
过程宏可以分为三类:
- 自定义派生宏
- 自定义属性宏
- 类函数宏
1. 自定义派生宏
用在结构体上,用来自动实现某些 trait,如#[derive(Debug)]
,可以实现 trait,也可以不实现 trait,没有强制。
pub trait Hello {
fn hello(&self);
}
#[proc_macro_derive(HelloMacro)]
pub fn hello_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = input.ident;
// 生成 impl 代码
TokenStream::from(quote! {
impl Hello for #name {
fn hello(&self) {
println!("Hello, I'm a {}!", stringify!(#name));
}
}
})
}
#[proc_macro_derive(HelloMacro2)]
pub fn hello_derive2(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = input.ident;
quote! {
impl #name {
pub fn hello(&self) {
println!("Hello from {}", stringify!(#name));
}
}
}
.into()
}
HelloMacro2
宏不实现 trait,只生成一个结构体方法。
struct Person;
impl Hello for Person {
fn hello(&self) {
println!("Hello, I'm a Person!");
}
}
#[derive(HelloMacro)]
struct Animal;
#[derive(HelloMacro2)]
struct MyStruct;
#[derive(HelloMacro)]
struct MyStruct2;
fn main() {
let person = Person;
person.hello();
let animal = Animal;
animal.hello();
let my_struct = MyStruct;
my_struct.hello();
// 测试多态情况下,宏是否有用
let v: Vec<Box<dyn Hello>> = vec![Box::new(Person), Box::new(Animal), Box::new(MyStruct2)];
// 下面代码编译报错,因为 MyStruct2 没有实现 Hello trait
// let v: Vec<Box<dyn Hello>> = vec![Box::new(Person), Box::new(Animal), Box::new(MyStruct)];
for item in v {
item.hello();
}
}
2. 自定义属性宏
前文提到的那个为每个前端请求的接口增加一个统计耗时的记录,就可以使用属性宏来实现。
#[proc_macro_attribute]
pub fn timeit(_attr: TokenStream, item: TokenStream) -> TokenStream {
// `_attr` 是属性本身的 TokenStream,这里我们不需要参数,所以忽略它。
// `item` 是属性所附加的项(在这里是函数)的 TokenStream。
// 1. 解析输入的函数代码
// parse_macro_input! 帮助我们解析 TokenStream 为 syn 库中的结构体
// ItemFn 代表一个函数定义
let input_fn = parse_macro_input!(item as ItemFn);
// 2. 提取函数的各个部分
let function_name = &input_fn.sig.ident; // 函数名 (Ident)
let signature = &input_fn.sig; // 函数签名 (Signature)
let body = &input_fn.block; // 函数体 (Block)
let attributes = &input_fn.attrs; // 函数上的其他属性 (Vec<Attribute>)
let visibility = &input_fn.vis; // 函数的可见性 (Visibility)
// 将函数名转换为字符串,以便在 println! 中使用
let function_name_str = function_name.to_string();
// 3. 构建新的代码块
// quote! 宏用于方便地生成 TokenStream
let expanded = quote! {
// 保留原始的属性(如 #[allow(...)] 等)
#(#attributes)*
// 保留原始的可见性、签名(包括 fn async 等关键字和参数/返回值)
#visibility #signature {
// 在函数体执行前记录开始时间
let start_time = std::time::Instant::now();
// 执行原始的函数体,并捕获其返回值
// 将原始函数体放在一个块 {} 中,确保其内部变量不泄露,并捕获返回值
let result = #body;
// 等待一秒钟
std::thread::sleep(std::time::Duration::from_secs(3));
// 在函数体执行后记录结束时间并计算时长
let duration = start_time.elapsed();
// 打印函数名和执行时间
// 使用 #function_name_str (字符串字面量) 代替 #[function_name] (Ident)
println!("Function '{}' executed in {:?}", #function_name_str, duration);
// 返回原始函数体的结果
result
}
};
// 4. 将生成的代码块转换回 TokenStream 并返回
expanded.into()
}
#[timeit]
fn test_func_time() {
std::thread::sleep(std::time::Duration::from_secs(2));
println!("test_func_time completed");
}
fn main() {
test_func_time();
}
有意思的是在属性宏里使用sleep
方法,可以阻塞线程。在编写 Rust 属性宏时,让我想起了在 Java Spring
框架中的AOP
(动态代理)特性,通过在方法执行前后添加切面逻辑,可以实现统计耗时、日志记录、权限检查等功能。
3. 类函数宏
类函数宏与声明宏类似,但比声明宏更强大,支持模式匹配,可以匹配更多的语法结构。还没找到合适的例子,后续有时间再补充。