Rust过程性宏是该语言最令人兴奋的特性之一。它们让你能够在编译时注入代码,但与单态泛型所使用的方法不同。使用非常特殊的包(crate),让你可以完全从头开始构建新代码。本文从简单示例开始,逐步分解,也会详细介绍相关依赖包。
构建过程派生宏
过程宏的运行原理非常简单:获取一段称为输入TokenStream的代码,将其转换为抽象语法树(ast),该树表示该代码段的编译器内部结构,从输入处获得的内容(使用syn::parse() 方法)构建一个新的TokenStream,并将其作为输出代码注入编译器。
- 使用派生宏语法
#[derive()]
举例,支持输出的Debug派生宏,你应该很熟悉吧。
#[derive(Debug)]
从简单示例开始
假设你想创建了WhoAmI
的派生宏,只是在派生宏代码中打印结构的名称,实际使用时代码:
#[derive(WhoAmI)]
struct Point {
x: f64,
y: f64
}
首先我们创建lib包项目,过程宏必须定义在独立的lib包中,在相同包中定义并调用会报错:can’t use a procedural macro from the same crate that defines it
- 创建lib 项目
cargo new --lib whoami
- 增加必要的依赖
proc-macro = true
表明时定义过程宏;可以通过 cargo add crate_name
方式增加,最终文件内容:
[lib]
proc-macro = true
[dependencies]
syn = { version = "1.0.82", features = ["full", "extra-traits"] }
quote = "1.0.10"
- lib.rs 中增加代码
use proc_macro::TokenStream; // no need to import a specific crate for TokenStream
use syn::parse;
// 通过编译错误输出结构体名称
#[proc_macro_derive(WhoAmI)]
pub fn whatever_you_want(tokens: TokenStream) -> TokenStream {
// 转化输入tokens为AST语法树
let ast: syn::DeriveInput = syn::parse(tokens).unwrap();
// ast.ident获取增加该派生宏的名称,如结构体名称
panic!("My struct name is: <{}>", ast.ident.to_string());
TokenStream::new()
}
由于不能使用常规的Rust宏在标准输出上打印出信息(如println!()),因此唯一的方法是panic输出消息,通过停止编译器并告诉编译器输出必要的信息。这不是很方便调试,也不容易完全理解过程宏的具体细节!
现在,我们创建可执行项目使用这个很棒的宏(不是很方便,因为它不会编译):
cargo new demo
增加前面派生宏依赖:
[dependencies]
# 假设两个项目在相同目录,否则你需要修改path路径的内容
whoami = { path = "../whoami" }
修改main.rs代码:
// import our crate
use whoami::WhoAmI;
#[derive(WhoAmI)]
struct Point {
x: f64,
y: f64
}
fn main() {
println!("Hello, world!");
}
cargo build
编译整个项目:
error: proc-macro derive panicked
--> src/main.rs:3:10
|
3 | #[derive(WhoAmI)]
| ^^^^^^
|
= help: message: My struct name is: <Point>
可以看到编译器在过程宏中抛出我们定义的错误消息。
深入理解过程宏
至少可以这么说,前一种方法很笨拙,而且并没有让你理解如何真正利用过程性宏,因为暂时无法真正调试宏(尽管它将来可能会更改)。这就是proc-macro2存在的原因:你可以在单元测试中使用它的方法以及syn::parse2()
方法。然后你可以直接将生成的代码输出到stdout或将其保存为“*.Rs”文件以检查其内容。
让我们再通过一个稍复杂的过程宏示例来深入说明,该宏自动神奇地定义一个函数,用于计算Point结构中所有字段的总和。
创建二进制包:
$ cargo new fields_sum
这个示例项目没有采用lib类型项目,主要希望底层让你理解每一步的过程,如果你跟着下面步骤读完,就很快能够独立编写自己实际可用的过程宏了。
增加依赖:
syn = { version = "1.0.82", features = ["full", "extra-traits"] }
quote = "1.0.10"
proc-macro2 = "1.0.32"
修改main.rs文件代码:
// necessary for the TokenStream::from_str() implementation
use std::str::FromStr;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::ItemStruct;
fn main() {
// 以字符串方式给出结构体定义
let s = "struct Point { x : u16 , y : u16 }";
// 从字符串构建标识流
let tokens = TokenStream::from_str(s).unwrap();
// build the AST: 这里使用syn::parse2()方法
// 这里通过方法解析,因此使用ItemStruct
let ast: ItemStruct = syn::parse2(tokens).unwrap();
// 获取结构体类型名称
let struct_type = ast.ident.to_string();
assert_eq!(struct_type, "Point");
// 结构体字段数
assert_eq!(ast.fields.len(), 2);
// syn::Fields 实现了 Iterator trait, 因此可以迭代处理
let mut iter = ast.fields.iter();
// x
let x_field = iter.next().unwrap();
assert_eq!(x_field.ident.as_ref().unwrap(), "x");
// y
let y_field = iter.next().unwrap();
assert_eq!(y_field.ident.as_ref().unwrap(), "y");
// 下面是重要的部分: 使用 quote!() 宏生成新的代码,新的TokenStream
// 首先构建函数名称: point_summation
let function_name = format_ident!("{}_summation", struct_type.to_lowercase());
// 如何未格式化,函数原型为:pub fn point_summation (pt : "Point")
let argument_type = format_ident!("{}", struct_type);
// 获取属性 x 和 y
let x = format_ident!("{}", x_field.ident.as_ref().unwrap());
let y = format_ident!("{}", y_field.ident.as_ref().unwrap());
// quote!() 宏返回新的 TokenStream. 该TokenStream是过程宏返回给编译器的
let summation_fn = quote! {
pub fn #function_name(pt: &#argument_type) -> u16 {
pt.#x + pt.#y
}
};
// 输出Rust代码
println!("{}", summation_fn);
}
输出结果:
pub fn point_summation (pt : &Point) -> u16 { pt.x + pt.y }
这里我们解释下 ItemStruct 和 DeriveInput 两者的区别:
-
syn::ItemStruct
- 它代表一个结构体定义。在 Rust 编译器的抽象语法树(AST)表示中,
ItemStruct
用于描述结构体相关的信息。这包括结构体的名称、字段、可见性修饰符等。它主要用于处理普通的结构体定义代码,比如在分析现有结构体的结构或者进行简单的代码转换操作时使用。 - 例如,对于结构体定义
struct MyStruct { field1: i32, field2: String }
,syn::ItemStruct
可以用来解析这个结构体的名称MyStruct
,以及它的两个字段field1
和field2
的类型。
- 它代表一个结构体定义。在 Rust 编译器的抽象语法树(AST)表示中,
-
syn::DeriveInput
- 这个类型主要用于派生宏(
derive macros
)的场景。当编写一个派生宏,例如#[derive(Debug)]
这样的宏时,DeriveInput
用来接收和处理要应用派生宏的目标结构体或枚举的信息。它包含了更多与派生宏相关的上下文信息,如目标类型(结构体或枚举)、属性(attrs
)等。 - 比如,当用户在一个结构体上使用
#[derive(MyTrait)]
,syn::DeriveInput
会获取这个结构体的所有信息,包括结构体本身的定义以及这个#[derive(MyTrait)]
属性的相关信息,以便派生宏可以根据这些信息生成对应的MyTrait
实现代码。
- 这个类型主要用于派生宏(
-
使用TokenStreams
前面的例子很简单,因为我们事先知道了结构体中的字段数量。如果我们事先不知道呢?我们可以使用quote!()
的特殊构造来生成所有字段的总和:
use std::str::FromStr;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::ItemStruct;
fn main() {
// struct sample
let s = "struct Point { x : u16 , y : u16 }";
// create a new token stream from our string
let tokens = TokenStream::from_str(s).unwrap();
let ast: ItemStruct = syn::parse2(tokens).unwrap();
// save our struct type for future use
let struct_type = ast.ident.to_string();
// first, build our function name: point_summation
let function_name = format_ident!("{}_summation", struct_type.to_lowercase());
let argument_type = format_ident!("{}", struct_type);
// syn::Fields is implementing the Iterator trait, so we can iterate through the fields
let tokens = ast.fields.iter().map(
|field| {
let fx = &field.ident;
quote!(pt.#fx)
}
);
let summation_fn = quote! {
pub fn #function_name(pt: &#argument_type) -> u16 {
0 #(+ #tokens)*
}
};
// output our function as Rust code
println!("{}", summation_fn);
}
这里要解释应该是 0 #(+ #tokens)*
:
首先初始化为 0
,然后 #(+ #tokens)*
是一种类似模式匹配和展开的语法。#tokens
就是前面通过 map
操作生成的一系列用于获取 pt
各个字段的代码片段。#(... )*
的含义是,对于括号内的内容(这里就是 + #tokens
),会根据 tokens
集合中的元素数量进行多次展开。也就是说,它会把前面生成的每一个获取字段的代码片段 #tokens
都用 +
运算符与前面的结果(初始为 0
)进行连接,从而实现对 pt
的各个字段进行求和的效果(不过这里要注意,只是简单地用 +
连接可能并不适用于所有字段类型,比如如果字段类型不是数字类型,就需要进一步调整这个求和的逻辑)。
最后总结
本文主要介绍过程宏的构建过程,如何调试、理解相关依赖包。有了这些基础知识,有助于理解并构建自定义过程宏。