精通 Rust 宏 — 第一个宏

208 阅读8分钟

1.png

大概三年前,我想找一门语言替代我所学的 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!{} 也是有效的,不过对于简短的调用,大括号似乎不太受欢迎。

第一个匹配器详解

2.png

既然我们已经介绍了基本语法,让我们再看一下第一个匹配器:

() => [
    Vec::new()
];

你要是愿意,下面这样写是一样的:

{} => (
    Vec::new()
);

我一直试图强调匹配器和转换器,以及调用的时候括号有多么不重要!

因为我们的匹配器是空的,所以它会匹配宏的任何空调用。所以当我们在 main 函数中调用 let empty: Vec<i32> = my_vec!(); 时,我们最终会匹配到这个匹配器,因为:

  1. Rust 会从上到下检查匹配器
  2. 我们没有在括号中传入任何内容

我说过转换器的内容位于(在本例中是方括号)括号之间,这意味着 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:属性的内容。例如 Clonerename = "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::CSelf::method
  • stmt:语句。例如赋值语句(let foo = "bar")。
  • tt:标记树(TokenTree)。见前面的解释。
  • ty:类型。例如 String
  • vis:可见性修饰符。比如 pub

在转换器中,我们创建了一个新向量,添加输入表达式,然后返回整个向量——此时向量已包含该表达式作为其唯一元素。这是标准的 Rust 代码,只有两点值得注意:

  1. 转换器中的美元符号:我们在转换器中也必须使用美元符号。记住,我们用 $ 标记了 x 是一个宏变量。所以我们是在告诉 Rust,要把这个绑定到输入的变量 $x 推入向量。如果没有美元符号,Rust 会报错:cannot find value x in this scope,因为作用域中只有 $x,没有 x

  2. 额外的大括号:如果没有这对额外的大括号,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

未完待续...

既然你已经了解了基础知识,那么,现在的你,已经强得可怕了!!!