第七章 宏
宏在本质上是一种让编译器为你写代码的工具。你给编译器一个生成代码的公式,给定一些输入参数,编译器就会用公式的运行结果来替换宏的每次调用。你可以把宏想象成自动代码替换,由你来定义替换的规则。
Rust 的宏有许多不同的形状和大小,以方便实现许多不同形式的代码生成。两种主要类型是声明性宏和过程宏,我们将在本章中探讨这两种类型。我们还将探讨宏在日常编码中的一些使用方法,以及在更高级的使用中出现的一些隐患。
来自基于 C 语言的程序员可能习惯于 C 和 C++的邪恶之地,在那里你可以用 #define 将每个 true 改为 false,或者删除所有 else 关键字的出现。如果你是这种情况,你就需要将宏与做 "坏事" 的感觉分开。Rust 中的宏远不是 C 语言中的宏。它们遵循(大部分)定义明确的规则,并且相当耐误用。
声明宏
声明性宏是那些使用 macro_rules! 语法定义的宏,它使你可以方便地定义类似函数的宏,而不必为此目的编写一个专门的 crate(就像你对过程宏所做的那样)。一旦你定义了一个声明性的宏,你就可以用宏的名字和惊叹号来调用它。我喜欢把这种宏想象成一种编译器辅助的搜索和替换:它能完成许多常规的、结构良好的转换任务,并能消除重复的模板。在你到目前为止的 Rust 使用经验中,你所认识到的大多数宏都可能是声明性宏。然而,请注意,并非所有类似函数的宏都是声明性宏;macro_rules! 本身就是一个例子,format_args! 是另一个例子。! 后缀只是向编译器表明,宏的调用将在编译时被替换成不同的源代码。
注意:由于 Rust 的解析器专门识别和解析了用
!注释的宏调用,你只能在解析器允许的地方使用它们。它们在大多数你期望的地方都可以使用,比如在表达式位置或impl块中,但不是在任何地方。例如,你不能(在写这篇文章时)在预期有标识符或匹配臂的地方调用一个类函数宏。
为什么声明式宏被称为声明式,这可能不是很明显。毕竟,你不是在程序中 "声明 "了一切吗?在这种情况下,声明性指的是你不说宏的输入应该如何转化为输出,只是说当输入为 B 时,你希望输出看起来像 A。这使得声明式宏简洁而富有表现力,尽管它也有使它们变得相当隐晦的倾向,因为你有一种有限的语言来表达你的声明。
什么时候使用它们
当你发现自己在不断地写同样的代码,而你又想不写的时候,声明性的宏就很有用。它们最适合于相当机械的替换--如果你的目的是进行花哨的代码转换或大量的代码生成,过程宏可能更适合。
我最常使用的是声明式宏,在我发现自己在写重复的和结构相似的代码时,例如在测试和 trait 实现中。对于测试,我经常想多次运行同一个测试,但配置略有不同。我可能有像清单 7-1 中所示的东西。
#![allow(unused)] fn main() { fn test_inner<T>(init: T, frobnify: bool) { ... } #[test] fn test_1u8_frobnified() { test_inner(1u8, true); } // ... #[test] fn test_1i128_not_frobnified() { test_inner(1i128, false); } // 第 7-1 项:重复测试代码 }
虽然这样做行得通,但它太冗长、太重复,而且太容易出现手工错误。使用宏,我们可以做得更好,如清单 7-2 所示。
#![allow(unused)] fn main() { macro_rules! test_battery { ($($t:ty as $name:ident),*) => {$( mod $name { #[test] fn frobnified() { test_inner::<$t>(1, true) } #[test] fn unfrobnified() { test_inner::<$t>(1, false) } })* } } test_battery! { u8 as u8_tests, // ... i128 as i128_tests ); // 清单 7-2:让一个宏为你重复。 }
这个宏将每个以逗号分隔的指令扩展成自己的模块,然后包含两个测试,一个是以 true 调用 test_inner,另一个是以 false 调用。虽然宏的定义并不简单,但它使添加更多的测试更加容易。每种类型都是 test_battery!调用中的一行,宏将负责生成真和假参数的测试。我们还可以让它为 init 的不同值生成测试。现在,我们已经大大减少了忘记测试某个特定配置的可能性。
Trait 实现的情况也类似。如果你定义了自己的特质,你通常会想为标准库中的一些类型实现该特质,即使这些实现是微不足道的。让我们想象一下,你发明了 Clone trait,并想为标准库中所有的 Copy 类型实现它。你可以使用一个类似清单 7-3 中的宏,而不是手动为每个类型编写一个实现。
#![allow(unused)] fn main() { macro_rules! clone_from_copy { ($($t:ty),*) => { $(impl Clone for $t { fn clone(&self) -> Self { *self } })* } } clone_from_copy![bool, f32, f64, u8, i8, /* ... */]; // 清单 7-3:使用宏来实现许多类似类型的特质,一举两得 }
在这里,我们为每个提供的类型生成一个 Clone 的实现,其主体只是使用 * 来复制出 &self。你可能想知道为什么我们不为 T 添加一个覆盖的实现 Clone for T where T: Copy。我们可以这样做,但不这样做的一个重要原因是,这将迫使其他 crate 的类型也为他们自己的类型使用相同的 Clone 实现,而这些类型恰好都是 Copy。一个叫做专业化的实验性编译器 trait 可以提供一个解决方法,但是在写这篇文章的时候,这个 trait 的稳定化还有一段距离。所以,就目前而言,我们最好还是专门枚举这些类型。 这种模式也超越了简单的转发实现:例如,你可以很容易地改变清单 7-3 中的代码,对所有整数类型实现 AddOne trait。
注意:如果你发现自己不知道应该使用泛型还是声明性的宏,你应该使用泛型。 泛型通常比宏更符合人体工程学,并且与语言中的其他构造整合得更好。 考虑这个经验法则:如果你的代码基于类型而改变,就使用泛型;否则就使用宏。
他们如何工作的
每一种编程语言都有一个语法,它规定了构成源代码的各个字符如何被转化为标记。令牌是语言的最底层构件,如数字、标点符号、字符串和字符字面,以及标识符;在这个层面上,语言关键词和变量名称之间没有区别。例如,文本(value + 4)在类似 Rust 的语法中会由五个令牌序列(, value, +, 4, )表示。将文本转化为令牌的过程也为编译器的其他部分和解析文本的棘手的低级细节之间提供了一层抽象。例如,在标记表示法中,没有空白的概念,而且 /*"foo"*/和"/*foo*/"有不同的表示法(前者没有标记,后者是一个内容为/*foo*/的字符串字面标记)。
一旦源代码变成了一个标记序列,编译器就会浏览这个序列,并为这些标记赋予句法意义。例如,以 () 为界限的标记构成了一个组,!标记表示宏调用,等等。这就是解析的过程,它最终产生一个抽象语法树(AST),描述了源代码所代表的结构。作为一个例子,考虑表达式 let x = || 4,它由 tokens let(关键字)、x(标识符)、=(标点符号)、|(标点符号)的两个实例和 4(字面意思)组成的序列。当编译器将其转化为语法树时,它表示为一个语句,其模式是标识符 x,其右侧表达式是一个闭包,其主体是一个空参数列表和一个整数字 4 的字面表达。请注意,语法树的表示方法比标记序列要丰富得多,因为它按照语言的语法为标记组合赋予了语法意义。
Rust 宏规定了语法树,当编译器在解析过程中遇到宏调用时,给定的令牌序列要转换为语法树,它必须对宏求值以确定替换令牌,这最终将成为宏调用的语法树。然而,此时编译器仍在解析标记,可能还不能对宏求值,因为它所做的只是解析宏定义的标记。相反,编译器推迟宏调用分隔符中包含的任何内容的解析,并记住输入标记序列。当编译器准备好计算所指示的宏时,它将在标记序列上计算宏,解析它产生的标记,并将生成的语法树替换为宏调用所在的树。
从技术上讲,编译器确实为宏的输入做了一点解析。具体来说,它解析基本的东西,比如字符串字面值和分隔的组,因此产生一系列的标记树,而不仅仅是标记。例如,代码 x - (a.b + 4) 解析为三个标记树的序列。第一个标记树是标识符 x 的单个标记,第二个标记树是标识符-的单个标记,第三个标记树是一个组(使用括号作为分隔符),该组本身由五个标记树序列组成:a(一个标识符)、.(标点符号)、b(另一个标识符)、+(另一个标点符号)和 4(文字)。这意味着宏的输入不一定是有效的 Rust,但它必须由 Rust 编译器可以解析的代码组成。例如,在 Rust 中不能在宏调用之外编写 for <- x,但可以在宏调用内部编写,只要宏生成有效的语法。另一方面,你不能将 for{ 传递给宏,因为它没有右大括号。
声明性宏总是生成有效的 Rust 作为输出。你不能让一个宏生成,比如说,一个函数调用的前半部分,或者一个没有后面的块的 if。一个声明性的宏必须生成一个表达式(基本上是任何可以赋值给变量的东西),一个语句,如 let x = 1;,一个项目,如 trait 定义或 impl 块,一个类型,或一个匹配模式。这使得 Rust 的宏可以抵制滥用:你根本不可能写一个产生无效 Rust 代码的声明式宏,因为宏定义本身不会被编译。
这就是声明性宏的全部内容--当编译器遇到宏调用时,它将调用定界符中包含的标记传递给宏,解析产生的标记流,并将宏调用替换为产生的 AST。
如何编写声明式宏
对声明性宏所支持的所有语法的详尽解释超出了本书的范围。然而,我们将涵盖基础知识,因为有一些奇怪的地方值得指出。
声明性宏由两个主要部分组成:匹配器和转录器。一个特定的宏可以有许多匹配器,每个匹配器都有一个相关的转录器。当编译器找到一个宏的调用时,它会从头到尾走一遍宏的匹配器,当它找到一个与调用中的标记相匹配的匹配器时,它会通过走相应转录器的标记来替换调用。清单 7-4 显示了声明式宏规则的不同部分是如何结合在一起的。
#![allow(unused)] fn main() { macro_rules! /* macro name */ { (/* 1st matcher */) => { /* 1st transcriber */ }; (/* 2nd matcher */) => { /* 2nd transcriber */ }; } // 清单 7-4:声明性宏定义组件 }
匹配器(Matchers)
你可以把宏匹配器看作是一个标记树,编译器试图以预定义的方式将其扭曲,以匹配它在调用地点得到的输入标记树。作为一个例子,考虑一个带有 $a:ident + $b:expr 匹配器的宏。该匹配器将匹配任何标识符 (:ident) 后面的加号,以及任何 Rust 表达式 (:expr)。如果用 x + 3 * 5 来调用宏,编译器会注意到,如果它设置 $a = x 和 $b = 3 * 5,那么匹配器就会匹配。尽管 * 从未出现在匹配器中,但编译器意识到 3 * 5 是一个有效的表达式,因此它可以与$b:expr 匹配,它接受任何表达式(:expr 部分)。
匹配器可以变得非常多,但它们有巨大的表达能力,很像正则表达式。对于一个不太长的例子,这个匹配器接受一个或多个 (+) 逗号分割 (),) 的序列 ($()),以 key => value 格式给出的键/值对:
#![allow(unused)] fn main() { $($key:expr => $value:expr),+ }
而且,最重要的是,用这个匹配器调用宏的代码可以为键或值提供任意复杂的表达式,匹配器的魔力将确保键和值表达式被适当地分区。
宏规则支持各种各样的片段类型;你已经看到了用于标识符的:ident 和用于表达式的:expr,但也有用于类型的:ty,甚至用于任何单一标记树的:tt 你可以在 Rust 语言参考(https://doc.rust-lang.org/reference/macrosby-example.html)的第三章中找到完整的片段类型列表。这些,加上重复匹配模式的机制($()),使你能够匹配大多数简单的代码模式。然而,如果你发现很难用匹配器来表达你想要的模式,你可能想尝试用过程宏来代替,在这里你不需要遵循 macro_rules! 要求的严格语法。我们将在本章后面更详细地讨论这些问题。
转录器(Transcribers)
一旦编译器匹配了一个声明性的宏匹配器,它就会使用匹配器相关的转录器生成代码。由宏匹配器定义的变量被称为元变量,编译器将每个元变量在转录器中的任何出现(如上一节例子中的$key)替换为与匹配器中该部分匹配的输入。如果你在匹配器中有重复(比如同一例子中的$(),+),你可以在转录器中使用相同的语法,它将为输入中的每个匹配重复一次,每次扩展都为该迭代的每个元变量提供适当的替换。例如,对于$key 和$value 匹配器,我们可以写下面的转录器,为每个被匹配的$key/$value 对产生一个 insert 调用到某个 map 中去。
#![allow(unused)] fn main() { (map.insert($key, $value);)+ }
请注意,在这里我们希望每个重复都有一个分号,而不仅仅是为重复划定界限,所以我们把分号放在重复的括号内。
注意:你必须在转录器中的每个重复中使用一个元变量,以便编译器知道要使用匹配器中的哪个重复(如果有多个重复的话)。
卫生(Hygiene)
你可能听说过 Rust 的宏是卫生的,也许是卫生的使它们更安全或更适合工作,但不一定了解这意味着什么。当我们说 Rust 宏是卫生的,我们的意思是,一个声明性的宏(通常)不能影响没有明确传递给它的变量。一个微不足道的例子是,如果你声明了一个名字为 foo 的变量,然后调用一个同样定义了一个名为 foo 的变量的宏,那么宏的 foo 在调用地点(宏被调用的地方)默认是不可见的。同样,宏也不能访问在调用地点定义的变量(甚至是 self),除非它们被明确地传递进来。
大多数时候,你可以认为宏的标识符存在于自己的命名空间中,与它们所扩展的代码的命名空间是分开的。举个例子,看看清单 7-5 中的代码,它有一个试图(但失败了)在调用地点影射一个变量的宏。
#![allow(unused)] fn main() { macro_rules! let_foo { ($x:expr) => { let foo = $x; } } let foo = 1; // expands to let foo = 2; let_foo!(2); assert_eq!(foo, 1); // 清单 7-5:宏存在于他们自己的小宇宙中。大多数情况下。 }
在编译器展开 let_foo!(2) 之后,断言看起来应该失败。然而,原始代码中的 foo 和宏生成的 foo 存在于不同的宇宙中,除了碰巧共享一个人类可读的名字外,它们之间没有任何关系。事实上,编译器会抱怨说宏中的 let foo 是一个未使用的变量。这种卫生方式对于宏的调试非常有帮助--你不必担心因为你碰巧选择了相同的变量名而意外地影射或覆盖了宏调用者中的变量!
然而,这种卫生的分离并不适用于变量标识符之外。声明性宏确实与调用站点共享一个类型、模块和函数的命名空间。这意味着你的宏可以定义可以在调用范围内调用的新函数,为在其他地方定义的类型添加新的实现(并且没有传入),引入一个新的模块,然后可以在宏被调用的地方访问,等等。这是由设计决定的--如果宏不能像这样影响更广泛的代码,那么用它们来生成类型、trait 实现和函数就会麻烦得多,而这正是它们最方便的地方。
当你写一个你想从你的 crate 中导出的宏时,宏中缺乏对类型的卫生管理是特别重要的。为了使宏真正能够被重用,你不能对调用者的范围内有哪些类型做任何假设。也许调用你的宏的代码定义了 mod std {} 或者导入了自己的 Result 类型。为了安全起见,确保你使用完全指定的类型,比如 ::core::option::Option 或 ::alloc::boxed::Box。如果你特别需要引用定义宏的 crate 中的东西,使用特殊的元变量 $crate。
注意:如果可以的话,避免使用
::std路径,这样宏就能在no_stdcrate 中继续工作。
如果你想让宏影响调用者范围内的特定变量,你可以选择在宏和它的调用者之间共享标识符。关键是要记住标识符的来源,因为那是标识符将被绑定的命名空间。如果你把 let foo = 1; 放在一个宏中,那么标识符 foo 就起源于这个宏,并且永远不会被调用者的标识符命名空间所使用。另一方面,如果宏把 $foo:ident 作为参数,然后写上 let $foo = 1;,当调用者用 !(foo) 调用宏时,标识符将起源于调用者,因此将在调用者的范围内引用 foo。
标识符也不必如此明确地传递;任何出现在宏之外的代码中的标识符都将引用调用者范围内的标识符。在清单 7-6 的例子中,变量标识符出现在 :expr 中,但是仍然可以访问调用者范围中的变量。
#![allow(unused)] fn main() { macro_rules! please_set { ($i:ident, $x:expr) => { $i = $x; } } let mut x = 1; please_set!(x, x + 1); assert_eq!(x, 2); // 清单 7-6:让宏在调用地点访问标识符 }
我们可以在宏中使用 =$i+1 来代替,但我们不能使用 =x+1,因为在宏的定义范围中没有 x 这个名字。
关于声明性宏和范围的最后一点说明:与 Rust 中的几乎所有其他东西不同,声明性宏只有在被声明后才存在于源代码中。如果你试图使用一个你在文件中进一步定义的宏,这将不会起作用!这适用于你的项目;如果你声明了一个宏,那么你就可以使用它。这适用于你的项目;如果你在一个模块中声明了一个宏,并想在另一个模块中使用它,你声明宏的模块必须出现在 crate 的前面,而不是后面。如果 foo 和 bar 是位于 crate 根部的模块,foo 声明了一个 bar 想要使用的宏,那么 mod foo 必须出现在 lib.rs 中的 mod bar 之前!
注意:宏的这种奇怪的范围(正式称为文本范围)有一个例外,那就是如果你用
#[macro_export]标记宏。这个注解可以有效地将宏提升到 crate 的根部,并将其标记为pub,这样它就可以在 crate 的任何地方或 crate 的附属物中使用。
过程宏
你可以把过程宏看作是解析器和代码生成的结合,在这中间你要写胶水代码。在高层次上,通过过程宏,编译器收集输入宏的标记序列,并运行你的程序,以确定用什么标记来替换它们。
过程宏之所以被称为过程宏,是因为你定义了如何在给定的输入标记中生成代码,而不是仅仅写出生成的代码。编译器方面涉及的智能很少--就其所知,过程宏或多或少是一个可以任意替换代码的源代码预处理器。 你的输入可以被解析为 Rust 标记流的要求仍然存在,但仅此而已!。
过程宏的类型
过程宏有三种不同的风格,每一种都是专门针对一个特定的普通用例的
- 类似于函数的宏,如
macro_rules!生成的。 - 属性宏,如
#[test]。 - 派生宏,如
#[derive(Serialize)]。
所有这三种类型都使用相同的基本机制:编译器向你的宏提供一串标记,它希望你产生一串与输入树有关的标记作为回报(可能)。然而,它们在调用宏的方式和处理其输出的方式上有所不同。我们将简要地介绍每一种。
函数宏(Function-Like Macros)
类似函数的宏是过程宏的最简单的形式。 像声明性宏一样,它只是用过程宏返回的代码替换了调用处的宏代码。然而,与声明式宏不同的是,所有的防护栏都是关闭的:这些宏(像所有的过程式宏一样)不需要是卫生的,也不会保护你在调用位置与周围代码中的标识符发生交互。相反,你的宏应该明确指出哪些标识符应该与周围的代码重叠(使用 Span::call_site),哪些应该被视为宏的私有部分(使用 Span::mixed_site,我们将在后面讨论)。
属性宏
属性宏也是将属性分配给的项批发出去,但这个宏需要两个输入:出现在属性中的标记树(减去属性的名称)和它所连接的整个项的标记树,包括该项可能有的任何其他属性。属性宏允许你轻松地编写一个过程宏来改变一个项目,例如通过为一个函数定义添加前奏或尾声(像 #[test] 那样),或者通过修改一个结构的字段。
派生宏
派生宏与其他两个宏略有不同,它是对宏的目标进行添加,而不是替换。尽管这个限制看起来很严重,但派生宏是创建过程宏的最初动机之一。具体来说,Serde crate 需要派生宏来实现其现在众所周知的 #[derive(Serialize, Deserialize)] 魔法。
派生宏可以说是过程宏中最简单的,因为它们有如此严格的形式:你只能在被注解的项目之后追加项目;你不能替换被注解的项目,而且你不能让派生接受参数。派生宏确实允许你定义辅助属性--这些属性可以放在被注释的类型中,为派生宏提供线索(比如 #[serde(skip)])--但这些功能主要是像标记一样,不是独立的宏。
过程宏的成本
在我们讨论不同的过程宏类型何时合适之前,值得讨论的是,为什么你在使用过程宏之前可能要三思而后行--即增加编译时间。
过程宏可以显著增加编译时间,主要有两个原因。首先,它们往往会带来一些相当重的依赖性。例如,syn crate 为 Rust 标记流提供了一个解析器,使编写过程宏的经验更加容易,在启用所有功能的情况下,编译需要几十秒。你可以(也应该)通过禁用你不需要的功能,并在调试模式而不是发布模式下编译你的过程宏来缓解这一问题。代码在调试模式下的编译速度通常要快几倍,对于大多数过程宏,你甚至不会注意到执行时间的差异。
过程宏增加编译时间的第二个原因是,它使你很容易在不知不觉中生成大量的代码。虽然宏使你不必实际输入生成的代码,但它并没有使编译器不必解析、编译和优化它。当你使用更多的过程宏时,生成的模板就会增加,而且会使你的编译时间变长。
这就是说,过程宏的实际执行时间很少是整个编译时间的一个因素。虽然编译器必须等待过程宏做完它的事情才能继续,但实际上,大多数过程宏不做任何繁重的计算。 也就是说,如果你的过程宏特别复杂,你的编译可能会在过程宏代码上花费大量的执行时间,这是值得注意的。
所以你认为你想要一个宏
现在让我们看看每种过程宏的一些良好用途。 我们从简单的开始:派生宏。
何时使用派生宏
派生宏只用于一件事,而且只有一件事:在有可能实现自动化的地方,自动实现一个 trait。并非所有的 trait 都有明显的自动化实现,但许多 trait 都有。在实践中,只有当 trait 经常被实现,并且它对任何给定类型的实现都相当明显时,你才应该考虑为该 trait 添加一个派生宏。第一个条件似乎是常识;如果你的特质只被实现一两次,可能不值得为它编写和维护一个复杂的派生宏。
然而,第二个条件可能看起来比较奇怪:实现 "明显" 是什么意思?考虑一下像 Debug 这样的 trait。如果你被告知 Debug 是做什么的,并且给你看了一个类型,你可能会期望 Debug 的实现在输出每个字段的名字的同时,也输出其值的调试表示。 这就是 derive(Debug) 的作用。那么 Clone 呢?你可能希望它只是克隆每个字段--同样,这也是 derive(Clone) 所做的。对于 derive(serde::Serialize),我们希望它能序列化每个字段和它的值,而它就是这么做的。一般来说,你希望一个 trait 的派生与开发者对它可能做什么的直觉相匹配。如果一个 trait 没有明显的派生,或者更糟的是,如果你的派生与明显的实现不匹配,那么你最好不要给它一个派生宏。
何时使用函数宏
类似于函数的宏很难给出一个一般的经验法则。 你可能会说,当你想要一个类似于函数的宏,但又不能用 macro_rules! 来表达时,你应该使用类似于函数的宏!但这是一个相当主观的指导原则。毕竟,如果你真的用心去做,你可以用声明式宏来做很多事情。
有两个特别好的理由让我们去找一个类似函数的宏。
- 如果你已经有了一个声明性的宏,而它的定义变得非常多,以至于宏很难维护。
- 如果你有一个纯函数,你需要在编译时能够执行,但不能用
const fn来表达。这方面的一个例子是phfcrate,当在编译时给定一组键时,它使用一个完美的哈希函数生成一个哈希图或集。另一个是hex-literal,它接收一串十六进制的字符,并将其替换为相应的字节。一般来说,任何不只是在编译时对输入进行转换,而是真正对其进行计算的东西都可能是一个好的候选者。
我不建议为了破坏宏内的卫生状况而去找一个类函数宏。类函数宏的卫生性是一个可以避免许多调试问题的功能,在你故意破坏它之前,你应该仔细考虑。
何时使用属性宏
这给我们留下了属性宏。尽管这些可以说是过程宏中最通用的,但它们也是最难知道何时使用的。多年来,我一次又一次地看到属性宏在四个方面增加了巨大的价值。
-
测试生成(Test generation)
想要在多个不同的配置下运行同一个测试,或者用相同的引导代码运行许多类似的测试是非常常见的。虽然声明性的宏可以让你表达这一点,但如果你有一个像
#[foo_test]这样的属性,在每个注释的测试中引入设置前奏和后记,或者像#[test_case(1)]#[test_case(2)]这样的可重复属性来标记一个给定的测试应该被重复多次,每次输入一次,你的代码往往会更容易阅读和维护。 -
框架注释
像
rocket这样的库使用属性宏来为函数和类型增加额外的信息,然后框架就会使用这些信息,而用户无需进行大量的手动配置。 能够写#[get("/<name>")] fn hello(name: String)要比用函数指针等设置一个配置结构方便得多。从本质上讲,这些属性构成了一种微型的特定领域语言(DSL),它隐藏了大量的模板,否则是必要的。同样,异步 I/O 框架tokio让你使用#[tokio::main] async fn main()来自动设置运行时间并运行你的异步代码,从而使你不必在每个异步应用程序的主函数中写相同的运行时间设置。 -
透明的中间件
有些库希望以不引人注目的方式将自己注入你的应用程序,以提供不改变应用程序功能的附加值。例如,跟踪和记录库(如
tracing)和指标收集库(如metered)允许你通过向一个函数添加一个属性来透明地检测该函数,然后对该函数的每次调用都会运行一些由库决定的额外代码。 -
类型转换(Type transformers)
有时你想不仅仅是为一个类型派生特性,而是真正以某种基本方式改变类型的定义。在这种情况下,属性宏是最合适的方式。
pin_projectcrate 就是一个很好的例子:它的主要目的不是实现一个特定的 trait,而是确保对一个给定类型的字段的所有钉住访问都是按照 Rust 的Pin类型和Unpintrait 所规定的严格规则进行的(我们将在第 8 章中更多地讨论这些类型)。它通过生成额外的辅助类型,为注解的类型添加方法,以及引入静态安全检查来确保用户不会意外地射中自己的脚。虽然pin_project可以用过程性的derive宏来实现,但该派生特性的实现很可能并不明显,这违反了我们关于何时使用过程宏的规则之一。
它们是如何工作的?
所有过程宏的核心是 TokenStream 类型,它可以被迭代以获得组成该标记流的单个 TokenTree 项目。TokenTree 要么是一个单一的标记,如标识符、标点符号或字面意义,要么是另一个 TokenStream,它被一个分隔符 () 或 {} 所包围。通过走一个 TokenStream,你可以解析出任何你想要的语法,只要单个标记是有效的 Rust 标记。如果你想把你的输入解析成 Rust 代码,你可能想使用 syn crate,它实现了一个完整的 Rust 解析器,可以把 TokenStream 变成一个易于遍历的 Rust AST。
对于大多数过程宏,你不仅要解析 TokenStream,还要产生 Rust 代码,注入到调用过程宏的程序中。有两种主要的方法来做到这一点。第一种是手动构建 TokenStream,并每次扩展一个 TokenTree。第二种是使用 TokenStream 的 FromStr 实现,它让你用 "".parse::<TokenStream>() 解析一个包含 Rust 代码的字符串到 TokenStream。你也可以混合使用这些方法;如果你想给你的宏的输入预加一些代码,只需为序幕构造一个 TokenStream,然后使用 Extend trait 来追加原始输入。
注意:
TokenStream也实现了Display,它可以漂亮地打印出流中的令牌。这对于调试来说是非常方便的。
令牌比我到目前为止描述的要神奇一些,因为每个令牌,甚至每个 TokenTree,都有一个跨度(span)。跨度是编译器将生成的代码与生成它的源代码联系起来的方式。每个标记的跨度都标志着该标记的来源。例如,考虑一个像清单 7-7 中的(声明性)宏,它为所提供的类型生成了一个微不足道的 Debug 实现。
#![allow(unused)] fn main() { macro_rules! name_as_debug { ($t:ty) => { impl ::core::fmt::Debug for $t { fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { ::core::write!(f, ::core::stringify!($t)) } } }; } // 清单 7-7:实现 Debug 的一个非常简单的宏 }
现在让我们想象一下,有人用 name_as_debug!(u31) 来调用这个宏。技术上讲,编译器错误发生在宏内部,特别是我们为 $t 写的地方($t 的其他用法可以处理无效的类型)。但我们希望编译器能在用户的代码中指向 u31--事实上,这正是跨度(spans)让我们做的。
生成的代码中 $t 的跨度是映射到宏调用中 $t 的代码。这些信息会通过编译器进行处理,并与最终的编译器错误相关联。当编译器错误最终被打印出来时,编译器将打印出宏中的错误,说 u31 类型不存在,但会突出显示宏调用中的 u31 参数,因为那是与错误相关的跨度。
跨度(spans)是相当灵活的,如果你使用 compile_error! 宏,它可以让你编写过程宏,产生复杂的错误信息。顾名思义,compile_error! 导致编译器在任何地方发出一个错误,并以提供的字符串作为信息。这可能看起来不是很有用,直到你把它与跨度(span)配对。通过设置你为 compile_error! 调用而生成的 TokenTree 的跨度等于输入的某个子集的跨度,你就有效地告诉编译器发出这个编译错误,并将用户指向源代码的这一部分。这两种机制结合在一起,让宏产生的错误似乎源于代码的相关部分,即使实际的编译器错误是在生成的代码中的某个地方,而用户甚至从来没有看到过!"。
注意:如果你曾经好奇
syn的错误处理是如何进行的,它的Error类型实现了一个Error::to_compile_error方法,将它变成了一个只持有compile_error!指令的TokenStream。syn的Error类型的特别之处在于,它在内部持有一个错误集合,每个错误集合都会产生一个独立的compile_error!指令,并有自己的跨度,这样你就可以很容易地从程序宏中产生多个独立的错误。
跨度(spans)的力量还不止于此;跨度也是 Rust 的宏观卫生的实现方式。当你构造一个 Ident 标记时,你也给出了该标识符的跨度,而该跨度决定了该标识符的范围。如果你把标识符的跨度设置为 Span::call_site(),标识符就会在宏被调用的地方被解析,因此不会与周围的范围隔离。另一方面,如果你把它设置为 Span::mixed_site(),那么(变量)标识符就会在宏的定义处被解析,因此对于调用处的类似命名的变量来说,是完全卫生的。Span::mixed_site 之所以被称为 "mixed_site",是因为它与 macro_rules! 的标识符卫生规则相匹配!正如我们前面所讨论的,在变量使用宏定义站点和类型、模块和其他一切使用调用站点之间 "混合 "了标识符解析。
总结
在这一章中,我们介绍了声明性和过程宏,并探讨了在你的代码中何时会发现它们各自的作用。我们还深入探讨了支撑每种类型的宏的机制,以及当你编写自己的宏时需要注意的一些特性和问题。在下一章中,我们将开始我们的异步编程和 Future 特性的旅程。我保证--它就在下一页。