第七章 宏

宏在本质上是一种让编译器为你生成代码的工具. 你向编译器提供一套根据输入参数生成代码的规则, 编译器会将每次宏调用替换为应用该规则后生成的代码, 宏可以被看作一种由你定义替换规则的自动代码替换机制.

Rust的宏具有多种形式和结构, 使实现各种类型的代码生成变得更容易. 主要有两种宏: 声明性宏和过程宏, 本章将对它们逐一进行探讨. 我们还将介绍宏在日常编码中的常见用途, 以及在更高级用法中可能遇到的一些陷阱.

来自C系语言的程序员可能习惯于CC++的那片"混乱之地", 在那里你可以用#define将每个true改为false, 甚至移除所有的else关键字. 如果你也是如此, 就需要摆脱"宏等于不良实践"的刻板印象. Rust中的宏远不如C的宏那般"狂野失控". 它们遵循(大多)定义明确的规则, 并且对误用具有较强的防护性.

声明宏(Declarative Macros)

声明式宏是使用macro_rules!语法定义的, 它允许你方便地定义类似函数的宏, 而无需像过程宏那样编写一个专用的crate. 定义好声明式宏后, 可以通过宏名加感叹号来调用它. 我更倾向于将这种宏看作一种由编译器辅助完成的查找替换工具: 它适用于许多规律明确、结构良好的转换任务, 并能有效消除重复样板代码. 到目前为止, 你在Rust中遇到的大多数宏都可能是声明性宏. 但请注意, 并非所有函数形式的宏都是声明性宏; macro_rules! 本身就是一个例外, format_args!也是如此. !仅表示该宏调用会在编译时被替换为不同的源代码.

注意: 由于Rust的解析器专门识别和解析带有!的宏调用, 因此它们只允许用于解析器允许的位置. 它们在大多数常见场景中都可以使用, 比如在表达式位置或impl块中, 但并非适用于所有地方. 例如(截至目前), 在需要标识符或match分支的位置无法调用函数形式的宏.

声明式宏为何被称为声明式, 这一点可能并不直观. 毕竟, 在程序中你不是一直都在"声明"各种内容吗? 在这里, "声明式"指的是: 你并不指定输入应如何被转换为输出, 而只是声明"当输入是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!时, 每种类型只需占一行, 宏会自动为truefalse两种参数生成测试. 我们还可以扩展它, 使其为不同的init值生成测试. 这样一来, 就大大降低了遗漏某个配置项测试的风险.

Trait实现方面的情况也类似. 如果你定义了一个自定义的trait, 通常会希望为标准库中的多个类型实现它, 即使这些实现非常简单. 假设你发明了一个名为Clonetrait, 并希望为标准库中所有实现了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:Copy的类型添加一个通用的Clone的实现. 我们当然可以这么做, 但主要原因之一是, 这样会强制其他crate中那些恰好是Copy的类型也使用这个统一的Clone实现. 编译器中有一个叫做specialization(特化)的实验性feature提供一个解决方法, 但截至目前, 这个trait仍未稳定. 所以, 在当前阶段, 手动枚举具体类型会更合适. 这种模式不仅适用于简单的转发实现, 例如, 你也可以轻松地修改清单7-3中的代码, 对所有整数类型实现AddOne trait.

注意: 如果你在犹豫该使用泛型还是声明式宏, 那你应该优先选择泛型. 泛型通常比宏更易用, 并且能更好地与语言中的其他结构集成. 记住这个经验法则: 如果代码的变化依赖于类型, 就使用泛型; 否则就使用宏.

他们如何工作的

每种编程语言都有自己的语法规则, 规定了如何将源代码中的字符转化为token. token是语言的最底层构建单元, 包括数字、标点符号、字符串和字符字面量, 以及标识符; 在这个层级上, 语言关键字和变量名没有区别. 例如, 在Rust的语法中, 文本(value + 4)在会被表示为五个token: (, value, +, 4, ). 将文本转化为token的过程, 为编译器的其余部分与底层繁锁的文本解析细节之间提供了一层抽象. 例如, 在token表示法中, 空白字符是不存在的; /*"foo"*/"/*foo*/"表达的语义完全不同: 前者不会生成任何token, 后者会生成一个内容为/*foo*/的字符串字面量token.

一旦源代码被转换为token序列, 编译器会遍历这些token并为它们分配语法意义. 例如, 以()为界限的token构成一个组, !表示宏调用, 等等. 这就是解析的过程, 它最终生成一个抽象语法树(AST), 描述了源代码所代表的结构. 举个例子, 考虑表达式let x = || 4, 它由以下token序列组成: let(关键字)、x(标识符)、=(标点符号)、两个|(标点符号)和4(字面意思). 当编译器将其转化为语法树时, 它表示为一个语句, 其中模式是标识符x, 其右侧表达式是一个闭包, 闭包有一个空参数列表, 且其主体是一个整数4的字面量表达式. 请注意, 语法树的表示方法比token序列要丰富得多, 因为它为符合语言语法的token组合赋予了语法意义.

Rust宏决定了一段特定token序列应被转换为何种语法树, 当编译器在解析过程中遇到一个宏调用时, 它必须先对宏求值, 以确定替换用的token序列, 而这组token最终会构成该宏调用对应的语法树. 然而, 此时编译器仍处于解析token阶段, 尚未准备好对宏进行求值, 因为它此时仅仅解析了宏定义的token. 因此, 编译器会推迟解析宏调用括号内的内容, 并将其中的输入token序列暂存下来. 当编译器准备好对宏进行求值时, 它会以先前暂存的token序列作为输入对宏求值, 解析宏展开所生成的token, 并将得到的语法树插入到原宏调用的位置中.

从技术上讲, 编译器确实会对宏的输入进行一部分解析. 具体来说, 它会解析出诸如字符串字面量和有界分组等基本结构, 因此生成的是一系列的token树, 而不仅仅是普通的token. 例如, 代码x - (a.b + 4)解析为三个token树的序列. 第一个token树是标识符x, 第二个token树是标点符号-的, 第三个token树是使用括号括起来的分组, 该组本身由五个token树组成: a(一个标识符)、.(标点符号)、b(另一个标识符)、+(另一个标点符号)和 4(字面量). 这意味着宏的输入不一定是合法的Rust语句, 但它必须是Rust编译器可以解析的代码结构. 例如, 在Rust中不能在宏调用之外编写for <- x, 但在宏调用内部可以, 只要宏展一后生成的是合法的语法. 另一方面, 你不能将for{传递给宏, 因为它缺少闭合的右大括号.

声明式宏总是生成合法有效的Rust代码作为输出. 你不能让一个宏生成函数调用的一半, 或者只生成一个if而不包含随后的代码块. 声明式宏必须生成一个表达式(基本上是任何可以赋值给变量的任何东西), 一个语句, 如let x = 1; 一个条目, 如trait定义或impl 块, 一个类型, 或者一个模式匹配. 这使得Rust的宏机制较强的误用抵抗力: 你根本无法编写出会生成非法Rust代码的声明式宏, 因为宏的定义本身就无法通过编译!

从宏观层面来看, 声明式宏的机制就是这些--当编译器遇到宏调用时, 它会将调用括号中的token传递给宏, 解析宏展开所产生的token流, 然后用生成的抽象语法树(AST)替换原始的宏调用.

如何编写声明式宏

要详尽解释声明式宏所支持的全部语法超出了本书的范围. 不过我们会介绍基本语法, 因为其中确实存在一些值得注意的特殊之处.

声明性宏由两个主要部分组成: 匹配器(matchers)和转换器(transcribers). 一个宏可以包含多个匹配器, 每个匹配器都对应一个转换器. 当编译器遇到一个宏调用时, 它会按顺序依次尝试宏中的每个匹配器, 一旦找到一个能匹配调用中标志的匹配器, 它就会使用相应转换器中的内容替换掉宏调用. 代码清单7-4显示了声明式宏规则的各个部分是如何协同工作的.

#![allow(unused)]
fn main() {
macro_rules! /* macro name */ {
    (/* 1st matcher */) => { /* 1st transcriber */ };
    (/* 2nd matcher */) => { /* 2nd transcriber */ };
}

// 清单 7-4: 声明性宏定义组件
}

匹配器(Matchers)

你可以把宏匹配器看作是一个token树, 编译器会尝试以预定义的方式"扭转"它, 以匹配它在调用地点得到的输入token树. 举个例子, 假设有一个宏匹配器是$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),+
}

关键在于, 调用使用该匹配器的宏时, keyvalue可以是任意复杂的表达式, 匹配器的魔力将确保键和值表达式被正确地划分开.

宏支持多种片段类型; 你已经见到用于标识符的:ident和用于表达式的:expr, 但还有用于类型的:ty, 甚至还有表示任意单个token树的: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中出现在调用它的模块之前, 而不能在其之后. 如果foobar是位于crate根部下的两个模块, 且foo定义了一个bar想要使用的宏, 那么在lib.rs中, mod foo必须出现在mod bar之前。

注意: 这是宏作用域规则(正式称为"文本作用域")有一个例外, 那就是如果你使用了#[macro_export]宏. 这个标注会实质上将宏提升到crate的根作用域, 并将其标志为pub, 这样它就可以在crate的任何地方使用, 甚至可以被依赖这个crate的其他crate使用.

过程宏(Procedural Macros)

你可以把过程宏看作是解析器和代码生成的组合, 而你要做的就是写中间的粘合代码. 在宏观上看, 对于过程宏, 编译器收集传递给宏的输入token序列, 然后运行你的程序, 以确定用哪些token来替换它们.

过程宏之所以被称为"过程宏", 是因为你要定义如何根据输入token来生成代码, 而不是直接写出要生成的代码. 编译器这边并没有太多智能可言--在它看来, 过程宏差不多就是一个可以任意替换代码的源代码预处理器. 唯一的要求是你的输入必须能够被解析为一串合法的Rust token, 但仅此而已!

过程宏的类型(Types of Procedural Macros)

过程宏有三种不同的形式, 每种形式都专门针对一个常见的使用场景

  • 函数宏, 如macro_rules!生成的.
  • 属性宏, 如#[test].
  • 派生宏, 如#[derive(Serialize)].

这三种类型都使用相同的底层机制: 编译器会将一串token传递给你的宏, 并期望你返回一串可能与输入语法树相关的token作为输出. 不过, 它们在宏的调用方式以及输出的处理方式上有所不同. 我们将简要地介绍每一种形式.

函数宏(Function-Like Macros)

函数宏是过程宏中最简单的形式. 它与声明宏类似, 只是将调用处的宏代码替换为过程宏生成的代码. 然而, 与声明式宏不同的是, 它不再有任何"护栏"限制: 这些宏(像所有的过程式宏一样)不要求具备"卫生性", 也不会阻止你与调用处周围代码中的标识符产生冲突. 相反, 你需要在宏中显式指定哪些标识符应该与外部代码重叠(使用Span::call_site), 以及哪些标识符应当被视为宏内部私有的(使用Span::mixed_site, 我们稍后会讨论这个).

属性宏

属性宏也会整体替换其所修饰的代码, 但这个宏需要两个输入: 一个是属性中的token树(不包括属性的名字), 另一个是它所修饰的整个项目的token树, 包括该项目可能具有的其他属性. 属性宏允许你轻松地编写一个过程宏来转换某个项, 例如可以为函数定义添加前置或后置代码(像#[test]那样), 或者修改结构体的字段.

派生宏

派生宏与其他两个宏略有不同, 它是对宏的目标进行添加内容, 而不是替换它. 尽管这个限制看起来很严苛, 但派生宏实际上是促使创建过程宏的最初动力之一. 具体来说, Serde crate需要派生宏来实现其现在广为人知的#[derive(Serialize, Deserialize)]魔法.

派生宏可以说是过程宏中最简单的, 因为它们有如此严格的形式: 你只能在被注解的项之后追加项; 你不能替换被注解的项, 也不能让推导过程接受参数. 派生宏确实允许你定义辅助属性--这些属性可以放在被标注的类型内部中, 以提供线索给派生宏(比如#[serde(skip)])--但这些属性大多充当标记作用, 不是独立的宏.

过程宏的成本

在我们讨论每种不同的过程宏类型适用的情况之前, 值得讨论的是, 为什么在使用过程宏之前你可能需要三思——也就是编译时间的增加.

过程宏可能会显著增加编译时间, 主要有两个原因. 首先, 它们往往会带来一些相当重的依赖. 例如, syn crateRust token流解析器, 使得编写过程宏的体验更加容易, 但是启用所有feature时, 它的编译可能需要几十秒. 你可以(并且应该)通过禁用你不需要的feature, 并在调试模式而不是发布模式下编译过程宏来减轻这一问题. 代码在调试模式下的通常编译得更快, 对于大多数过程宏, 你甚至不会注意到执行时间的差异.

过程宏增加编译时间的第二个原因是, 它们让你很容易生成大量的代码而且没有意识到. 虽然宏可以让你不必实际输入生成的代码, 但它并没有节省编译器的解析、编译和优化这些代码的时间. 随着你使用更多的过程宏, 生成的样板代码会积累起来, 而且可能导致编译时间膨胀.

这就是说, 过程宏的实际执行时间通常很少会成为整体编译时间的因素. 虽然编译器必须等待过程宏完成其操作后才能继续, 但实际上, 大多数过程宏并不会进行任何复杂的计算. 也就是说, 如果你的过程宏特别复杂, 你可能会发现编译时间的很大一部分被你的程序宏代码所占用, 这是值得注意的.

所以你认为你想要一个宏

现在让我们看看每种过程宏的一些使用场景. 我们从简单的开始: 派生宏.

何时使用派生宏

派生宏只用于一件事: 在可以自动化的情况下, 自动实现某个trait. 并非所有的trait都能轻松自动化实现, 但很多可以. 在实践中, 只有某个trait经常被实现, 并且它对任何给定类型的实现方式都比较明确时, 你才应该考虑为它添加一个派生宏. 第一个条件似乎是常识; 如果你的trait只会被实现一两次, 可能不值得为它编写和维护一个复杂的派生宏.

不过, 第二个条件可能就没有那么直观了: 所谓"明显"的实现到底是什么意思? 以Debug这样的trait为例. 如果你知道Debug是做什么的, 并且给定一个类型, 你可能会期望Debug的实现会输出每个字段的名字以及该字段值的调试形式. 这就是derive(Debug)的作用. 那么Clone呢? 你大概会希望它对每个字段都执行一次克隆--而derive(Clone)也确实就是这么做的. 对于derive(serde::Serialize), 我们期望它序列化每个字段及值, 而它也确实照做了. 通常来说, 你希望trait的派生能符合开发者对其功能的直觉理解. 如果某个trait没有明显的派生, 或者更糟的是, 你的派生实现和开发者直觉中的实现方式不一致, 那你最好就不要为它提供派生宏.

何时使用函数宏

函数宏比较难给出一条通用的经验法则. 你也许会说, 当你想要一个类似于函数的宏, 但又无法用macro_rules!来表达时, 就该用函数式宏! 但这是一个相当主观的指导原则. 毕竟, 如果你真的用心去做, 声明宏也能实现很多功能.

不过, 有两个特别好的理由可以让你选择使用函数式宏:

  • 如果你已经有了一个声明宏了, 而它的定义变得非常复杂, 以至于宏难以维护.
  • 如果你有一个纯函数需要在编译时执行, 但又无法用const fn来表达, 那么这就是使用函数式宏的一个好理由. 列如phf crate, 当在编译时给定一组键时, 它会使用完美哈希函数来生成一个哈希映射或集合. 另一个例子是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)

有时修, 你希望做的不仅仅是为一个类型派生trait, 而是从根本上修改类型的定义. 在这种情况下, 属性宏是最合适的方式. pin_project crate就是一个很好的例子: 它的主要目的并不是实现某个特定的trait, 而是确保对某个类型字段的所有pinned访问, 都遵循RustPin类型和Unpin trait所规定的严格规则(我们将在第8章中进一步讨论这些类型). 它通过生成额外的辅助类型, 向被注解的类型添加方法, 以及引入静态安全检查机制来实现这点, 从而确保用户不会因为操作不当而"搬起石头砸自己的脚". 虽然pin_project也可以用派生宏来实现, 但那个派生trait的实现并不直观, 这就违反了我们关于何时使用过程宏的一条规则.

它们是如何工作的?

所有过程宏的核心是TokenStream类型, 它可以被迭代以获取构成该token流的各个TokenTree项. 这些TokenTree项要么是单个的token, 例如标识符、标点符号或字面值, 要么是被一个分隔符((){})包裹的另一个TokenStream. 通过遍历TokenStream, 你可以解析出任何你想要的语法, 只要这些单个token是合法的Rust token. 如果你希望将输入明确地作为Rust代码来解析, 你可能需要使用syn crate, 它实现了一个完整的Rust解析器, 可以将TokenStream变成一个易于遍历的Rust抽象语法树(AST).

对于大多数过程宏, 你不仅要解析TokenStream, 还需要生成要注入到调用该过程宏的程序中的Rust代码. 有两种主要的方法来实现这一点. 第一种是手动构建TokenStream, 并每次添加一个TokenTree. 第二种是使用TokenStreamFromStr实现, 调用"".parse::<TokenStream>()将包含Rust代码的字符串解析到TokenStream. 你也可以混合使用这两种方法; 如果你想在宏的输入前添加一些代码, 只需为前置代码构造一个TokenStream, 然后使用Extend trait来追加原始输入即可.

注意: TokenStream也实现了Display trait, 它可以漂亮地打印出流中的token. 这对于调试来说是非常方便的.

token比我到目前描述的要神奇一些, 因为每个token, 甚至每个TokenTree, 都有一个span. span是编译器用来生成的代码与其来源代码关联起来的方式. 每个tokenspan都标志了该token的来源. 例如, 考虑一个类似标列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)来调用这个宏. 技术上讲, 编译器错误发生在宏内部, 具体是在我们写for $t的地方(而$t 的另一个用法可以处理无效的类型). 但我们希望编译器能够把错误指向用户代码中的u31--而这正是span所能做到的.

生成的代码中$tspan是在宏调用中映射到$t的代码. 这条信息会在编译器中传递下去, 并与最终的编译器错误关联起来. 当该编译器最终被打印出来时, 编译器会打印宏内部的错误, 指出u31类型不存在, 它会高亮宏调用中作为参数的u31, 因为那才是该错误所关联的span.

span是非常灵活的, 如果你使用compile_error!宏, 它们可以让你编写出能够生成复杂错误信息的过程宏. 顾名思义, compile_error!会在其所在位置让编译器抛出错误, 并使用所提供的字符串作为错误信息. 这看起来可能没什么用, 直到你将它与span配合使用. 通过将你为compile_error!所生成的TokenTreespan设置为输入的一部份代码的span, 你实际上是在告诉编译器: 抛出这个错误, 并指向源代码中的这部分位置. 这两种机制结合使用, 可以让宏生成的错误看起来就像来自代码中相关部分, 即便实际的编译器错误其实发生在用户根本看不到的生成代码中.

注意: 如果你曾好奇syn的错误处理是如何工作的, 它的Error类型实现了一个Error::to_compile_error方法, 这个方法将其转化为一个只包含compile_error!指令的TokenStream. synError类型的特别巧妙之处在于, 它内部保存了一个错误集合, 每个错误都会产生一个独立的compile_error!指令, 并有自己的span, 这样你就可以轻松地从过程宏中生成多个独立的错误.

span的强大功能不仅于此; 它们还是Rust宏卫生实现的关键. 当你构造一个Ident token时, 你还需要为该标识符指定一个span, 而这个span决定了该标识符的作用域. 如果你将标识符的span设置为Span::call_site(), 那么该标识符将在宏被调用的地方被解析, 因此不会与周围的作用域隔离开. 另一方面, 如果你把它设置为Span::mixed_site(), 那么(变量)标识符就会在宏的定义处被解析, 这样就能确保宏与调用站点的同名变量完全遵循宏卫生规则. Span::mixed_site之所以如此命名, 是因为它符合macro_rules!中标识符卫生规则! 正如我们之前讨论的, 它在解析标识符时, 将宏定义站点用于变量, 而调用站点用于类型、模块以及其他所有内容.

总结

在本章中, 我们介绍了声明宏和过程宏, 并讨论了在何种情况下你可能会在自己的代码中使用它们. 我们还深入探讨了支撑每种类型宏的机制, 以及在编写自己宏时需要注意的一些特性和陷阱. 在下一章中, 我们将开始探索异步编程和Future特性. 我保证--它就在下一页.