19.4 宏(macro)
19.4.1 什么是宏
在 Rust 中,宏是一组相关特性的统称:
- 用
macro_rules!构建的声明宏 - 三种过程宏:
- 派生宏,用于
struct或enum,让你指定通过derive属性添加的代码 - 类属性宏,可为任意条目添加自定义属性
- 类函数宏,看起来像函数调用,并对作为参数传入的 token 进行操作
- 派生宏,用于
19.4.2 函数与宏的区别
- 从本质上说,宏是生成其它代码的代码,这称为元编程。
- 函数必须在签名中声明参数的个数和类型;宏可以处理可变数量的参数。
- 编译器会在解释代码之前展开宏。
- 宏的定义比函数定义复杂得多,更难阅读、理解和维护。
- 在某个文件中调用宏时,宏必须已经定义好,或已导入当前作用域。函数可以在任何位置定义并在任何位置使用。
19.4.3 使用 macro_rules! 的声明宏
声明宏有时叫做宏模板,有时叫做 macro_rules 宏,有时就直接叫做宏。
它们是 Rust 中最常见的宏形式。它们有点类似于 match 表达式的模式匹配,定义声明宏时我们使用 macro_rules!。
例如:
#![allow(unused)]
fn main() {
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
}
这是用于创建 Vec 的 vec! 宏的简化定义。我们逐行看一下:
-
#[macro_export]表示只有在所属 crate 被引入作用域后,这个宏才能使用。没有这个属性,宏就不能被导入作用域。 -
macro_rules!是声明宏的关键字。宏的名字是vec,后面{}里的内容是宏体。 -
宏体有点像
match模式匹配,看起来像分支。实际上这里只有一个分支。虽然我们说宏体类似于match模式匹配,但它与match有本质区别:match匹配的是模式,而宏匹配的是 Rust 代码结构。 -
( $( $x:expr ),* )是它的模式,后面是代码。因为这里只有一个模式,其它任何模式都会导致编译时错误。更复杂的宏可能包含多个分支。首先,我们用括号包住整个模式。在宏系统中,我们用美元符号(
$)声明一个变量,它会包含与模式匹配的 Rust 代码。美元符号清楚地表明这是宏变量,而不是普通 Rust 变量。接下来是另一组括号,它们捕获与括号内模式匹配的值,以便在替换代码中使用。$()里面是$x:expr,它匹配任意 Rust 表达式,并把名字设为$x。*表示该模式可以匹配零个或多个前面的项。假设我们写
let v: Vec<u32> = vec![1, 2, 3];——那么$x会分别匹配1、2和3。现在看与该分支关联的代码体:
$()*中的temp_vec.push()会根据模式中$()部分的匹配次数生成零次或多次。$x会被替换成每个匹配到的表达式。当我们用vec![1, 2, 3];调用这个宏时,替换宏调用所生成的代码是:
#![allow(unused)]
fn main() {
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}
}
关于如何编写宏的更多内容,见 Daniel Keep 撰写、Lukas Wirth 续写的 The Little Book of Rust Macros。
大多数程序员只使用宏,从不编写宏,所以这里不再深入。
19.4.4 基于属性生成代码的过程宏
第二种宏是过程宏。它更像函数,或者说某种过程。过程宏把代码作为输入,处理后生成代码作为输出,而不像声明宏那样匹配模式并用其它代码替换。
过程宏有三种:
- 派生宏
- 类属性宏
- 类函数宏
创建过程宏时,定义必须放在它自己的 crate 中,而且该 crate 必须使用特殊的 crate 类型。这是出于复杂的技术原因。Rust 正在努力取消这一要求,但目前仍然存在。
例如:
#![allow(unused)]
fn main() {
use proc_macro;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
}
some_attribute是用来指定过程宏类型的占位符。- 下面是过程宏函数,它以
TokenStream为输入,并以TokenStream为输出。TokenStream定义在proc_macrocrate 中。它表示一串 token,而这正是过程宏操作的对象:需要处理的源代码成为输入TokenStream,宏生成的代码成为输出TokenStream。 附加在函数上的属性决定了我们创建的是哪一种过程宏。一个 crate 可以包含多种过程宏。
派生宏
来看一个例子:
创建一个名为 hello_macro 的 crate,定义一个带有关联函数 hello_macro 的 HelloMacro trait,并提供一个过程宏来自动实现该 trait,这样用户就可以在类型上写 #[derive(HelloMacro)],并得到默认的 hello_macro 实现。
首先,我们需要创建一个新的工作空间,并把其它项目放在这个工作空间下。创建并打开 Cargo.toml:
touch Cargo.toml
在里面写入:
[workspace]
members = [
"hello_macro",
"hello_macro_derive",
"pancakes",
]
先创建一个库 crate:
cargo new hello_macro --lib
在 hello_macro/src/lib.rs 中写入:
#![allow(unused)]
fn main() {
pub trait HelloMacro {
fn hello_macro();
}
}
这样我们就有了一个 hello_macro trait 和一个 hello_macro 方法,但还没有任何具体实现。
然后我们可以在 main.rs 中实现该 trait,并提供实际的方法体:
use hello_macro::HelloMacro;
struct Pancakes;
impl HelloMacro for Pancakes {
fn hello_macro() {
println!("Hello, Macro! My name is Pancakes!");
}
}
fn main() {
Pancakes::hello_macro();
}
这样做可以工作,但有一个缺点:如果用户希望很多类型都使用 hello_macro,就必须为每个类型编写相似的代码。这非常繁琐。
所以我们想用过程宏来生成相关代码。另外,因为宏需要打印类型名,而这部分是可变的。例如,如果类型是 Pancakes,应打印 "Hello, Macro! My name is Pancakes!";如果是 Apple,应打印 "Hello, Macro! My name is Apple!"。因为 Rust 没有反射,所以这里只能用宏。
过程宏需要自己的库,所以我们在工作空间中再创建一个库 crate:
cargo new hello_macro_derive --lib
hello_macro_derive 是存放过程宏的 crate。把 hello_macro 宏代码放在 hello_macro_derive 中是命名惯例。
在这个 crate 的 Cargo.toml 中添加以下内容,不要覆盖现有内容:
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
我们会用到 syn 和 quote crate,所以把它们加为依赖。
然后看这个 crate 的 lib.rs 该怎么写:
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use quote::quote;
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// Build a syntax tree representation of Rust code
// that we can manipulate
let ast = syn::parse(input).unwrap();
// Build the trait implementation
impl_hello_macro(&ast)
}
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let generated = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
generated.into()
}
}
- 通过
proc_macro提供的编译器接口,我们可以读取并操作 Rust 代码。因为它内置于 Rust,所以不必把它加为依赖。 syncrate 用于把 Rust 代码从文本转换成我们可以进一步操作的数据结构。quotecrate 把syn产生的数据结构再转回 Rust 代码。
这三个 crate 让解析 Rust 代码容易得多。编写完整的 Rust 解析器并不简单。
简而言之,这里的逻辑是:
hello_macro_derive函数解析TokenStreamimpl_hello_macro转换语法树(ast)
hello_macro_derive 中的代码对每个派生宏来说大体相同;不同的是 impl_hello_macro 部分。效果是:当用户在类型上写 #[derive(HelloMacro)] 时,hello_macro_derive 函数会被自动调用。
它之所以会自动调用,是因为我们在定义宏时使用了 #[proc_macro_derive(HelloMacro)],这个属性告诉 Rust 它作用于 HelloMacro trait。
这个函数首先把输入的 TokenStream 转换成我们可以解释和操作的数据结构。它把 TokenStream 传给 syn::parse,输出一个表示解析后 Rust 代码的 DeriveInput 结构体。以上面的 Pancakes 类型为例,输出大致如下:
#![allow(unused)]
fn main() {
DeriveInput {
// ...
ident: Ident {
ident: "Pancakes",
span: #0 bytes(95..103)
},
data: Struct(
DataStruct {
struct_token: Struct,
fields: Unit,
semi_token: Some(
Semi
)
}
)
}
}
它的 ident(标识符,也就是名称)是 Pancakes。其余部分不详细解释;见官方 DeriveInput 文档。
impl_hello_macro 是最终生成 Rust 代码并以 TokenStream 返回的地方。
#![allow(unused)]
fn main() {
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let generated = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
generated.into()
}
}
我们用 ast.ident 获得一个包含被注解类型名称的 Ident 结构体实例。以 Pancakes 为例,当我们在清单中的代码上运行 impl_hello_macro 时,得到的 ident 有一个值为 "Pancakes" 的 ident 字段。因此 name 变量包含一个 Ident 结构体实例,打印时会是字符串 "Pancakes"。
quote! 宏让我们定义想要返回的 Rust 代码。因为 quote! 的结果不能直接被编译器理解,所以我们需要把它转换成 TokenStream。做法是调用 into,它接收这个中间表示并返回所需的 TokenStream 值。
quote! 宏还提供模板机制:我们可以写 #name,然后 quote! 会用 name 中的值替换它。你甚至可以用类似普通宏的方式做重复。见官方 quote 文档。
stringify! 宏内置于 Rust。它接受一个 Rust 表达式,例如 1 + 2,但不会求值。相反,1 + 2 会直接被转换成字符串 "1 + 2"。这与 format! 或 println! 不同,后两者会先求值表达式,再把结果转换成 String。#name 输入可能是一个按字面打印的表达式,所以我们使用 stringify!。使用 stringify! 还可以在编译时把 #name 变成字符串字面量,从而节省分配。
写完这些后,编译这两个 crate(带包名的 cargo build 即可;注意路径,否则 Cargo 找不到 crate)。然后在同一个工作空间中创建一个二进制 crate:
cargo new pancakes
在 pancakes crate 的 Cargo.toml 中添加以下内容,不要覆盖其它内容:
[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro_derive" }
添加 hello_macro 和 hello_macro_derive 依赖。
在 pancakes/src/main.rs 中写入:
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;
#[derive(HelloMacro)]
struct Pancakes;
fn main() {
Pancakes::hello_macro();
}
这样就完成了。运行后可以看到:
Hello, Macro! My name is Pancakes!
类属性宏
类属性宏也叫属性宏。它们类似于自定义派生宏,但不是为 derive 属性生成代码,而是让你创建新属性。它们也更灵活:derive 只适用于结构体和枚举,而属性还可以应用到其它条目上,例如函数。
下面是一个类属性宏的例子:
有一个名为 route 的属性(表示路由),在使用 Web 应用框架时,它会注解一个函数。
#![allow(unused)]
fn main() {
#[route(GET, "/")]
fn index() {
}
这段代码只是片段,并不完整。它表示如果路径是 / 且方法是 Get,就会执行 index 函数。route 属性由过程宏定义,宏的函数签名如下:
#![allow(unused)]
fn main() {
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
}
这里有两个 TokenStream 参数:attr 对应 (GET, "/"),item 对应函数体,也就是 index 函数。
除此之外,属性宏的工作方式几乎与派生宏完全一样。它们也需要一个 proc_macro crate,以及一个生成相应代码的函数。
类函数宏
类函数宏也叫函数宏。它们用 macro_name!(...) 这种调用形式,类似于 macro_rules! 宏,但比 macro_rules! 宏更灵活:它们接收 TokenStream 作为输入,并且像另外两种过程宏一样,在定义中使用 Rust 代码来操作这个 TokenStream。
例如:
#![allow(unused)]
fn main() {
let sql = sql!(SELECT * FROM posts WHERE id=1);
}
这只是片段,并不完整。假设我们想定义一个解析 SQL 语句的宏,具体是 SELECT * FROM posts WHERE id=1。宏定义可以是:
#![allow(unused)]
fn main() {
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
}
它的签名也类似于派生宏:接收一个 TokenStream,并返回带有所需行为的 TokenStream。