Rust支持两种宏,一种是声明宏,一种是过程宏,前者相较于后者还是比较简单的。本文主要是讲解Rust元编程里的声明宏,通过声明宏可以减少一些样板代码,它是一个用代码生成代码的技术。
声明宏的主要原理是通过匹配传入的代码然后替换成指定的代码,因为替换是发生在编译器,所以rust的宏编程没有任何运行时的开销,可以放心的用,不用担心性能 :)。
快速入门
声明宏不像过程宏那样需要在单独的包(package/crate)中定义,只需要使用macro_rules!就可以简单的定义一个声明宏,一个简单的示例如下。
1
2
3
4
5
6
7
8
9
10
11
|
// https://youerning.top/post/rust-declarative-macros-tutorial/
macro_rules! add {
($a:expr, $b:expr) => {
$a + $b
};
}
fn main() {
let sum = add!(1,2);
println!("sum: {sum}");
}
|
输出如下:
sum: 3
上面这个结果应该不会让人意外,你会发现声明宏定义的那一段代码和普通的match代码非常相似,不同的在于变量前面多了个前缀$, 而且需要通过冒号:注明变量的类型,这里的变量类型是expr,这是表达式的意思。
声明宏语法
一个声明宏大致可以分为三个部分
- 声明宏的名称定义,比如例子中的add
- 模式匹配部分, 比如例子中的($a:expr, $b:expr)
- 声明宏返回的部分, 也就是花括号被包裹的部分, 比如例子中的$a + $b
本文的开头说过,过程宏的原理就是通过匹配传入的代码然后替换成指定的代码, 所以上面的例子在编译(展开)之后应该会变成下面的代码。
1
2
3
4
|
fn main() {
let sum = 1 + 2;
println!("sum: {sum}");
}
|
如果我们传递三个参数呢? 比如add!(1,2,3),那么它会在编译的时候报以下错误。
error: no rules expected the token `,`
--> src\main.rs:8:23
|
1 | macro_rules! add {
| ---------------- when calling this macro
...
8 | let sum = add!(1,2,3);
| ^ no rules expected this token in macro call
|
note: while trying to match meta-variable `$b:expr`
--> src\main.rs:2:15
|
2 | ($a:expr, $b:expr)=>{
| ^^^^^^^
error: could not compile `declarative-macros` (bin "declarative-macros") due to previous error
其实这很好理解,我们的模式只能匹配两个变量$a和$b, 但是add!(1,2,3)却传入了三个变量,所以匹配不了,那么就会报错,因为这是不合法的语法。
那么,怎么匹配三个变量,或者是一个变量呢? 有两个办法,一是一一对应,二是使用重复的匹配方法。为了简单起见,我们先使用比较笨的方法,代码如下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
macro_rules! add {
// 声明宏的第一条匹配规则
($a: expr) => {
$a
};
// 声明宏的第二条匹配规则
($a:expr, $b:expr)=>{
$a + $b
};
// 声明宏的第三条匹配规则
($a:expr, $b:expr, $c: expr)=>{
$a + $b
};
}
fn main() {
let sum = add!(1);
println!("sum1: {sum}");
let sum = add!(1,2);
println!("sum2: {sum}");
let sum = add!(1,2,3);
println!("sum3: {sum}");
}
|
上面的代码和快速入门的例子没有太大的区别,主要的区别是之前的例子只有一个匹配规则,而新的例子有三条匹配规则,当rust编译代码的时候,会将调用声明宏的输入参数从上至下依次匹配每条规则,当匹配到就会停止匹配,然后返回对应的代码,这和rust的match模式匹配没有太大的区别,唯一的区别可能是, 声明宏使用;分隔不同的匹配模式,而match的不同匹配模式使用,分隔。
上面的代码输出如下:
sum1: 1
sum2: 3
sum3: 3
这样的结果并不让人意外,唯一让人沮丧的是,每种情况都写一个对应的表达式的话,得累死去。
元变量
现在让我们继续看看rust的声明宏支持哪些类型。
- item: 条目,比如函数、结构体、模组等。
- block: 区块(即由花括号包起的一些语句加上/或是一项表达式)。
- stmt: 语句
- pat: 模式
- expr: 表达式
- ty: 类型
- ident: 标识符
- path: 路径 (例如 foo, ::std::mem::replace, transmute::<_, int>, …)
- meta: 元条目,即被包含在 #[...]及#![...]属性内的东西。
- tt: 标记树
大多数情况,一般只会使用expr和tt, 使用expr是因为rust中几乎可以被称为基于表达式的编程语言,因为它的表达式概念非常大,即使是if和while这样的语句也可以作为一个表达式返回值,而tt是一个万金油,它可以简单的被认为是其他类型都不匹配的情况下的兜底类型。
下面看一个tt类型的例子。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
macro_rules! add {
($a: tt) => {
{
println!("{}", stringify!($a));
1
}
};
}
fn main() {
let sum = add!(1);
println!("sum: {sum}");
let sum = add!(,);
println!("sum: {sum}");
let sum = add!({});
println!("sum: {sum}");
let sum = add!(youerning);
println!("sum: {sum}");
}
|
代码输出如下:
1
sum: 1
,
sum: 1
{}
sum: 1
youerning
sum: 1
代码展开后长这样:
值得注意的是: 下面的代码是手动的展开,与真实的编译代码还是有点区别的!!!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
fn main() {
let sum = {
println!("{}", "1")
1
};
println!("sum: {sum}");
let sum = {
println!("{}", ",")
1
};
println!("sum: {sum}");
let sum = {
println!("{}", "{}")
1
};
println!("sum: {sum}");
}
|
总的来说, tt这个类型可以接受合法或者不合法的各种标识符。
stringify!是啥? 说实话我也不太懂,我的理解是,你可以将任何东西扔给它,它会返回一个字符串字面量给你。
宏展开(expand)
如果我真的能够手动展开自己的代码,那就肯定会了,也就不用开文章学习了不是,所以如果吃不准宏展开之后的结果或者故障排查的时候可以使用cargo expand命令查看展开后的代码。
可以通过以下命令安装。
1
|
cargo install cargo-expand
|
安装之后在项目的根目录执行cargo expand即可,上面的例子展开之后如下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
#![feature(prelude_import)]
#[prelude_import]
use std::prelude::rust_2021::*;
#[macro_use]
extern crate std;
fn main() {
let sum = {
{
::std::io::_print(format_args!("{0}\n", "1"));
};
1
};
{
::std::io::_print(format_args!("sum: {0}\n", sum));
};
let sum = {
{
::std::io::_print(format_args!("{0}\n", ","));
};
1
};
{
::std::io::_print(format_args!("sum: {0}\n", sum));
};
let sum = {
{
::std::io::_print(format_args!("{0}\n", "{}"));
};
1
};
{
::std::io::_print(format_args!("sum: {0}\n", sum));
};
let sum = {
{
::std::io::_print(format_args!("{0}\n", "youerning"));
};
1
};
{
::std::io::_print(format_args!("sum: {0}\n", sum));
};
}
|
如果看不太懂可以结合我手动展开的代码一起看。
标记树撕咬机(TT muncher)
通过标记树撕咬机(TT muncher)我们可以实现递归的声明宏,不过在此之前让我们先解决不定参数的问题,之前解决的方案是根据要传的参数编写声明宏的匹配代码,这样实在是太不优雅了,让我们看看怎么一次性搞定。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
macro_rules! add {
($($a: expr),*) => {
0$(+$a)*
};
}
fn main() {
let sum = add!();
println!("sum1: {sum}");
let sum = add!(1);
println!("sum1: {sum}");
let sum = add!(1,2);
println!("sum2: {sum}");
let sum = add!(1,2,3);
println!("sum3: {sum}");
}
|
输出如下:
sum1: 0
sum1: 1
sum2: 3
sum3: 6
重复
声明宏里面有一些难点,其中一个就是重复的匹配模式, 也就是这个例子中的$($a: expr),*, 为啥要这样写? 因为这是rust的语法, 就像定义一个新变量必须使用let表达式一样,这个不需要太纠结。
下面来看看这种模式的语法定义,重复的一般形式是$ ( ... ) sep rep
- $ 是字面标记。
- ( ... ) 代表了将要被重复匹配的模式,由小括号包围。
- sep是一个可选的分隔标记。常用例子包括,和;。
- rep是重复控制标记。当前有两种选择,分别是* (代表接受0或多次重复)以及+ (代表1或多次重复)。目前没有办法指定“0或1”或者任何其它更加具体的重复计数或区间。
大家可以将($($a: expr),*)改成($($a: expr);*),然后就会发现编译不过了,因为分隔符需要是;了
也就是说, $($a: expr),*匹配到了(), (1), (1,2),(1,2,3),为啥能匹配到()?, 因为*能匹配0个或多个,所以零参数的()也能匹配上,如果你将这个例子中的*换成+,就会发现add!()会报错,因为+要求至少一个参数。
下面以参数(1,2,3)的例子再深入一下宏展开时的操作,当传入(1,2,3)时,因为跟$($a: expr),*能够匹配上, 所以(1,2,3)里的冒号,被$($a: expr),*的冒号,给匹配上,而$a代表1 2 3中的每个元素, 那么怎么在返回的代码中标识重复的参数呢?rust的语法是, 我们需要使用$()*将$a包裹起来,外面的包装代码对应参数匹配时的重复次数, 你可以简单的将$()*认为是必要的语法。
下面看一个简单的例子
1
2
3
4
5
6
7
8
9
|
macro_rules! print {
($($a: expr),*) => {
println!("{} {}", $($a),*)
};
}
fn main() {
print!(1,2);
}
|
$($a),*会原封不动的将参数放在它对应的位置,因为println!指定了两个位置参数,所以使用自定义的print只能传递两个参数。
最后看看上面那个add!宏的例子, add!(1,2,3)展开之后应该变成下面这样。
0+1+2+3
之所以这样,是因为我们在返回的代码模式中$($a)*在$a前面加了一个+, 而这个加号+因为被$()*包裹,所以会跟着$a重复一样的次数,也就变成了+1+2+3。
为啥前面要加个0?因为不加0的话, 就不是合法的表达式了。
递归示例1
虽然add!这个宏可以使用一个模式匹配就能完成,但是我们可以使用更加复杂的方式实现,也就是标记树撕咬机(TT muncher)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
macro_rules! add {
($a: expr) => {
$a
};
($a: expr, $b: expr) => {
$a + $b
};
($a: expr, $($other: tt)*) => {
$a + add!($($other)*)
};
}
fn main() {
let sum = add!(1,2,3,4,5);
println!("sum: {sum}");
}
|
使用**标记树撕咬机(TT muncher)**的代码和之前的代码结果没有什么区别,但是展开的过程中会有些不同,因为后者使用了递归,它的递归调用类似于add!(1, add!(2, add!(3, add!(3, add!(3, add!(5))))));
这段代码的前两个匹配模式不用过多介绍,关键在于最后一个($a: expr, $($other: tt)*), $a 和 ,会吃掉一个参数和一个逗号,, 而$($other: tt)*会匹配到后面所有的参数2,3,4,5。
注意这些参数包含逗号,, 还有就是我们在使用$($other: tt)*这种重复模式的时候没有指定分隔符, 所以tt既匹配了参数2 3 4 5也匹配了分割这些数字的逗号,, 所以在展开的代码$a + add!($($other)*)会变成1 + add!(2,3,4,5), 然后就是不断的递归了,直到遇到第一个匹配模式。
递归示例2
你可能在上一个例子不能感受到**标记树撕咬机(TT muncher)**的威力,所以我们继续看下一个例子。
我们可以通过**标记树撕咬机(TT muncher)**的递归调用来生成对嵌套对象的递归调用,这样就不需要不断的判断Option的值是Some还是None了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
use serde_json::{json, Value};
macro_rules! serde_get {
($value: ident, $first: expr) => {
{
match ($value).get($first) {
Some(val) => Some(val),
None => {
None
}
}
}
};
($value: ident, $first: expr, $($others:expr),+) => {
{
match ($value).get($first) {
Some(val) => {
serde_get!(val, $($others),+)
},
None => {
None
}
}
}
};
($value: ident, $first: expr, $($others:tt)* ) => {
{
match ($ident).get($first) {
Some(val) => {
serde_get!(val, $($others)+),
}
None => None
}
}
};
}
fn main() {
let object = json!({
"key11": {"key12": "key13"},
"key21": {"key22": {"key23": "key24"}}
});
if let Some(val) = serde_get!(object, "xx") {
println!(r#"object["a"]["b"]["c"]={val:?}"#);
} else {
println!(r#"object["a"]["b"]["c"]不存在"#);
}
if let Some(val) = serde_get!(object, "key1", "key12") {
println!(r#"object["key11"]["key12"] = {val:}"#);
}
if let Some(val) = serde_get!(object, "key21", "key22", "key23") {
println!(r#"object["key21"]["key21"]["key23"] = {val:}"#);
}
}
|
这个例子写完,我才发现serde_json可以直接使用["key21"]["key21"]["key23"]这样的语法直接判断!!!, 不过serde_json的返回结果都是null, 如果键值对不存在的话。
总结
我感觉rust的宏编程还是很有意思的,不过这东西的确得真正有需求的时候才会真的理解,我之前也不是太懂,看了视频和文章也不是太懂,只是知道它能干啥,但是没有一个真正要解决的问题,所以一直不能很好的掌握,直到在使用serde_json时遇到嵌套的数据结构需要写重复的判断代码时,我才在应用的时候掌握了声明宏(虽然最后发现它的实用价值可能不是那么大),至于过程宏,可能等我遇到需要过程宏的时候才会很好的掌握吧,到时候在写对应的文章吧。
参考链接
https://earthly.dev/blog/rust-macros/
https://doc.rust-lang.org/reference/macros-by-example.html#metavariables
https://www.bookstack.cn/read/DaseinPhaos-tlborm-chinese/mbe-macro-rules.md
https://veykril.github.io/tlborm/
https://github.com/dtolnay/cargo-expandhttps://youerning.top/post/rust/rust-declarative-macros-tutorial/