大概三年前,我想找一门语言替代我所学的 C++,因为我的 C++ 实在是太烂了。
作为一名 Android 开发者,我深知 Java 的负担 —— 要运行一个 Java 程序,首先要安装一个 Java 虚拟机,这第一道门槛就让很多用户望而却步了。
就在那时,我发现了 Rust,我渐渐迷上了 Rust —— 一开始倒不是因为它出众的性能表现,而是它没有继承,我讨厌继承,我迷恋 C 那样简洁的语法。
后面,其扎实的设计根基、强大的类型系统,以及完善的工具链深深地吸引着我。
虽然这门语言的学习门槛不低,但市面上的入门教程却十分丰富,涵盖了大量书籍、指南与视频。
我学习了 Rust 的异步运行时 tokio,基于 Rust 的游戏引擎 Bevy,这些都让我受益良多。
可当我接触到宏时,却陷入了迷茫。相关的入门资料要么篇幅短小,要么内容零散。
这就有些奇怪了 —— 毕竟已经有不计其数的库,正借助宏实现着各式各样强大的功能。
于是,在反复摸索宏的用法之后,我决定把自己一路走来的所学整理成系列文章。
帮助更多的开发者,从最基础的宏示例出发,逐步深入到那些近乎可以直接用于实际项目的进阶用法。
宏的使用与编写确实存在不少挑战:它们不仅难写、难读,对新手来说更是如此;同时还会增加项目的复杂度,以及令人头疼的编译时长。
但即便如此,Rust 宏的价值是无可替代的。
“凡人当互助,此乃天道。”
来吧!让我们看开始我们的第一个宏!
本系列文章会从示例入手,现在,马上引入我们第一个示例。
创建向量
vec! 宏在很多初学者的声明式宏讲解中都会出现。
我们将通过一个简化的实现,来展示声明宏中的匹配器和转换器如何协同工作,从而为任何给定的场景生成正确的代码输出。
my_vec,我们的第一个声明式宏:
macro_rules! my_vec { #1
() => [ #2
Vec::new()
]; #3
(make an empty vec) => ( #4
Vec::new()
); #5
{$x:expr} => {
{
let mut v = Vec::new();
v.push($x);
v
}
}; #6
[$( $x:expr ),+] => (
{
let mut v = Vec::new();
$(
v.push($x);
)+
v
}
)
}
fn main() {
let empty: Vec<i32> = my_vec![];
println!("{:?}", empty); #7
let also_empty: Vec<i32> = my_vec!(make an empty vec);
println!("{:?}", also_empty);
let three_numbers = my_vec!(1, 2, 3);
println!("{:?}", three_numbers); #8
}
- #1 我们声明了一个名为
my_vec的宏。 - #2
()是我们的第一个匹配器。因为它是空的,所以它会匹配没有任何参数的宏调用。 - #3 方括号对之间的所有内容都是第一个转换器。这是我们对宏的空调用时会生成的代码。注意末尾的分号。
- #4
(make an empty vec)是我们的第二个匹配器。只有当输入字面量完全匹配 "make an empty vec" 时,它才会匹配。 - #5 这是我们的第二个转换器,这次用圆括号包裹。我们生成的输出与第一个转换器中的相同。
- #6 接下来的两个匹配器-转换器对。第一个接受一个表达式(
expr)并将其绑定到x。第二个接受多个用逗号分隔的表达式,这些表达式也会被绑定到x。 - #7 这两行打印
[]。 - #8 这一行打印
[1, 2, 3]。
语法基础
声明式宏的声明以 macro_rules! 开头,后跟你想要为宏使用的名称,这与你通过 fn 后跟函数名来创建函数的方式类似。在大括号内,你放入所需的匹配器和转换器。
匹配器和对应的转换器(类似于模式匹配的语法)通过箭头分隔:(matcher) => (transcriber)。
也就是说,=> 前面括号里面的是匹配器,后面括号里面的是转换器。
在本例中,我们有四组匹配器-转换器对。第一组由一个空匹配器(用空括号表示)和一个内容用方括号包裹的转换器组成。
不过,方括号并不是必需的:对于匹配器和转换器,你可以选择括号:()、{} 和 [] 都是有效的,甚至在使用宏的时候,这三个任意一个也都是有效的。
举个例子,假设上面匹配空分支定义成:
[] => [
Vec::new()
];
那么下面三种调用方式都是有效的:
my_vec!();
my_vec![];
my_vec!{};
当然,匹配分支也可以定义成:
[] => { // 这个括号长什么样子并不重要
Vec::new()
};
但你必须从这三个选项中选择一个,因为完全移除它们(例如 () => Vec::new())会让 Rust 感到困惑。它会开始抱怨:no rules expected the token ::。
如果你去掉双冒号,错误提示会更有帮助,它会说“宏的右侧必须用括号分隔”——也就是说,要使用括号!
示例中的括号只是为了演示在括号方面的选择。如果你只选择一种括号,代码会看起来更整洁。
你应该选哪一种括号呢?大量开源代码给了我们的充分的经验。大括号的缺点如果你的转换器中包含代码块,可能会让代码不太清晰。而方括号似乎是最不受欢迎的选择,毕竟太像数组了。所以,只剩下圆括号了。
另一个重要的语法元素是,这些匹配器-转换器对之间用分号分隔。如果你忘记了这一点,Rust 会报错:
5 | {$x:expr} => {
| ^ no rules expected this token in macro
这表示如果你没有用分号结束一个匹配器-转换器对,就不应该再有其他规则了。所以,只要后面还有更多的匹配器-转换器对,就要继续添加分号。当你到了最后一对时,分号就是可有可无的了。
不过为了方便拷贝和添加,习惯性的都添加分号。
声明和导出声明式宏
有一个限制是:声明式宏只能在声明之后才能使用。
如果我将宏放在 main 函数下方,Rust 会这样报错:
error: cannot find macro `my_vec` in this scope
--> src/main.rs:5:25
|
5 | let three_numbers = my_vec!(1, 2, 3);
| ^^^^^^^^^^^^^^^^
|
= help: have you added the `#[macro_use]` on the
一旦你开始导出宏,这个问题就不再存在了,因为在模块或导入顶部添加 #[macro_use](例如 #[macro_use] mod some_module;)会将宏添加到“macro_use 预导入(prelude)”中。
在编程中,“预导入(prelude)”指的是语言中那些全局可用的内容集合。
例如,Clone(#[derive(Clone)])不需要导入,因为它在 Rust 的预导入中。
当你添加 #[macro_use] 时,来自所选导入的宏也会变得全局可用,无需导入。所以,上一个错误信息中的提示可以解决这个错误,尽管有点“杀鸡用牛刀”。此外,这是导出宏的“旧方法”,不再是推荐的做法,这个具体后面再说。
当你需要调用宏时,使用宏名后跟感叹号,再在括号中传入参数。与宏本身类似,调用时你可以使用任何括号,只要是普通括号、大括号或方括号即可。
你经常会看到 vec![],但 vec!() 和 vec!{} 也是有效的,不过对于简短的调用,大括号似乎不太受欢迎。
第一个匹配器详解
既然我们已经介绍了基本语法,让我们再看一下第一个匹配器:
() => [
Vec::new()
];
你要是愿意,下面这样写是一样的:
{} => (
Vec::new()
);
我一直试图强调匹配器和转换器,以及调用的时候括号有多么不重要!
因为我们的匹配器是空的,所以它会匹配宏的任何空调用。所以当我们在 main 函数中调用 let empty: Vec<i32> = my_vec!(); 时,我们最终会匹配到这个匹配器,因为:
- Rust 会从上到下检查匹配器
- 我们没有在括号中传入任何内容
我说过转换器的内容位于(在本例中是方括号)括号之间,这意味着 Vec::new() 是 Rust 在匹配成功时会生成的代码。所以在这种情况下,我们是在告诉它我们想要调用向量结构体的 new 方法。这段代码会被添加到应用程序中宏被调用的位置。
我们回到 main 中的第一个调用。
Rust 看到 my_vec!() 并想:“感叹号!这一定是宏调用。”
由于我们的文件中没有导入,所以这要么是标准库中的宏,要么是自定义宏。结果是自定义宏,因为 Rust 在同一个文件中找到了它。
找到宏后,Rust 从第一个匹配器开始,而它恰好是正确的那个。
现在它可以用转换器的内容 Vec::new() 替换 my_vec!()。
所以当你对代码进行任何操作(检查、lint、运行等)时,let empty: Vec<i32> = my_vec!(); 已经变成了 let empty: Vec<i32> = Vec::new();。这是一个细微但重要的细节:因为只有 my_vec!() 被替换,所以语句末尾的分号会保留在原来的位置。正因为如此,我们不需要在转换器中添加分号。
非空匹配器
让我们来看第二个匹配器,它长这样:
(make an empty vec) => (
Vec::new()
);
在这种情况下,匹配器包含字面量(literal values)。
这意味着要匹配宏的这个特定“分支”,你需要在调用宏时在括号中放入完全相同的字面量,这正是我们在 main 函数的第二个示例中所做的:let also_empty: Vec<i32> = my_vec!(make an empty vec);。
我们的转换器没有变化,所以输出仍然是 Vec::new(),代码变成了 let also_empty: Vec<i32> = Vec::new();。
你可以多尝试一下:
{empty} => (
Vec::new()
);
let also_empty: Vec<i32> = my_vec!(empty);
都一样。
在这种情况下,字面量并没有增加什么有趣的功能。
下一个匹配器-转换器对更有意思:
{$x:expr} => {
{
let mut v = Vec::new();
v.push($x);
v
}
};
这一次,我们告诉 Rust 我们想要匹配任意单个 Rust 表达式(expr),并将其绑定到一个名为 x 的值。x 前面的美元符号很重要,因为它表示这是一个宏变量(macro variable)。
没有它,Rust 会认为这只是另一个字面量,在这种情况下只会有一个匹配(即 my_vec![x:expr])。除了表达式(这是匹配的常见目标),你还可以匹配标识符、字面量、类型等等。
元变量(METAVARIABLES)
在 Rust 的术语中,expr 被称为元变量(metavariable),也叫片段指定符(fragment specifier)。
这些元变量中最强大的是 tt(TokenTree,标记树),它几乎可以接受你传入的任何内容。这是一个功能强大的选项,但它的“包罗万象”也可能是缺点。
对于更简单的类型,Rust 可以捕捉错误,比如当你传入一个字面量(literal),但宏只匹配标识符(ident)时。
此外,使用 tt 会让你的匹配器变得不够精细,因为它相当于在喊“把你有的任何东西都给我!”。正因为如此,tt 也可能过于“贪心”,有很多内容都会匹配标记树。
这有点像正则表达式:\d+ 只会捕获一个或多个数字,功能不如 .* 强大,但 .* 会捕获任何内容。然而,限制有时也是优势,这让 \d 更可预测、更易于管理。
对于元变量,建议从更具体的类型开始,只有在必要时才升级到 tt 这类通用类型。如果确实需要使用 tt,请务必仔细思考并充分测试。
以下是所有元变量的列表。别担心,一般不会出现都一起用的情况,你也不用一次性全部掌握,你甚至可以跳过这些内容!
block:代码块表达式。也就是大括号{}包裹的语句。expr:表达式。Rust 中涵盖范围非常广的内容。ident:标识符或关键字。例如,函数声明的开头(fn hello)包含一个关键字,后跟一个标识符,我们可以通过两次使用ident来同时捕获它们。item:结构体、枚举、导入(use声明)这类项。lifetime:Rust 的生命周期('a)。literal:字面量。比如数字或字符。meta:属性的内容。例如Clone或rename = "true"。在后面的章节中,你会更清楚地了解属性可能包含的内容。pat:模式。例如1 | 2 | 3。pat_param:与pat类似,但它可以用|作为分隔符。例如,规则($first:pat_param | $second:ident)可以正常工作,但($first:pat | $second:ident)会报错,因为pat不允许后面跟|。这也意味着你需要做一些额外的工作,才能用pat_param解析1 | 2 | 3(因为它会将其视为三个独立的标记,而不是一个)。path:路径。例如::A::B::C或Self::method。stmt:语句。例如赋值语句(let foo = "bar")。tt:标记树(TokenTree)。见前面的解释。ty:类型。例如String。vis:可见性修饰符。比如pub。
在转换器中,我们创建了一个新向量,添加输入表达式,然后返回整个向量——此时向量已包含该表达式作为其唯一元素。这是标准的 Rust 代码,只有两点值得注意:
-
转换器中的美元符号:我们在转换器中也必须使用美元符号。记住,我们用
$标记了x是一个宏变量。所以我们是在告诉 Rust,要把这个绑定到输入的变量$x推入向量。如果没有美元符号,Rust 会报错:cannot find value x in this scope,因为作用域中只有$x,没有x。 -
额外的大括号:如果没有这对额外的大括号,Rust 会报错:
expected expression, found let statement。当你尝试在脑海中将宏调用替换为它的输出时,原因就会变得清晰。举个例子,假设我们有这样的代码:let a_number_vec = my_vec!(1);我们知道
my_vec!(1)会被转换器的内容替换。因为let a_number_vec =会保留在原地,我们需要一个可以被赋值给let的内容——比如一个表达式。但如果没有额外的大括号,我们得到的会是两条语句加一个表达式!Rust 该如何把它赋值给
let呢?这个错误看起来可能有点晦涩,但实际上逻辑很清晰。解决方案很简单:把我们的输出变成一个单一的表达式。大括号正是用来做这件事的。以下是宏展开后的代码:
let a_number_vec = { let mut v = Vec::new(); v.push(1); v }如果你写过 C 语言的宏定义的话,实际上这个很好理解。
想象一下上面的代码没有大括号的话...
编写宏确实需要一些思考(以及大量的调试)。但既然你选择了 Rust,就会知道“思考”肯定是必不可少的环节。
我们现在几乎已经掌握了基础知识!
最后一个匹配器-转换器对是:
[$($x:expr),+] => (
{
let mut v = Vec::new();
$(
v.push($x);
)+
v
}
)
这基本上和之前的一样,只是多了一些美元符号和加号。在匹配器中,我们可以看到 $x:expr 被包裹在 $( ... ),+ 中。这告诉 Rust 要接受“一个或多个用逗号分隔的表达式”。
作为程序员,你应该不会感到惊讶:除了 +(一个或多个),你还可以用 *(零个或多个)和 ?(零个或一个)。和宏一样,正则表达式的思想无处不在。
一个小陷阱是,这个匹配器不会匹配带尾随逗号的输入。my_vec![1, 2, 3] 可以正常工作,但 my_vec![1, 2, 3,] 则不行。要支持尾随逗号,你需要额外添加一条规则。
在转换器中,唯一的变化是 push 语句也被类似的 $() 和 + 包裹,只是这次没有逗号。这里的 $( ... )+ 同样表示重复:“对于匹配器中的每一个表达式,重复括号内的内容”。也就是说,每找到一个表达式,就生成一条 push 语句。这意味着 my_vec![1, 2, 3] 会生成三条 push 语句。
注意
到这里,你可能已经发现,第三个匹配器-转换器对(也就是只能匹配到一个的情况)其实已经被这一对覆盖了。
有很多写法是无法编译的。例如,你可能希望 Rust 足够聪明,能自动推断出你想把每个表达式都推入向量,于是你从 $(v.push($x))+ 中移除了 $()+——结果会得到错误:variable x is still repeating at this depth。编译器说的“repeating”,意思是 x 包含了多个表达式,而你的代码却假设只需要推入一个表达式,这就产生了矛盾。
如果你喜欢折腾这段代码,最终会发现:在转换器中,你可以使用任何重复操作符,无论匹配器中用的是什么。你可以在 push 中使用 ? 和 *,一切都会按预期工作
最后:如果你尝试在宏中做一些非法的事情会发生什么?比如,你尝试混合整数和字符串作为输入(而 Vec 无法接受这种混合类型),会怎么样?
Rust 不会被蒙骗,因为它会根据你的宏“规则”生成“正常”的代码,而这些代码必须遵守 Rust 的编译规则。也就是说,如果你尝试混合类型,会得到类似 expecting x, found y 的错误(具体名称取决于你先传入的是什么)。
经典的 multiple
准备好了吗?让我写一个那个经典的宏,乘法(如果你还记得 C 语言怎么写的话,你也应该知道里面有个运算符有优先级的坑):
macro_rules! multiple {
($a:expr,$b:expr) => {
$a * $b
};
}
//...
let sum = multiple!(1, 2);
println!("{}", sum);
let sum = multiple!(1 + 2, 3); // 你猜猜这个会正常工作吗? #1
println!("{}", sum);
let sum = multiple!(1.0f32, 3.0f32);
println!("{}", sum);
// output
2
9
3
你会发现,它的输出结果没问题!
#1 这里 Rust 会工作的非常完美,原因就是 $a:expr 捕获的是完整的表达式 1 + 2(作为一个整体),也就是这里直接使用的运算结果 3。
未完待续...
既然你已经了解了基础知识,那么,现在的你,已经强得可怕了!!!