目录

初次尝试 Rust Macro 宏

逛 Reddit 上的帖子,看到有人说在面试中被问到了 Rust 宏的使用,但在实际开发中发现公司使用宏为了减少 CRUD 代码(ps. 虽然还不知道怎么使用宏来减少 CRUD 的,挺好奇的🤔),并没有太多深入使用宏,那就稍微浅尝辄止一下,了解 Rust 中的宏。

宏有什么用?

  1. 减少代码量:
  2. 提高代码可读性
  3. 提高代码可维护性
// 不用宏
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];
  1. 第一个模式:(),匹配空括号,创建一个空的 Vec 实例
  2. 第二个模式:($($x:expr),*),匹配括号内的任意数量的表达式,创建一个带有初始元素的 Vec 实例。
  3. $x:expr 匹配一个表达式,将其存储在变量 x
  4. * 匹配零个或多个 $x:expr 模式
  5. $() 中的代码块会被重复执行,每次执行都会将 $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 类型

  1. $ident: 匹配一个标识符,如变量名、函数名、结构体名等
  2. $expr: 匹配一个表达式,如数字、字符串、函数调用等
  3. $pat: 匹配一个模式,如变量模式、结构体模式等
  4. $tt: 匹配一个 token tree,如标识符、表达式、模式等
  5. $block: 匹配一个块表达式,如 { ... }
  6. $stmt: 匹配一个语句,如 let x = 1;
  7. $item: 匹配一个项,如函数、结构体、枚举等
  8. $meta: 匹配一个元数据,如属性、宏调用等
  9. $vis: 匹配一个可见性修饰符,如 pubpub(crate)pub(in path)

过程宏

过程宏必须在自己的 crate 里,然后在其他 crate 使用。(ps. 这里费了很多时间,终于知道如何在主项目中创建 creat 并调用,一开始以为是用mod模块的形式来让其他模块来调用宏🤦‍♂️

过程宏需要导入两个依赖,用于解析TokenStream,并生成新的TokenStreamsynquote,可以使用 cargo add syncargo add quote 命令添加。 TokenStream 是一个迭代器,用于遍历宏的输入,syn提供了解析TokenStream的方法,quote提供了生成TokenStream的方法。

过程宏可以分为三类:

  1. 自定义派生宏
  2. 自定义属性宏
  3. 类函数宏

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. 类函数宏

类函数宏与声明宏类似,但比声明宏更强大,支持模式匹配,可以匹配更多的语法结构。还没找到合适的例子,后续有时间再补充。