Skip to content

9.3 过程宏

对于一些简单的宏,使用 macro_rules! 声明式宏就足够使用了。

过程宏与声明式宏在工作原理上有着本质的区别。声明式宏是基于模式匹配的文本替换,而过程宏是真正的 Rust 代码,它们接收 proc_macro::TokenStream 作为输入,对其进行解析、操作,然后生成新的 proc_macro::TokenStream 作为输出。

它是直接用Rust语言写出来的,相当 于一个编译器插件。但是编译器插件的最大问题是,它依赖于编译器的 内部实现方式。一旦编译器内部有所变化,那么对应的宏就有可能出现 编译错误,需要修改。因此,Rust中的“宏”一直难以稳定。

所以,Rust 设计者希望提供一套相对稳定一点的 API,它基本跟 rustc 的内部数据结构解耦。 这个设计就是 macro 2.0。这个功能目前暂时还没完成。但是,Rust 提前推出了一个 macro 1.1 版本。 在以后的 macro 2.0 中,我们也可以用类似的 API 设计自定义 attribute。 目前有一个叫作 derive 的 attribute 是最常用的,最需要支持自定义扩展。专门为支持自定义 derive 的功能,就是 macro 1.1。 derive 功能我们在 trait 一章中已经讲过了,attribute 可以让编译器帮我们自动 impl 某些 trait。

在 Rust 中,attribute 也是一种特殊的宏。在编译器内部,attribute 和 macro 并没有本质的区别,它们都是所谓的编译器扩展

过程宏的工作原理非常简单:

  1. 输入:接收一个代表 Rust 代码的 标记流 (TokenStream)
  2. 处理:在函数体内对这个标记流进行任意复杂的分析和操作。
  3. 输出:返回一个新的 TokenStream,编译器会用它替换掉原来的宏调用。

这种“代码进,代码出”的模式赋予了过程宏无与伦比的能力,但也使其比声明式宏更复杂。

过程宏主要分为三种类型:

  • 函数式宏(Function-like Macros):看起来像函数调用,例如 my_macro!(...)。与 macro_rules! 宏类似,但它们是过程宏,可以执行更复杂的逻辑。 它们在函数上使用#[proc_macro]属性。lazy_static 程序库中的lazy_static!宏就采用了类函数过程宏。

    rust
    sql!(SELECT * FROM users);  // 解析SQL语句
  • 属性宏(Attribute-like Macros):通过 #[my_attribute] 属性添加到任何项(函数、结构体、模块等)上。它们可以修改或替换被应用项的代码。例如,我们可以使用 #[route] 属性宏来标记一个函数作为 Web 应用程序中的路由处理器。 wasm-bindgen 程序库中 的#[wasm-bindgen]属性就采用类过程宏。

    rust
    #[route(GET, "/")]
    fn index() { ... }
  • 自定义派生宏(Derive Macros):最常见的一种,用于为结构体和枚举自动实现 Trait,例如 #[derive(Debug)]。 它们使用#[proc_macro_derive]属性。这些是大多数 Rust 软件包中最常见的宏,例如 serde 程序库。

由于引入了它们的 RFC 的名称,它们有时也被称为派生宏或 macro 1.1。

proc_macro::TokenStream

TokenStream 是 Rust 编译器提供的一种数据结构,它代表了一系列 Rust token。这些 token 是 Rust 源代码的最小语法单元,例如关键字、标识符、运算符、字面量等。过程宏接收 TokenStream 作为输入,并返回 TokenStream 作为输出。

TokenStream 实现了 Iterator trait,这意味着你可以像处理迭代器一样处理它,遍历其中的 token。然而,直接操作 TokenStream 是非常低级的,通常我们会使用 syn crate 来将其解析成更高级的抽象语法树(AST)。

9.3.1 函数式宏

与声明式宏不同,过程宏必须定义在它们自己的、特殊类型的 crate 中。这是一个强制要求,因为过程宏是作为编译器插件来加载和执行的。

我们通过一个项目结构来演示:

  1. 创建一个用于定义过程宏的 crate,我们称之为 macro-lib
  2. 创建一个用于使用该宏的普通 crate,我们称之为 my-app
sh
cargo new my-app
cd my-app
cargo new macro-lib --lib

macro-lib 中进行:

  1. Cargo.toml新增:
toml
[lib]
proc-macro = true # 开启过程宏
  1. lib.rs
rust
#![crate_type = "proc-macro"]
extern crate proc_macro;
use proc_macro::TokenStream;

#[proc_macro]
pub fn make_answer(_item: TokenStream) -> TokenStream {
    "fn answer() -> u32 { 42 }".parse().unwrap()
}

完成无误后运行:cargo build

my-app 目录下进行:

  1. Cargo.toml 新增:
toml
[dependencies]
# 使用 path 指向本地的宏 crate
macro-lib = { path = "macro-lib" }
  1. main.rs
rust
use macro_lib::make_answer;

make_answer!();

fn main() {
    println!("{}", answer());
}

运行:

sh
cargo install --path .
cargo run

输出:42

9.3.2 属性宏

派生宏只能用于 struct 和 enum。而属性宏更加灵活,它可以附加到几乎任何项上,例如函数。

属性宏的函数签名与派生宏不同,它接收两个 TokenStream

  • attr: 属性本身的内容,即 #[my_attr(...)] 中括号里的部分。

  • item: 被该属性附加的代码项,例如整个函数定义。

可以按上面的函数式宏方式来构建个项目:

创建一个简单的属性宏 #[show_streams],它会打印出它接收到的两个 TokenStream,这对于学习和调试宏非常有帮助。

macro-lib 中lib.rs更改为:

rust
extern crate proc_macro;
use proc_macro::TokenStream;

#[proc_macro_attribute]
pub fn show_streams(attr: TokenStream, item: TokenStream) -> TokenStream {
    println!("attr: \"{}\"", attr.to_string());
    println!("item: \"{}\"", item.to_string());
    item
}

hello-macro 目录下 main.rs更改为:

rust
extern crate macro_lib;
use macro_lib::show_streams;

// Example: Basic function
#[show_streams]
fn invoke1() {}

// Example: Attribute with input
#[show_streams(bar)]
fn invoke2() {}

// Example: Multiple tokens in the input
#[show_streams(multiple => tokens)]
fn invoke3() {}

// Example:
#[show_streams { delimiters }]
fn invoke4() {}

fn main(){
  invoke1();
  invoke2() ;
  invoke3() ;
  invoke4() ;
}

运行后输出:

txt
attr: ""
item: "fn invoke1() {}"
attr: "bar"
item: "fn invoke2() {}"
attr: "multiple => tokens"
item: "fn invoke3() {}"
attr: "delimiters"
item: "fn invoke4() {}"

这清晰地展示了属性宏的输入。在实际应用中,我们会使用 syn 去解析 attritem,然后根据属性的参数来修改或包装原始的函数。

9.3.3 自定义派生宏

目前,编译器的 derive 只支持一小部分固定的 trait。但我们可以通过自定义宏实现扩展 derive。 下面,我们用一个示例来演示一下自定义#[derive(HelloWorld)]功能。

可以参考上面的函数式宏的项目创建。

宏库项目编译完成后,会生成一个动态链接库。这个库会被编译器在编译主项目的过程中调用。在主项目代码中写上如下测试代码:

rust
#[macro_use]
extern crate hello_world_derive;

// 1. 定义我们希望宏能为其生成代码的 Trait
trait THelloWorld {
    fn hello();
}

// 2. 在结构体上使用我们自定义的派生宏
#[derive(HelloWorld)]
struct FrenchToast;


fn main() {
   // 3. 调用宏生成的函数
    FrenchToast::hello();
}

接下来,我们来实现这个宏。它的代码骨架如下所示:

rust
extern crate proc_macro;

use proc_macro::TokenStream;
use std::str::FromStr;

#[proc_macro_derive(HelloWorld)]
pub fn hello_world(input: TokenStream) -> TokenStream {
    // Construct a string representation of the type definition
    let s = input.to_string();
    TokenStream::from_str("").unwrap()
}

我们的主要逻辑就写在hello_world函数中,它需要用proc_macro_derive修饰。它的签名是,输入一个TokenStream,输出一个TokenStream。 目前这个TokenStream类型还没实现什么有用的成员方法,暂时只提供了和字符串类型之间的转换方式。我们在函数中把input的值打印出来:

rust
let s = input.to_string();
println!("{}", s);

编译可见,输出值为struct FrenchToast;。由此可见,编译器将#[derive()]宏修饰的部分作为参数,传递给了我们这个编译器扩展函数。

我们需要对这个参数进行分析,然后将希望自动生成的代码作为返回值传递出去。

在这里,我们引入 regex 库来辅助实现逻辑。在项目文件中,加入以下代码:

toml
[dependencies]
regex = "0.2"

然后写一个函数,把类型名字从输入参数中提取出来:

rust
fn parse_struct_name(s: &str) -> String {
    let r = Regex::new(r"(?:struct\s+)([\w\d_]+)").unwrap();
    let caps = r.captures(s).unwrap();
    caps[1].to_string()
}

#[test]
fn test_parse_struct_name() {
    let input = "struct Foo(i32);";
    let name = parse_struct_name(input);
    assert_eq!(&name, "Foo");
}

接下来,就可以自动生成我们的 impl 代码了:

rust
#[proc_macro_derive(HelloWorld)]
pub fn hello_world(input: TokenStream) -> TokenStream {
    let s = input.to_string();
    let name = parse_struct_name(&s);
    let output = format!(r#"
impl THelloWorld for {0} {{
    fn hello() {{ println!(" {0} says hello "); }}
}}"#, name);

    TokenStream::from_str(&output).unwrap()
}

我们构造了一个字符串,然后将这个字符串转化为TokenStream类型返回。

编译主项目可见,FrenchToast类型已经有了一个hello()方法,执行结果为:

txt
FrenchToast says hello

在 macro 1.1 版本中,只提供了这么一点简单的 API。 在接下来的 macro 2.0 版本中,会为 TokenStream 添加一些更有用的方法,或许那时候就没必要把 TokenStream 转成字符串再自己解析一遍了。

9.3.4 常用的过程宏软件包

由于过程宏可以作为独立的软件包进行分发,因此可以在 crates.io 上找到许多实用的宏软件包。 通过它们可以大大减少为生成 Rust 代码而手动编写模板的工作量。其中一些如下所示。

  • derive-new:该软件包为结构体提供了默认的全字段构造函数,并且支持自定义。

  • derive-more:该软件包可以绕过这样的限制,即我们已经为类型包装了许多自动实 现的特征,但是失去了为其创建自定义类型包装的能力。 该软件包可以帮助我们提 供相同的特征集,即使是在这种包装器类型上也是如此。

  • lazy_static:该软件包提供了一个类函数的过程宏,其名为lazy_static!,你可以在其 中声明需要动态初始化类型的静态值。 例如,你可以将配置对象声明为 HashMap, 并可以跨代码库全局访问它。

总结与对比

过程宏为 Rust 提供了强大的元编程能力,是许多高级库(如 serdetokioactix-web)的基石,Rust 的异步编程模型 async/await 也是通过宏实现的。

宏类型声明属性函数签名调用方式主要用途
自定义派生#[proc_macro_derive(Name)]fn(TokenStream) -> TokenStream#[derive(Name)]struct/enum 自动实现 Trait
属性宏#[proc_macro_attribute]fn(attr: T, item: T) -> T#[name(...)]修改或包装任何代码项
函数式宏#[proc_macro]fn(TokenStream) -> TokenStreamname!(...)创建类似函数的、功能强大的宏

当你需要根据代码的结构(如结构体字段、函数签名)来动态生成代码时,过程宏是你唯一的选择。虽然入门门槛稍高,但它所带来的表达力和自动化能力是无与伦比的。

Released under the MIT License