Appearance
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 并没有本质的区别,它们都是所谓的编译器扩展。
过程宏的工作原理非常简单:
- 输入:接收一个代表 Rust 代码的 标记流 (TokenStream)。
- 处理:在函数体内对这个标记流进行任意复杂的分析和操作。
- 输出:返回一个新的
TokenStream
,编译器会用它替换掉原来的宏调用。
这种“代码进,代码出”的模式赋予了过程宏无与伦比的能力,但也使其比声明式宏更复杂。
过程宏主要分为三种类型:
函数式宏(Function-like Macros):看起来像函数调用,例如
my_macro!(...)
。与macro_rules!
宏类似,但它们是过程宏,可以执行更复杂的逻辑。 它们在函数上使用#[proc_macro]
属性。lazy_static 程序库中的lazy_static!
宏就采用了类函数过程宏。rustsql!(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 中。这是一个强制要求,因为过程宏是作为编译器插件来加载和执行的。
我们通过一个项目结构来演示:
- 创建一个用于定义过程宏的 crate,我们称之为
macro-lib
。 - 创建一个用于使用该宏的普通 crate,我们称之为
my-app
。
sh
cargo new my-app
cd my-app
cargo new macro-lib --lib
macro-lib 中进行:
Cargo.toml
新增:
toml
[lib]
proc-macro = true # 开启过程宏
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 目录下进行:
Cargo.toml
新增:
toml
[dependencies]
# 使用 path 指向本地的宏 crate
macro-lib = { path = "macro-lib" }
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
去解析 attr
和 item
,然后根据属性的参数来修改或包装原始的函数。
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 提供了强大的元编程能力,是许多高级库(如 serde
、tokio
、actix-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) -> TokenStream | name!(...) | 创建类似函数的、功能强大的宏 |
当你需要根据代码的结构(如结构体字段、函数签名)来动态生成代码时,过程宏是你唯一的选择。虽然入门门槛稍高,但它所带来的表达力和自动化能力是无与伦比的。