条款28:谨慎使用宏
“在某些情况下,决定编写宏而不是函数很容易,因为只有宏才能完成所需的操作。” – Paul Graham, "On Lisp (Prentice Hall)"
Rust 的宏系统允许您执行元编程:编写代码将代码发送到您的项目中。当存在确定性和重复性的“样板”代码块并且否则需要手动保持同步时,这最有价值。
接触 Rust 的程序员可能以前遇到过 C/C++ 预处理器提供的宏,它们对输入文本的标记执行文本替换。Rust 的宏是另一种野兽,因为它们可以处理程序的解析标记或程序的抽象语法树 (AST),而不仅仅是其文本内容。
这意味着 Rust 宏可以了解代码结构,从而可以避免整个与宏相关的错误类别。特别是,我们在以下部分中看到 Rust 的声明性宏是卫生的 - 它们不会意外引用(“捕获”)周围代码中的局部变量。
思考宏的一种方法是将它们视为代码中不同级别的抽象。一种简单的抽象形式是函数:它抽象出相同类型的不同值之间的差异,实现代码可以使用该类型的任何特性和方法,而不管当前操作的值是什么。泛型是不同级别的抽象:它抽象出满足特征边界的不同类型之间的差异,实现代码可以使用特征边界提供的任何方法,而不管当前操作的类型是什么。
宏抽象出程序中扮演相同角色(类型、标识符、表达式等)的不同片段之间的差异;然后实现可以包含以相同角色使用这些片段的任何代码。
Rust 提供了两种定义宏的方法:
- 声明宏,也称为“示例宏”,允许根据宏的输入参数(根据其在 AST 中的角色分类)将任意 Rust 代码插入程序中。
- 过程宏允许根据源代码的解析标记将任意 Rust 代码插入程序中。这最常用于派生宏,它可以根据数据结构定义的内容生成代码。
声明宏
虽然本条目不是重现声明性宏文档的地方,但还是需要提醒大家注意一些细节。
首先,请注意使用声明性宏的范围规则与其他 Rust 条目不同。如果在源代码文件中定义了声明性宏,则只有宏定义之后的代码才能使用它:
#![allow(unused)] fn main() { fn before() { println!("[before] square {} is {}", 2, square!(2)); } /// Macro that squares its argument. macro_rules! square { { $e:expr } => { $e * $e } } fn after() { println!("[after] square {} is {}", 2, square!(2)); } }
#![allow(unused)] fn main() { error: cannot find macro `square` in this scope --> src/main.rs:4:45 | 4 | println!("[before] square {} is {}", 2, square!(2)); | ^^^^^^ | = help: have you added the `#[macro_use]` on the module/import? }
#[macro_export]
属性使宏更加广泛地可见,但这也有一个奇怪之处:宏出现在包的顶层,即使它是在模块中定义的:
#![allow(unused)] fn main() { mod submod { #[macro_export] macro_rules! cube { { $e:expr } => { $e * $e * $e } } } mod user { pub fn use_macro() { // Note: *not* `crate::submod::cube!` let cubed = crate::cube!(3); println!("cube {} is {}", 3, cubed); } } }
Rust 的声明性宏是所谓的卫生的:宏主体中的展开代码不允许使用局部变量绑定。例如,假设某个变量 x 存在的宏:
#![allow(unused)] fn main() { // Create a macro that assumes the existence of a local `x`. macro_rules! increment_x { {} => { x += 1; }; } }
使用时会触发编译失败:
#![allow(unused)] fn main() { let mut x = 2; increment_x!(); println!("x = {}", x); }
#![allow(unused)] fn main() { error[E0425]: cannot find value `x` in this scope --> src/main.rs:55:13 | 55 | {} => { x += 1; }; | ^ not found in this scope ... 314 | increment_x!(); | -------------- in this macro invocation | = note: this error originates in the macro `increment_x` }
这种卫生特性意味着 Rust 的宏比 C 预处理器宏更安全。但是,在使用它们时仍有几个小问题需要注意。
首先要意识到,即使宏调用看起来像函数调用,但实际上并不是。宏在调用时生成代码,并且生成的代码可以对其参数执行操作:
#![allow(unused)] fn main() { macro_rules! inc_item { { $x:ident } => { $x.contents += 1; } } }
这意味着关于参数是否被移动或&
-引用的正常直觉不适用:
#![allow(unused)] fn main() { let mut x = Item { contents: 42 }; // type is not `Copy` // Item is *not* moved, despite the (x) syntax, // but the body of the macro *can* modify `x`. inc_item!(x); println!("x is {x:?}"); }
#![allow(unused)] fn main() { x is Item { contents: 43 } }
如果我们记得宏在调用时插入代码(在本例中,添加一行增加 x.contents 的代码),这一点就变得很清楚了。cargo-expand 工具显示了编译器在宏扩展后看到的代码:
#![allow(unused)] fn main() { let mut x = Item { contents: 42 }; x.contents += 1; { ::std::io::_print(format_args!("x is {0:?}\n", x)); }; }
扩展的代码包括通过项目的所有者而不是引用进行的修改。(同样有趣的是,println! 的扩展版本依赖于稍后讨论的 format_args! 宏。)1
因此,感叹号起到了警告的作用:宏的扩展代码可能会对其参数执行任意操作。
扩展的代码还可以包括调用代码中不可见的控制流操作,无论是循环、条件、返回语句还是使用 ? 运算符。显然,这很可能违反最小惊讶原则,因此在可能和适当的情况下,最好选择行为与正常 Rust 一致的宏。(另一方面,如果宏的目的是允许奇怪的控制流,那就去做吧!但要确保控制流行为有明确的记录,以帮助您的用户。)
例如,考虑一个宏(用于检查 HTTP 状态代码),它在其主体中默默地包含一个返回:
#![allow(unused)] fn main() { /// Check that an HTTP status is successful; exit function if not. macro_rules! check_successful { { $e:expr } => { if $e.group() != Group::Successful { return Err(MyError("HTTP operation failed")); } } } }
使用此宏检查某种 HTTP 操作的结果的代码最终可能会产生有些模糊的控制流:
#![allow(unused)] fn main() { let rc = perform_http_operation(); check_successful!(rc); // may silently exit the function // ... }
生成发出结果的代码的宏的替代版本:
#![allow(unused)] fn main() { /// Convert an HTTP status into a `Result<(), MyError>` indicating success. macro_rules! check_success { { $e:expr } => { match $e.group() { Group::Successful => Ok(()), _ => Err(MyError("HTTP operation failed")), } } } }
给出更容易理解的代码:
#![allow(unused)] fn main() { let rc = perform_http_operation(); check_success!(rc)?; // error flow is visible via `?` // ... }
使用声明式宏时要注意的第二件事是与 C 预处理器共有的一个问题:如果宏的参数是具有副作用的表达式,则要注意不要在宏中重复使用该参数。前面定义的 square! 宏将任意表达式作为参数,然后使用该参数两次,这可能会导致意外:
#![allow(unused)] fn main() { let mut x = 1; let y = square!({ x += 1; x }); println!("x = {x}, y = {y}"); // output: x = 3, y = 6 }
假设这种行为不是有意的,那么修复该问题的一种方法就是简单地对表达式进行一次求值,然后将结果赋给局部变量:
#![allow(unused)] fn main() { macro_rules! square_once { { $e:expr } => { { let x = $e; x*x // Note: there's a detail here to be explained later... } } } // output now: x = 2, y = 4 }
另一种选择是不允许将任意表达式作为宏的输入。如果将 expr 语法片段说明符替换为 ident 片段说明符,则宏将只接受标识符作为输入,并且尝试向其输入任意表达式将不再编译。
格式化值
一种常见的声明式宏样式涉及组装一条消息,该消息包含来自代码当前状态的各种值。例如,标准库包括 format!
用于组装字符串、println!
用于打印到标准输出、eprintln!
用于打印到标准错误,等等。文档描述了格式化指令的语法,它们大致相当于 C 的 printf
语句。但是,格式参数是类型安全的,并在编译时进行检查,并且宏的实现使用第 10 条中描述的 Display
和 Debug
特征来格式化单个值。
您可以(并且应该)对任何执行类似功能的宏使用相同的格式化语法。例如,log
crate 提供的日志记录宏使用与 format!
相同的语法。为此,请对执行参数格式化的宏使用 format_args!
,而不是尝试重新发明轮子:
#![allow(unused)] fn main() { /// Log an error including code location, with `format!`-like arguments. /// Real code would probably use the `log` crate. macro_rules! my_log { { $($arg:tt)+ } => { eprintln!("{}:{}: {}", file!(), line!(), format_args!($($arg)+)); } } }
#![allow(unused)] fn main() { let x = 10u8; // Format specifiers: // - `x` says print as hex // - `#` says prefix with '0x' // - `04` says add leading zeroes so width is at least 4 // (this includes the '0x' prefix). my_log!("x = {:#04x}", x); }
#![allow(unused)] fn main() { src/main.rs:331: x = 0x0a }
过程宏
Rust 还支持过程宏,通常称为 proc
宏。与声明性宏一样,过程宏能够将任意 Rust 代码插入程序的源代码中。但是,宏的输入不再只是传递给它的特定参数;相反,过程宏可以访问与原始源代码的某些块相对应的解析标记。这提供了接近动态语言(如 Lisp)灵活性的表达能力水平——但仍然具有编译时保证。它还有助于减轻 Rust 中反射的限制,如第 19 项所述。
过程宏必须在使用它们的单独包(包类型为 proc-macro
)中定义,并且该包几乎肯定需要依赖 proc-macro
(由标准工具链提供)或 proc-macro2
(由 David Tolnay 提供)作为支持库,以便能够使用输入标记。
过程宏有三种不同的类型:
- 函数类宏:使用参数调用
- 属性宏:附加到程序中的一些语法块
- 派生宏:附加到数据结构的定义
函数式宏
函数式宏使用参数调用,宏定义可以访问组成参数的解析标记,并发出任意标记作为结果。请注意,上一句说的是“参数”,单数——即使函数式宏使用看起来像多个参数的参数调用:
#![allow(unused)] fn main() { my_func_macro!(15, x + y, f32::consts::PI); }
宏本身接收单个参数,即解析后的标记流。宏实现仅在编译时打印流的内容:
#![allow(unused)] fn main() { use proc_macro::TokenStream; // Function-like macro that just prints (at compile time) its input stream. #[proc_macro] pub fn my_func_macro(args: TokenStream) -> TokenStream { println!("Input TokenStream is:"); for tt in args { println!(" {tt:?}"); } // Return an empty token stream to replace the macro invocation with. TokenStream::new() } }
显示与输入对应的流:
#![allow(unused)] fn main() { Input TokenStream is: Literal { kind: Integer, symbol: "15", suffix: None, span: #0 bytes(10976..10978) } Punct { ch: ',', spacing: Alone, span: #0 bytes(10978..10979) } Ident { ident: "x", span: #0 bytes(10980..10981) } Punct { ch: '+', spacing: Alone, span: #0 bytes(10982..10983) } Ident { ident: "y", span: #0 bytes(10984..10985) } Punct { ch: ',', spacing: Alone, span: #0 bytes(10985..10986) } Ident { ident: "f32", span: #0 bytes(10987..10990) } Punct { ch: ':', spacing: Joint, span: #0 bytes(10990..10991) } Punct { ch: ':', spacing: Alone, span: #0 bytes(10991..10992) } Ident { ident: "consts", span: #0 bytes(10992..10998) } Punct { ch: ':', spacing: Joint, span: #0 bytes(10998..10999) } Punct { ch: ':', spacing: Alone, span: #0 bytes(10999..11000) } Ident { ident: "PI", span: #0 bytes(11000..11002) } }
此输入流的低级性质意味着宏实现必须自行进行解析。例如,分离出看似单独的宏参数需要查找包含分隔参数的逗号的 TokenTree::Punct
标记。syn
crate(来自 David Tolnay)提供了一个解析库,可以帮助实现这一点,如下一节所述。
因此,使用声明性宏通常比使用类似函数的过程宏更容易,因为宏输入的预期结构可以在匹配模式中表达。
这种手动处理需求的另一面是,类似函数的过程宏可以灵活地接受不能解析为普通 Rust 代码的输入。这通常不需要(或不合理),因此类似函数的宏相对较少。
属性宏
属性宏的调用方式是将它们放在程序中某个项之前,而该项的解析标记是宏的输入。宏可以再次发出任意标记作为输出,但输出通常是输入的某种转换。
例如,属性宏可用于包装函数的主体:
#![allow(unused)] fn main() { #[log_invocation] fn add_three(x: u32) -> u32 { x + 3 } }
以便记录函数的调用:
#![allow(unused)] fn main() { let x = 2; let y = add_three(x); println!("add_three({x}) = {y}"); }
#![allow(unused)] fn main() { log: calling function 'add_three' log: called function 'add_three' => 5 add_three(2) = 5 }
这个宏的实现太大,无法包含在这里,因为代码需要检查输入标记的结构并构建新的输出标记,但 sync
crate 可以再次帮助完成这个处理。
派生宏
最后一种程序宏是派生宏,它允许生成的代码自动附加到数据结构定义(结构、枚举或联合)。这类似于属性宏,但需要注意一些派生特定方面。
首先,派生宏会添加到输入标记中,而不是完全替换它们。这意味着数据结构定义保持不变,但宏有机会附加相关代码。
第二,派生宏可以声明相关的辅助属性,然后可以使用这些属性标记标记需要特殊处理的数据结构部分。例如,serde
的 Deserialize
派生宏有一个 serde
辅助属性,可以提供元数据来指导反序列化过程:
#![allow(unused)] fn main() { fn generate_value() -> String { "unknown".to_string() } #[derive(Debug, Deserialize)] struct MyData { // If `value` is missing when deserializing, invoke // `generate_value()` to populate the field instead. #[serde(default = "generate_value")] value: String, } }
需要注意的派生宏的最后一个方面是,sync
crate 可以处理将输入标记解析为 AST 中的等效节点所涉及的大部分繁重工作。syn::parse_macro_input!
宏将标记转换为描述项目内容的 syn::DeriveInput
数据结构,而 DeriveInput
比原始标记流更容易处理。
在实践中,派生宏是最常见的过程宏类型 - 能够逐个字段(对于结构)或逐个变量(对于枚举)生成实现,这使得程序员几乎不费吹灰之力就可以提供许多功能 - 例如,通过添加一行代码,如 #[derive(Debug, Clone, PartialEq, Eq)]
。
由于派生实现是自动生成的,因此这也意味着实现会自动与数据结构定义保持同步。例如,如果要向结构添加新字段,则需要手动更新 Debug 的手动实现,而自动派生版本将无需额外努力即可显示新字段(如果不可能,则编译失败)。
何时使用宏
使用宏的主要原因是避免重复代码 — 尤其是那些必须手动与代码的其他部分保持同步的重复代码。在这方面,编写宏只是编程中通常使用的同一种泛化过程的扩展:
- 如果您对特定类型的多个值重复完全相同的代码,请将该代码封装到一个通用函数中,并从所有重复的位置调用该函数。
- 如果您对多种类型重复完全相同的代码,请将该代码封装到具有特征绑定的泛型中,并从所有重复的位置使用该泛型。
- 如果您在多个位置重复相同结构的代码,请将该代码封装到宏中,并从所有重复的位置使用该宏。
例如,只有通过宏才能避免对不同枚举变量起作用的代码重复:
enum Multi { Byte(u8), Int(i32), Str(String), } /// Extract copies of all the values of a specific enum variant. #[macro_export] macro_rules! values_of_type { { $values:expr, $variant:ident } => { { let mut result = Vec::new(); for val in $values { if let Multi::$variant(v) = val { result.push(v.clone()); } } result } } } fn main() { let values = vec![ Multi::Byte(1), Multi::Int(1000), Multi::Str("a string".to_string()), Multi::Byte(2), ]; let ints = values_of_type!(&values, Int); println!("Integer values: {ints:?}"); let bytes = values_of_type!(&values, Byte); println!("Byte values: {bytes:?}"); // Output: // Integer values: [1000] // Byte values: [1, 2] }
宏有助于避免手动重复的另一种情况是,当有关数据值集合的信息原本会分散在代码的不同区域时。
例如,考虑一个对有关 HTTP 状态代码的信息进行编码的数据结构;宏可以帮助将所有相关信息放在一起:
#![allow(unused)] fn main() { // http.rs module #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum Group { Informational, // 1xx Successful, // 2xx Redirection, // 3xx ClientError, // 4xx ServerError, // 5xx } // Information about HTTP response codes. http_codes! { Continue => (100, Informational, "Continue"), SwitchingProtocols => (101, Informational, "Switching Protocols"), // ... Ok => (200, Successful, "Ok"), Created => (201, Successful, "Created"), // ... } }
宏调用保存每个 HTTP 状态代码的所有相关信息(数值、组、描述),充当一种领域特定语言 (DSL),保存数据的真实来源。
然后,宏定义描述生成的代码;形式为 $( ... )+ 的每一行都会扩展为生成的代码中的多行,每个宏参数一行:
#![allow(unused)] fn main() { macro_rules! http_codes { { $( $name:ident => ($val:literal, $group:ident, $text:literal), )+ } => { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(i32)] enum Status { $( $name = $val, )+ } impl Status { fn group(&self) -> Group { match self { $( Self::$name => Group::$group, )+ } } fn text(&self) -> &'static str { match self { $( Self::$name => $text, )+ } } } impl core::convert::TryFrom<i32> for Status { type Error = (); fn try_from(v: i32) -> Result<Self, Self::Error> { match v { $( $val => Ok(Self::$name), )+ _ => Err(()) } } } } } }
因此,宏的整体输出负责生成从真实源值派生的所有代码:
- 包含所有变体的枚举的定义
group()
方法的定义,指示 HTTP 状态属于哪个组text()
方法的定义,将状态映射到文本描述TryFrom<i32>
的实现,用于将数字转换为状态枚举值
如果稍后需要添加额外的值,则只需添加一行:
#![allow(unused)] fn main() { ImATeapot => (418, ClientError, "I'm a teapot"), }
如果没有该宏,则必须手动更新四个不同位置。编译器会指出其中一些(因为匹配表达式需要涵盖所有情况),但不会指出全部 — TryFrom<i32>
很容易被遗忘。
由于宏在调用代码中就地展开,因此它们还可用于自动发出其他诊断信息 — 特别是通过使用标准库的 file!()
和 line!()
宏,它们会发出源代码位置信息:
#![allow(unused)] fn main() { macro_rules! log_failure { { $e:expr } => { { let result = $e; if let Err(err) = &result { eprintln!("{}:{}: operation '{}' failed: {:?}", file!(), line!(), stringify!($e), err); } result } } } }
发生故障时,日志文件会自动包含有关发生故障的原因和位置的详细信息:
#![allow(unused)] fn main() { use std::convert::TryInto; let x: Result<u8, _> = log_failure!(512.try_into()); // too big for `u8` let y = log_failure!(std::str::from_utf8(b"\xc3\x28")); // invalid UTF-8 }
#![allow(unused)] fn main() { src/main.rs:340: operation '512.try_into()' failed: TryFromIntError(()) src/main.rs:341: operation 'std::str::from_utf8(b"\xc3\x28")' failed: Utf8Error { valid_up_to: 0, error_len: Some(1) } }
宏的缺点
使用宏的主要缺点是它对代码的可读性和可维护性的影响。前面的“声明性宏”部分解释了宏允许您创建 DSL 来简洁地表达代码和数据的关键特性。然而,这意味着现在任何阅读或维护代码的人除了了解 Rust 之外,还必须了解这个 DSL——以及它在宏定义中的实现。例如,上一节中的 http_codes!
示例创建了一个名为 Status
的 Rust 枚举,但它在用于宏调用的 DSL 中不可见。
基于宏的代码的这种潜在的不可渗透性超出了其他工程师的范围:分析和与 Rust 代码交互的各种工具可能会将代码视为不透明的,因为它不再遵循 Rust 代码的语法约定。前面显示的 square_once!
宏提供了一个简单的例子:宏的主体没有按照正常的 rustfmt 规则进行格式化:
#![allow(unused)] fn main() { { let x = $e; // The `rustfmt` tool doesn't really cope with code in // macros, so this has not been reformatted to `x * x`. x*x } }
另一个例子是早期的 http_codes!
宏,其中 DSL 使用 Group
枚举变体名称(如 Informational
),既没有 Group::
前缀,也没有 use
语句,这可能会使某些代码导航工具感到困惑。
甚至编译器本身也没什么帮助:它的错误消息并不总是遵循宏使用和定义的链条。(但是,工具生态系统 [参见第 31 条] 的某些部分可以帮助解决这个问题,例如之前使用的 David Tolnay 的 cargo-expand
。)
使用宏的另一个可能的缺点是代码膨胀的可能性——一行宏调用可能会导致生成数百行代码,这些代码对于粗略查看代码来说是不可见的。当代码首次编写时,这不太可能成为问题,因为那时需要代码,并且可以节省相关人员自己编写代码的时间。但是,如果代码随后不再需要,则可能删除大量代码并不那么明显。
建议
尽管上一节列出了宏的一些缺点,但当需要保持一致但无法以其他方式合并的不同代码块时,宏仍然是完成这项工作的正确工具:当只有宏才能确保不同的代码保持同步时,请使用宏。
当需要压缩样板代码时,宏也是可以使用的工具:对于无法合并为函数或泛型的重复样板代码,请使用宏。
为了减少对可读性的影响,请尽量避免宏中的语法与 Rust 的正常语法规则相冲突;要么让宏调用看起来像正常代码,要么让它看起来足够不同,这样就不会有人混淆这两者。特别是,请遵循以下准则:
- 尽可能避免插入引用的宏扩展 - 像
my_macro!(&list)
这样的宏调用比my_macro!(list)
更符合正常的 Rust 代码。 - 尽量避免在宏中使用非局部控制流操作,这样任何阅读代码的人都可以跟踪流程,而无需了解宏的细节。
这种对 Rust 式可读性的偏好有时会影响声明性宏和过程宏之间的选择。如果您需要为结构的每个字段或枚举的每个变体发出代码,则最好使用派生宏而不是发出类型的过程宏(尽管前面部分显示了示例)——它更符合惯用语,并使代码更易于阅读。
但是,如果您要添加具有非特定于项目的功能的派生宏,请检查外部包是否已经提供了您所需的功能(参见第 25 条)。例如,将整数值转换为类似 C 的枚举的适当变体的问题已经得到很好的解决:enumn::N
、num_enum::TryFromPrimitive
、num_derive::FromPrimitive
和 strum::FromRepr
都涵盖了这个问题的某些方面。