声明宏
本章讲 Rust 的「写代码的代码」:
macro_rules!声明宏与三种过程宏(proc macro), 以及cfg/build.rs这类编译期元编程手段。 前置知识:变量与流程控制、 所有权与借用。 基准环境:rustc 1.98.1/cargo 1.98.1,Rust 2024 edition,Windows 11 + PowerShell。
本章目标
- 能说清「为什么 Rust 没有可变参数函数」,以及宏在编译期展开这一点带来的全部后果。
- 能读懂并写出
macro_rules!:片段说明符(fragment specifier)、重复(repetition)、 多条匹配臂、ttmuncher,并知道每条臂的匹配优先级。 - 能解释卫生性(hygiene)能保护什么、不能保护什么,并能在跨 crate 导出宏时正确使用
$crate::。 - 能区分三类过程宏的用途边界,写出一个
#[derive(...)]、一个属性宏和一个函数式宏。 - 能用
cfg/cfg_attr/build.rs做条件编译与代码生成,并知道build.rs什么时候是坏主意。 - 能在一张决策表上回答:「这里该用宏、泛型、trait,还是
build.rs?」 - 能识别
no rules expected this token、cannot find macro、unexpected end of macro invocation这三类高频宏报错,并各自说出至少一种修法。
为什么 Rust 需要宏
Rust 故意不提供的三样东西
先看一段你在 Java 或 Python 里随手就写、在 Rust 里写不出来的代码:
java
// Java:可变参数(varargs)
static int sum(int... xs) { int s = 0; for (int x : xs) s += x; return s; }
sum(1, 2, 3, 4, 5);python
# Python:可变参数 + 运行期反射
def call_any(fn, *args, **kwargs):
return fn(*args, **kwargs)
getattr(obj, "name_" + suffix) # 运行期按名字找属性
type("Point", (object,), {"x": 1}) # 运行期造类型Rust 三样都没有,而且不是「还没做」,是设计上不做:
| 能力 | Rust 的取舍 | 原因 |
|---|---|---|
| 可变参数函数(varargs) | 只能用宏或切片模拟 | 有了 varargs 就没有静态类型检查,printf 是反面教材 |
| 反射(reflection) | 不提供(Any 只给运行期类型 id,不给字段名) | 反射要保留类型元数据,与「零成本抽象」冲突 |
运行期生成代码 / 动态求值(eval) | 不提供 | 让静态分析和安全检查失效,二进制行为无法预测 |
| 泛型(generics) | ✅ 有,且单态化(monomorphization) | Rust 表达「对多种类型做同一件事」的主力工具 |
那「一个函数接受任意个参数」怎么办?Rust 的答案是:在编译期把调用点展开成普通代码。 这正是宏(macro)的定义。
rust
fn main() {
// println! 就是宏:它接受可变数量的参数、可变类型的参数
println!("{} + {} = {}", 1, 2, 1 + 2);
let v = vec![1, 2, 3]; // vec! 也是宏
assert_eq!(v.len(), 3); // assert_eq! 还是宏
println!("{v:?}"); // 输出:[1, 2, 3]
}宏的两条根本性质
🧠 原理:Rust 的宏只有两条根本性质,其他所有规则都是它们的推论。
- 宏在编译期展开:展开发生在类型检查之前,产物是普通的 AST(抽象语法树)节点, 之后走和手写代码完全一样的类型检查、借用检查、单态化、优化。
- 宏操作的是语法,不是值:宏看到的是一串 token(词法单元),不是运行期的数据。
由此推出几条非常实际的结论:
- 宏没有运行期开销(宏展开成普通代码,不存在「宏调用」这个运行期实体)。
- 宏不能内省类型:展开时类型还没算出来,所以
macro_rules!无法问「T有没有实现Display」。 - 宏的错误信息先天较差:展开发生在类型检查前,编译器看到的报错位置是展开后的代码, 指向宏内部的 token 而不是你写的那一行。这是宏最真实的代价。
- 宏可以生成任意语法:因为操作的是 token,所以宏能「发明」新语法,比如
vec![x; n]这种expr; expr形式——普通函数不可能有这种调用语法。
与别的语言对比:宏这个位置上都坐着谁
先建立直觉,后面每一节都会回到这张表。
| 语言 | 机制 | 展开/生效时机 | 操作对象 | 能否安全生成标识符 | 典型用途 |
|---|---|---|---|---|---|
| C / C++ | 预处理器宏 #define | 预处理,早于语法分析 | 纯文本 token | ❌ 字符串拼接,无卫生性 | MIN(a,b)、条件编译 |
| C++ | 模板(template)/ constexpr | 编译期实例化 | 类型与常量表达式 | ✅ 语言一级公民 | 泛型容器、编译期计算 |
| C++20 | Concepts + if constexpr | 编译期 | 类型约束与条件分支 | ✅ | 约束泛型、分支特化 |
| Java | 注解处理器(APT)+ 字节码生成 | 编译期生成新源文件 | Java 语法树 / 字节码 | ✅ 但需要一整轮编译 | Lombok、MapStruct、Dagger |
| Java | 反射(reflection) | 运行期 | 类的运行期元数据 | ❌(字符串) | 框架注入、序列化 |
| Python | 装饰器(decorator) | 运行期,函数对象是值 | 函数对象 | ✅(闭包/functools.wraps) | 日志、缓存、注册路由 |
| Python | 元类(metaclass)/ exec | 运行期 | 类对象 / 字符串源码 | ✅ | ORM 模型、动态 API |
| Go | go generate + 代码生成器 | 构建前的独立一步 | 源文件文本 | ✅(生成完整文件) | stringer、protobuf、mock |
| Go | 泛型(1.18+) | 编译期 | 类型参数 | ✅ | 容器、算法 |
| Lisp / Scheme | 宏(defmacro / syntax-rules) | 读取/展开期 | S-表达式本身 | ✅ syntax-rules 卫生 | 几乎一切(Lisp 里宏是日常) |
| Scala | 宏(inline + 引用 '{ }) | 编译期 | Scala 3 AST (quotes) | ✅ | 类型类派生、零成本封装 |
| Rust | macro_rules! 声明宏 | 编译期,token 模式匹配 | token 树 | ⚠️ 局部变量卫生,类型/函数名不卫生 | vec!、println!、assert! |
| Rust | 过程宏(proc macro) | 编译期,编译器插件 | proc_macro::TokenStream(可 syn 解析) | ✅ 完全可控(Span::call_site()) | #[derive(Serialize)]、sql! |
用一句话概括 Rust 的定位:
💡 对照:Rust 的
macro_rules!像「C 预处理器宏,但操作的是语法树而不是文本, 而且有局部卫生性」;Rust 的过程宏像「Java 注解处理器,但生成的是同一编译轮次的 AST, 不需要额外的编译回合」。
这两句是理解本章的钥匙:
- 因为操作语法树而不是文本,
macro_rules!不需要为了安全而给每个参数加括号 (C 的#define SQUARE(x) ((x)*(x))那种仪式在 Rust 里不存在)。 - 因为生成的是同一轮 AST,过程宏可以读你 crate 里的类型信息(Lombok 那种「改 AST 再编译」的能力, 在 Rust 里是语言一等支持,不需要 hack 编译器)。
宏的代价:先把丑话说在前面
宏不是免费午餐。写宏之前,请先确认下面四条你能接受:
- 可读性下降:读者必须知道宏展开成什么,才能理解代码。IDE 的「跳转到定义」在宏上常常失灵。
- 错误信息指向宏内部:用户看到的是展开后的位置,堆栈里会夹杂宏展开的
in this macro invocation。 - 编译期成本:过程宏要额外编译一个 crate,而且每次编译都要真的执行一遍宏代码。
- 调试变难:断点只能打在展开后的位置上;
cargo expand是必需品(见「build.rs什么时候是坏主意」)。
下面这条经验法则贯穿全章:能用泛型 / trait / 函数解决的问题,绝不用宏。 宏是「普通语言设施表达不了」时的最后手段,不是「少写几行」的便利工具。
macro_rules! 声明宏基础
语法结构:模式 → 展开
最小可用形态:
rust
// 定义一个宏:匹配 `add!(a, b)`,展开为 `a + b`
macro_rules! add {
($a:expr, $b:expr) => {
$a + $b
};
}
fn main() {
println!("{}", add!(1, 2)); // 输出:3
println!("{}", add!(1 + 2, 3 * 4)); // 输出:15
}逐段拆解这条定义:
| 部分 | 含义 |
|---|---|
macro_rules! add | 宏名不加 !(! 只在调用时写);宏名与函数/类型共享命名空间之外的「宏命名空间」 |
{ ... } | 宏体,里面是若干条规则(rule),每条规则形如 模式 => 展开; |
($a:expr, $b:expr) | 匹配器(matcher):$名字:片段类型 叫元变量(metavariable) |
=> { $a + $b } | 展开器(transcriber):把捕获到的 token 填回去 |
结尾的 ; | 多条规则之间用 ; 分隔;单条规则时 ; 可省略但建议保留 |
调用语法有三种括号,含义完全相同,只是习惯用法不同:
rust
macro_rules! show {
($($x:expr),*) => { println!("{}", 0 $(+ $x)*) };
}
fn main() {
show!(1, 2, 3); // 最常用:看起来像函数调用
show![1, 2, 3]; // 习惯上用于「像数组/列表」的宏,如 vec![]
show! { 1, 2, 3 }; // 习惯上用于「像块/声明」的宏,如 matches! { ... }
// 输出三行:6 6 6
}⚠️ 陷阱:括号种类只是书写习惯,对匹配模式没有任何影响。 但
vec![1, 2]用方括号、format!("{}", x)用圆括号,是社区约定; 写成vec!(1, 2)也能编译,只是会被rustfmt和 reviewer 嫌弃。
macro_rules! 必须在使用前定义
这是新手第一个大跟头。macro_rules! 的可见性是文本顺序的(textual scoping), 不是「整个 crate 都能看见」:
rust
// 这段代码编译失败
fn main() {
let v = my_vec![1, 2, 3];
println!("{v:?}");
}
macro_rules! my_vec { // 定义在使用之后
($($x:expr),*) => { vec![$($x),*] };
}rustc 1.98.1 的实际输出:
error: cannot find macro `my_vec` in this scope
--> src\main.rs:3:13
|
3 | let v = my_vec![1, 2, 3];
| ^^^^^^ consider moving the definition of `my_vec` before this call
|
note: a macro with the same name exists, but it appears later
--> src\main.rs:7:14
|
7 | macro_rules! my_vec {
| ^^^^^^注意编译器给的提示非常精确:a macro with the same name exists, but it appears later。 把 macro_rules! 挪到 main 之前即可。
有两条重要例外,它们解释了为什么你平时很少遇到这个错:
(1) 定义在父模块(或 crate 根)的宏,对后面的子模块也可见。
rust
#[macro_use]
mod defs; // 这个模块里的 macro_rules! 会「泄漏」到后面
mod user {
pub fn call() -> i32 {
early!() // 在子模块里也能用,只要宏定义在 mod defs 里
}
}
fn main() {
println!("{} {}", early!(), user::call()); // 输出:7 7
}其中 defs.rs 是:
rust
// src/defs.rs
macro_rules! early {
() => { 7 };
}🧠 原理:
#[macro_use]写在mod上时,会把这个模块内所有macro_rules!提升到 「当前作用域,并且对后续的项(含子模块)可见」。这就是 2015 edition 里#[macro_use] extern crate serde;那套写法的由来。现代代码应该优先用下面第 (2) 条的路径导入。
(2) #[macro_export] 的宏,永远挂在crate 根,可以用路径导入。
rust
// 定义侧:可以写在 crate 任意位置(这里放在 crate 根)
#[macro_export]
macro_rules! loud {
($e:expr) => { format!("{}!!!", $e) };
}
// 使用侧 1:直接写宏名(文本作用域内可见)
fn direct() -> String {
loud!("hi")
}
// 使用侧 2:子模块里用路径导入,Rust 2018 及以后的标准写法
mod deep {
use crate::loud; // 同一个 crate 内用 crate::;
// 别的 crate 则是 use mycrate::loud;
pub fn via_path() -> String {
loud!("path")
}
}
fn main() {
println!("{}", direct()); // 输出:hi!!!
println!("{}", deep::via_path()); // 输出:path!!!
}⚠️ 陷阱:
use crate::loud;千万不要写在macro_rules! loud所在的同一个模块里, 否则会报error[E0255]: the name 'loud' is defined multiple times—— 因为#[macro_export]已经把它放进了 crate 根的宏命名空间,再use一次就是重复定义。 路径导入的正经用途是「在别的模块里按路径取用」,就像上面mod deep里那样。
三种导入写法的适用场景:
| 写法 | 生效范围 | 何时用 |
|---|---|---|
| 什么都不写(就地定义) | 定义点之后、同一模块及子模块 | 内部一次性小宏,不导出 |
#[macro_use] mod defs; | 当前作用域后续的所有项(含子模块) | 2015 edition 遗留;多文件拆分宏定义的老项目 |
#[macro_export] + use crate::name; | 整个 crate 及下游 crate | 现代写法,库作者导出宏的标准做法 |
💡 对照:Python 的装饰器必须在使用前定义(或先 import),Go 的函数也是; 但 Java 的注解处理器在整轮编译之后才跑,所以顺序无关。 Rust 的
macro_rules!更接近「文本包含」的直觉,而#[macro_export]则更接近普通项的路径解析。
🚀 进阶:过程宏(「
cfg与条件编译作为元编程」一节)不受这个顺序限制,因为它们在独立的 crate 里, 通过use导入,走的是正常的名字解析。这也是「能用过程宏就别纠结顺序」的原因之一。
片段说明符(fragment specifier)完整表
元变量 $x:片段类型 里的「片段类型」决定了它能匹配什么 token 序列。 这是 macro_rules! 里最重要的一张表,选错了就会出现 「常见坑与编译错误」一节那些经典报错。
| 片段 | 匹配 | 一句话用途 | 例 |
|---|---|---|---|
expr | 一个完整表达式 | 最常见:要「一个值」时用它 | $x:expr ← 1 + 2、foo(a) |
stmt | 一条语句(不含结尾分号) | 需要「执行一步」时用,比 expr 宽 | $s:stmt ← let a = 1、foo(); |
ty | 一个类型 | 写泛型辅助宏时用 | $t:ty ← Vec<u8>、&'a str |
pat | 一个模式(2024 下与 pat_param 等价) | 拆解 match 臂时用 | $p:pat ← Some(x)、(a, b) |
pat_param | 不含顶层 | 的模式 | 需要在模式里再用 | 组合时用它 | $p:pat_param ← Some(1) |
ident | 一个标识符或关键字 | 造名字、传函数名/变量名 | $n:ident ← foo、r#type |
path | 一条路径 | 传类型路径、函数路径 | $p:path ← std::vec::Vec、crate::helper |
tt | 单个 token 树(token tree) | 万能兜底:(...)/[...]/{...} 各算一个 | $t:tt ← 1、+、(a, b) |
item | 一个项(item) | 批量生成函数/结构体/impl | $i:item ← fn f() {}、struct S; |
block | 一个 { ... } 块 | 要一整段语句序列 | $b:block ← { let x = 1; x } |
literal | 一个字面量(含负号数字) | 只需要字面量时用,比 expr 严格 | $l:literal ← 42、-3、"s"、true |
meta | 一个属性内容 | 解析 #[...] 里的东西 | $m:meta ← cfg(test)、doc = "..." |
lifetime | 一个生命周期(含 ') | 生成带生命周期的签名 | $l:lifetime ← 'a、'static |
vis | 一个可见性修饰符(可为空) | 宏里转发可见性 | $v:vis ← pub、pub(crate)、``(空) |
几条容易踩的细节:
literal匹配-3,虽然-3在 AST 里是「一元负号 + 字面量」, 但片段匹配器特判了这种情况;用expr也能匹配,只是约束更松。expr只能出现在「表达式可以出现的位置」。后面跟=>、,、;是合法的, 但$x:expr后面不能紧跟+之类的运算符——$a:expr + $b:expr会直接报error:$a:expris followed by+, which is not allowed forexprfragments。 解法是用tt,或者用括号把语义切干净。tt是最宽松也最危险的:它能匹配任何东西,所以「匹配成功」不代表「语义正确」, 错误会被推迟到类型检查甚至运行期。- 片段之后必须跟确定的 token:
$x:expr之后可以是,、;、=>、)、]、}; 如果是别的$y:expr,编译器无法决定在哪里断开。
⚠️ 陷阱:
$x:expr后面直接写+是语法错误,不是「运行结果不对」。 想表达「把两个表达式相加」,正确写法是{ $a + $b },中间用展开器拼接。
重复(repetition)与分隔符
重复让一条规则匹配「0 个或多个」同类 token。语法是 $( ... )分隔符 重复符:
| 写法 | 含义 |
|---|---|
$( $x:expr ),* | 0 个或多个 expr,用 , 分隔 |
$( $x:expr ),+ | 1 个或更多 expr,用 , 分隔 |
$( $x:expr );+ | 1 个或多个 expr,用 ; 分隔 |
$( $x:expr )* | 0 个或多个 expr,无分隔符 |
$( $x:expr )? | 0 个或 1 个(可选片段) |
$( ... )* $(,)? | 允许尾随逗号(trailing comma)的标准技巧 |
展开侧必须「同深度重复」:匹配侧 $( ... )* 捕获的变量,在展开侧也必须在 $( ... )* 里出现,否则报 variable 'x' is still repeating at this depth。
rust
// 匹配侧和展开侧都带 $( ... )*
macro_rules! my_vec {
($($x:expr),+ $(,)?) => {
<::std::vec::Vec<_> as ::std::iter::FromIterator<_>>::from_iter([$($x),+])
};
}
fn main() {
let a = my_vec![1, 2, 3];
let b = my_vec![1, 2, 3,]; // 尾逗号也接受
println!("{a:?} {b:?}"); // 输出:[1, 2, 3] [1, 2, 3]
}$x 在展开侧出现几次都可以,每次都会拿到对应那一轮的 token:
rust
// 同一个捕获变量在展开里用两次:一次当值,一次转成字符串
macro_rules! pair {
($($x:expr),* $(,)?) => {
vec![$( ($x, stringify!($x)) ),*]
};
}
fn main() {
let v = pair![1 + 1, 2 * 3];
println!("{v:?}"); // 输出:[(2, "1 + 1"), (6, "2 * 3")]
}嵌套重复 = 笛卡尔积:外层每轮展开时,内层会完整跑一遍。
rust
// 外层按「组」重复(用 ; 分隔),内层按「组内元素」重复(用 , 分隔)
macro_rules! flatten_pairs {
($($(($k:ident, $v:expr)),+);+ $(;)?) => {{
let mut out: Vec<(&'static str, i32)> = Vec::new();
$( $( out.push((stringify!($k), $v)); )+ )+
out
}};
}
fn main() {
let pairs = flatten_pairs![(a, 1), (b, 2); (c, 3)];
println!("{pairs:?}");
// 输出:[("a", 1), ("b", 2), ("c", 3)]
}🧠 原理:编译器在展开时会维护一个「重复栈」。
$( ... )+每展开一层, 栈上就多一层迭代器,内层重复在外层每轮里重新开始一遍。这也是为什么嵌套重复 的展开顺序是(外1内1) (外1内2) ... (外2内1) ...,即笛卡尔积。
尾逗号(trailing comma)的正确姿势:$(,)?。
rust
// 实验结论(rustc 1.98.1 实测):
macro_rules! ok_with_opt {
($($x:expr),* $(,)?) => { 0 $(+ $x)* };
}
// sum!(1, 2, 3) 与 sum!(1, 2, 3,) 与 sum!() 全部可用
macro_rules! broken_no_opt {
($($x:expr),*) => { 0 $(+ $x)* };
}
// broken_no_opt!(1, 2, 3,) 报:unexpected end of macro invocation
// note: while trying to match meta-variable `$x:expr`
macro_rules! broken_first_then_rest {
($first:expr $(, $rest:expr)*) => { $first $(+ $rest)* };
() => { 0 };
}
// broken_first_then_rest!(1, 2, 3,) 同样报:unexpected end of macro invocation这两条实测结论值得记住:
expr片段不会自己吃掉尾逗号,必须在模式末尾显式写$(,)?。- 把分隔符写在重复体内部(
$(, $x:expr)*)也不能解决尾逗号, 因为那个逗号属于「下一次重复的开始」,而后面已经没有 token 了。
多条匹配臂:第一个匹配者胜出
macro_rules! 的规则是自上而下依次尝试的,第一条能匹配的规则胜出, 后面的规则完全不参与。这一点与 match 表达式一致,但后果更严重: 宏没有「重叠检查」,写错了不会警告,只会静默选择了你没想到的那条。
rust
macro_rules! which {
($x:expr) => { "expr 臂" };
($($x:tt)*) => { "tt 臂" }; // 永远轮不到?不,只要第一臂匹配失败就会轮到这里
}
fn main() {
println!("{}", which!(1 + 1)); // 输出:expr 臂
println!("{}", which!(let a = 1)); // 输出:tt 臂(`let` 不能作为 expr 起头)
}顺序陷阱:把宽泛的臂写在前面,后面精确的臂就变成死代码。
rust
macro_rules! dead_arm {
($x:expr) => { "先匹配的总是我" };
($x:ident) => { "这条永远不可达,但编译器不会警告" };
}
fn main() {
println!("{}", dead_arm!(foo)); // 输出:先匹配的总是我
}tt muncher 技巧:递归地「一次吃掉一个 token」,直到剩下可控的部分。 它是 macro_rules! 里实现循环/累加的标准手法。
rust
// 递归求和:每次吃掉「一个表达式 + 逗号」,剩下的交给递归调用
macro_rules! sum_muncher {
() => { 0 };
($single:expr) => { $single };
// 这是「就地定义、不导出」的宏,递归调用写裸名即可
($first:expr, $($rest:tt)*) => { $first + sum_muncher!($($rest)*) };
}
fn main() {
println!("{}", sum_muncher!(1, 2, 3, 4, 5)); // 输出:15
println!("{}", sum_muncher!(7)); // 输出:7
println!("{}", sum_muncher!()); // 输出:0
}逐步展开过程(sum_muncher!(1, 2, 3)):
sum_muncher!(1, 2, 3)
→ 1 + sum_muncher!(2, 3)
→ 1 + 2 + sum_muncher!(3)
→ 1 + 2 + 3 // 命中 ($single:expr) 基础臂,停止为什么必须写基础臂:如果只有递归臂,展开永远不终止,编译器会报 recursion limit reached while expanding ...(默认上限 128,可用 #![recursion_limit = "256"] 提高,但正确做法是补基础臂):
rust
// 错误示范
macro_rules! countdown {
($first:expr, $($rest:tt)*) => { 1 + countdown!($($rest)*) };
}error: unexpected end of macro invocation
--> src\main.rs:7:38
|
2 | macro_rules! countdown {
| ---------------------- when calling this macro
...
7 | println!("{}", countdown!(1, 2, 3));
| ^ missing tokens in macro arguments
|
note: while trying to match `,`@ 内部标记臂(internal rules)是 muncher 的好搭档:它让你先做「模式匹配 + 分发」, 再把所有情况交给统一的累加臂,避免递归臂彼此打架。
rust
// 累加器式求和:@acc 是内部标记,普通用户不会写出这种调用
macro_rules! sum_internal {
(@acc $acc:expr;) => { $acc };
(@acc $acc:expr; $next:expr, $($rest:tt)*) => {
sum_internal!(@acc $acc + $next; $($rest)*)
};
(@acc $acc:expr; $next:expr) => { $acc + $next };
// 兜底臂放最后:把用户输入转换成内部形式
($($rest:tt)*) => { sum_internal!(@acc 0; $($rest)*) };
}
fn main() {
println!("{}", sum_internal!(1, 2, 3)); // 输出:6
println!("{}", sum_internal!(9)); // 输出:9
}⚠️ 陷阱:内部标记臂的顺序必须在兜底臂之前。如果把
($($rest:tt)*)放在最前面(这是最自然的写法),它会把@acc 0; ...也一起吞掉,于是无限递归,报recursion limit reached。 这是macro_rules!里最常见的自伤错误,务必把「具体模式」放前面、「兜底模式」放最后。
卫生性(hygiene)
卫生性是宏系统最重要的安全属性:宏内部引入的局部名字,不会与调用者的名字冲突,反之亦然。
rust
macro_rules! make_vars {
() => {{
let x = 1;
let y = 2;
x + y
}};
}
fn main() {
let x = 100;
let y = 200;
println!("{}", make_vars!()); // 输出:3,宏内的 x/y 与外部的 x/y 互不干扰
println!("{}", x + y); // 输出:300,外部变量完全没被污染
}macro_rules! 的卫生性边界,务必记准确:
| 语法种类 | 是否卫生 | 说明 |
|---|---|---|
局部变量(let 绑定) | ✅ 卫生 | 宏内的 let x 与外部的 x 是两个不同的符号 |
标签('label:) | ✅ 卫生 | 宏内的循环标签不会与外部冲突 |
$crate | ✅ 特殊 | 总是指向「定义宏的那个 crate 根」,与调用点无关 |
| 类型名 / 函数名 / 结构体名 | ❌ 不卫生 | 宏里写 String 就真的是调用点的 String;宏里生成 fn helper() 会与调用者的 helper 撞名 |
| trait 方法名 / 字段名 | ❌ 不卫生 | 同上 |
局部变量卫生的一个实用例子:在宏里用临时变量交换,不需要担心调用者也有 __tmp。
rust
macro_rules! swap {
($a:expr, $b:expr) => {{
let __tmp = $a; // 即使调用者也有 __tmp,也不会互相影响
$a = $b;
$b = __tmp;
}};
}
fn main() {
let mut a = 1;
let mut b = 2;
let __tmp = "调用者自己的 __tmp"; // 不冲突
swap!(a, b);
println!("{a} {b} {__tmp}"); // 输出:2 1 调用者自己的 __tmp
}类型/函数名不卫生的后果:同名项会互相覆盖或报重复定义。
rust
macro_rules! hygiene_broken {
() => {
fn helper() -> u32 { 42 } // 这个名字挂在调用点,不卫生
};
}
hygiene_broken!();
// hygiene_broken!(); // 展开第二次:error[E0428]: the name `helper` is defined multiple times
fn main() {
println!("{}", helper()); // 输出:42
}💡 对照:C 的
#define完全没有卫生性,所以流行写法是给所有内部名字加前缀 (__MYLIB_TMP)。Rust 的局部变量卫生性让你不需要这种丑写法, 但类型/函数名仍然需要靠「唯一化命名」或「放进匿名const块」来避免撞名。
跨 crate 导出宏必须用 $crate。原因:宏在调用点展开, 如果宏体里写裸路径 helpers::normalize,编译器会在调用者的作用域里找 helpers, 下游 crate 里根本没有这个名字。
rust
// 定义侧
pub mod helpers {
pub fn normalize(s: &str) -> String {
s.trim().to_ascii_lowercase()
}
}
#[macro_export]
macro_rules! make_normalizer {
() => {{
fn normalize_inner(s: &str) -> String {
$crate::helpers::normalize(s) // ✅ 无论谁调用,都指向「定义宏的 crate」
}
normalize_inner
}};
}
fn main() {
let f = make_normalizer!();
println!("{}", f(" HeLLo ")); // 输出:hello
}跨 crate 的真实结构(已实测可编译),定义侧:
rust
// mylib/src/lib.rs
pub fn helper(x: i32) -> i32 { x * 10 }
#[macro_export]
macro_rules! ten_times {
($e:expr) => { $crate::helper($e) }; // 必须是 $crate,不能写 crate::helper
}使用侧:
rust
// app/src/main.rs
use mylib::ten_times; // Rust 2018+:只能用路径导入;没有隐式的 #[macro_use]
fn main() {
println!("{}", ten_times!(4)); // 输出:40
}⚠️ 陷阱:在宏体里写
crate::helper(而不是$crate::helper)时,crate指的是调用者的 crate。同 crate 内自测完全正常,一导出给下游用就炸。 凡是#[macro_export]的宏,宏体里指向自身 crate 的路径一律写$crate::。
🚀 进阶(实测结论,容易写错):递归调用自己时该写裸名还是
$crate::, 取决于这个宏有没有#[macro_export]:
- 加了
#[macro_export]→ 必须写$crate::my_macro!(...)。 因为宏被放到 crate 根,跨 crate 展开时裸名会在调用者作用域里解析而找不到。- 没加
#[macro_export](就地定义的局部宏) → 必须写裸名my_macro!(...)。 此时写$crate::my_macro!会报错:error[E0433]: cannot find 'my_macro' in '$crate', 因为局部宏根本没有挂在 crate 根上。判断口诀:
$crate::只能寻址「已经导出到 crate 根」的名字。
macro_rules! 的三个硬性局限
| 局限 | 具体表现 | 替代方案 |
|---|---|---|
| 不能做条件逻辑 | 没有 if、没有比较、没有算术;不能「数到 3 就停」 | 用「多条臂 + 不同 token 模式」模拟;实在需要就用过程宏 |
| 不能内省类型 | 无法问「这个类型实现 Display 吗」「这个字段叫什么」 | 用 trait 约束表达(编译期由类型检查器判定),或用过程宏(syn 看 AST) |
| 错误信息差 | 报错位置在宏内部;用户看不懂是自己写错了还是宏写错了 | 用 syn::Error::to_compile_error()(过程宏);声明宏只能靠好文档与 compile_error! |
第一条局限的一个经典绕法是用「token 计数」:
rust
// macro_rules! 没有整数,只能老老实实数 token
macro_rules! count {
() => { 0usize };
($head:tt $($rest:tt)*) => { 1usize + count!($($rest)*) };
}
fn main() {
println!("{}", count!(a b c d e)); // 输出:5
}第二条局限的典型后果是无法给每个字段生成不同的逻辑。例如想「所有 Display 的字段用 {}、 其余用 {:?}」——macro_rules! 做不到,必须用过程宏。
第三条局限有一个不算优雅但有效的补救:在宏里主动 compile_error!。
rust
macro_rules! only_two {
($a:expr, $b:expr) => { $a + $b };
($($wrong:tt)*) => {
compile_error!("only_two! 只接受恰好两个用逗号分隔的表达式")
};
}
fn main() {
println!("{}", only_two!(1, 2)); // 输出:3
// only_two!(1, 2, 3);
// 报:error: only_two! 只接受恰好两个用逗号分隔的表达式
}完整示例一:my_vec!
演示三种形态:空、重复填充、列表(含尾逗号)。这是 std::vec! 的等价实现。
rust
macro_rules! my_vec {
() => { ::std::vec::Vec::new() };
($elem:expr; $n:expr) => {
::std::vec::from_elem($elem, $n) // 注意:这里用 ; 分隔,与列表形态区分
};
($($x:expr),+ $(,)?) => {
// 用 FromIterator 而不是 vec![],避免「用 vec! 实现 vec!」的循环依赖
<::std::vec::Vec<_> as ::std::iter::FromIterator<_>>::from_iter([$($x),+])
};
}
fn main() {
let empty: Vec<i32> = my_vec![];
let zeros = my_vec![0u8; 3];
let listed = my_vec![1, 2, 3];
let trailing = my_vec![1, 2, 3,];
println!("{empty:?} {zeros:?} {listed:?} {trailing:?}");
// 输出:[] [0, 0, 0] [1, 2, 3] [1, 2, 3]
}三个要点:
- 臂的顺序很重要:
($elem:expr; $n:expr)必须放在($($x:expr),+ ...)之前或之后都行 (因为分隔符;与,不同,不会歧义),但()空臂必须能匹配空输入。 - 用
::std::全路径:调用者可能定义了同名模块std,全路径避免被遮蔽。 - 不写
vec![$($x),+]:虽然能工作,但那是「宏调宏」,可读性和可移植性都更差; 显式用FromIterator更清楚。
完整示例二:hashmap!
rust
macro_rules! hashmap {
() => { ::std::collections::HashMap::new() };
($($k:expr => $v:expr),+ $(,)?) => {{
// 双花括号:让整个展开是一个表达式块,可以写 let mut
let mut map = ::std::collections::HashMap::new();
$( map.insert($k, $v); )+ // 展开侧同深度重复,逐个插入
map
}};
}
fn main() {
let empty: std::collections::HashMap<&str, i32> = hashmap![];
let m = hashmap! {
"a" => 1,
"b" => 2,
};
println!("{} {} {}", empty.len(), m["a"], m["b"]); // 输出:0 1 2
}🧠 原理:为什么展开器写
{{ ... }}而不是{ ... }? 因为宏体里的let mut map = ...; map是两条语句,只有放进一个块里才能变成 一个块表达式;外层{}是宏定义的语法括号,内层{}才是真正的块表达式。 少了内层这一对,宏的产物就是「一串语句」而不是「一个值」,let m = hashmap!{...};就无法成立。详见「宏体为什么普遍写「双花括号」」。
完整示例三:my_assert_eq!(演示 stringify!)
标准库的 assert_eq! 在你断言失败时会打印表达式的源码文本,靠的就是 stringify!。
rust
macro_rules! my_assert_eq {
($left:expr, $right:expr $(,)?) => {{
// 先取引用:避免把 $left/$right 移动掉,也避免重复求值带来的副作用
let l = &$left;
let r = &$right;
if !(*l == *r) {
panic!(
"assertion `left == right` failed\n left: {}\n right: {}",
::std::stringify!($left), // 把 token 原样变成字符串字面量
::std::stringify!($right)
);
}
}};
($left:expr, $right:expr, $($msg:tt)+) => {{
let l = &$left;
let r = &$right;
if !(*l == *r) {
panic!("{}: left = {:?}, right = {:?}", ::std::format_args!($($msg)+), l, r);
}
}};
}
fn double(n: i32) -> i32 { n * 2 }
fn main() {
my_assert_eq!(double(2), 4);
my_assert_eq!(1 + 1, 2, "加法应当成立,实际算出 {}", 1 + 1);
my_assert_eq!(double(3), 5); // 触发失败,观察下面的输出
}my_assert_eq!(double(3), 5) 的实际 panic 信息:
assertion `left == right` failed
left: double(3)
right: 5关键技巧是 let l = &$left;:先绑定再比较。如果直接写 $left == $right, 宏内出现两次的 $left 会被求值两次(表达式可能带副作用), 而且第二次使用可能触发 use of moved value。这是宏里处理表达式参数的通用范式。
完整示例四:impl_trait_for_tuple!(为元组批量实现 trait)
标准库为元组 impl 了大量 trait(Debug、Clone、PartialEq……),做法就是宏。 下面这个宏做同一件事:为不同长度的同构元组实现 SumAll。
rust
// 目标 trait:把一个元组里的所有元素加起来
pub trait SumAll<T> {
fn sum_all(self) -> T;
}
macro_rules! impl_trait_for_tuple {
// $trait_name 是 trait 名,$method 是方法名,$ret 是返回类型标识符,
// 后面是这一轮要生成的类型参数列表(同时当作解构变量名使用)
($trait_name:ident; $method:ident; $ret:ident; $($T:ident),+ $(,)?) => {
impl<$($T,)+ $ret> $trait_name<$ret> for ($($T,)+)
where
$($T: Copy + Into<$ret>,)+
$ret: ::std::ops::Add<Output = $ret> + Copy,
{
fn $method(self) -> $ret {
let ($($T,)+) = self; // 解构元组
// 数组长度由 @count 这条内部臂在编译期算出来(重复内 -1 +1)
let xs: [$ret; impl_trait_for_tuple!(@count $($T)+)] = [$($T.into()),+];
let mut acc = xs[0];
let mut i = 1;
while i < xs.len() {
acc = acc + xs[i];
i += 1;
}
acc
}
}
};
// 内部臂:把「类型参数个数」折成一个 usize 常量表达式
(@count $head:ident $($rest:ident)*) => { 1usize $(+ impl_trait_for_tuple!(@one $rest))* };
(@one $x:ident) => { 1usize };
}
impl_trait_for_tuple!(SumAll; sum_all; i32; a, b);
impl_trait_for_tuple!(SumAll; sum_all; i32; a, b, c);
impl_trait_for_tuple!(SumAll; sum_all; i32; a, b, c, d);
fn main() {
println!("{}", SumAll::<i32>::sum_all((1, 2))); // 输出:3
println!("{}", SumAll::<i32>::sum_all((1, 2, 3))); // 输出:6
println!("{}", SumAll::<i32>::sum_all((1, 2, 3, 4))); // 输出:10
}三个值得学的技巧:
$($T,)+:$T重复后面带逗号,是生成「类型参数列表」和「元组类型」的惯用写法 (impl<A, B,>合法)。@count内部臂:macro_rules!没有整数,用「重复一次加 1」把元素个数折成常量表达式。 它必须写在impl_trait_for_tuple!自己的规则里,这样展开发生在const上下文。$T: Copy + Into<$ret>:约束交给类型检查器;宏本身不需要「知道」类型能不能转换。
💡 对照:这就是 C++ 里
template <typename... Ts>+ 折叠表达式想做的事。 区别是 Rust 的宏在语法层展开,展开结果再走一遍普通泛型的检查; C++ 的模板在类型层实例化。所以 Rust 的写法更啰嗦,但报错更可预测。
完整示例五:一个简单的状态机宏
宏最实用的场景之一是「一次声明、多处生成」。下面这个宏从一份状态列表生成 枚举、name() 方法、start() 构造函数和 is_final() 判定。
rust
// 目标 trait:所有生成的状态机都实现它
pub trait States: ::std::fmt::Debug + Clone + PartialEq {
fn is_final(&self) -> bool;
}
macro_rules! state_machine {
// 完整形态:名字, 变体列表 ; final: 终态列表 ; start: 起始态
($name:ident, $($variant:ident),+ $(,)?; final: $($final:ident),+ $(,)?; start: $start:ident) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum $name { $($variant),+ }
impl $name {
pub fn start() -> Self { $name::$start }
pub fn name(&self) -> &'static str {
match self {
$( $name::$variant => stringify!($variant), )+
}
}
}
impl $crate::States for $name {
fn is_final(&self) -> bool {
// $( ... )|+ 是「或模式」的重复写法,等价于 A | B | C
matches!(self, $( $name::$final )|+)
}
}
};
// 简写形态:只写 `final:`,起始态默认取第一个变体
($name:ident, $first:ident $(, $rest:ident)* $(,)?; final: $($final:ident),+ $(,)?) => {
state_machine!($name, $first $(, $rest)*; final: $($final),+; start: $first);
};
}
// 主形态
state_machine!(Traffic, Red, Green, Yellow; final: Red; start: Red);
// 简写形态:起始态 = 第一个变体(Red),终态显式给出
state_machine!(Light, Red, Amber, Green; final: Red, Green);
fn main() {
println!("{}", Traffic::start().name()); // 输出:Red
println!("{:?}", Traffic::Green.is_final()); // 输出:false
println!("{:?}", Traffic::Red.is_final()); // 输出:true
println!("{}", Light::start().name()); // 输出:Red
println!("{:?}", Light::Amber.is_final()); // 输出:false
}要点:
- 一个宏,四样产物(
enum+ 两个impl+ 派生属性)。这是宏相对泛型的核心优势: 泛型只能在「已有类型」上做文章,宏可以生成类型本身。 $( ... )|+生成A | B | C;|的重复符写法合法,是matches!宏的常见搭档。$crate::States:跨 crate 时States这个名字必须能从定义方找到。
内置实用宏速查
标准库与 core 提供了一批宏,它们不是魔法,只是「用起来顺手的语法糖」。 本节只讲容易被忽略的坑,同时给出完整清单。
输出类宏与「两个 Write」的大坑
rust
use std::fmt::Write as _; // 提供 write! / writeln! 给 String 用
use std::io::Write as _; // 提供 write! / writeln! 给 Vec<u8> / File 用write! / writeln! 同时支持 std::fmt::Write(文本、无 Result 的 IO 错误) 和 std::io::Write(字节、会返回 io::Error)。两个 trait 同名方法冲突, 所以:
- 只
use一个即可;两个都要用时,必须用as _匿名导入(如上)或者调用处加限定。 String实现的是fmt::Write,Vec<u8>/File/Stdout实现的是io::Write。 搞混了会得到「String没有write_all」或「File没有fmt的write_fmt」这类报错。
rust
fn main() {
use std::fmt::Write as _;
let mut s = String::new();
write!(s, "{:>4}", 7).unwrap(); // fmt::Write::write_fmt,返回 fmt::Result
writeln!(s, "!").unwrap();
println!("{s:?}"); // 输出:" 7!\n"
use std::io::Write as _;
let mut bytes: Vec<u8> = Vec::new();
write!(bytes, "{}", 42).unwrap(); // io::Write::write_fmt,返回 io::Result
println!("{bytes:?}"); // 输出:[52, 50]
}| 宏 | 作用 | 使用要点 |
|---|---|---|
println! | 输出到 stdout,带换行 | 内部先 format_args! 再写锁;不要在高频循环里依赖它做日志 |
eprintln! | 输出到 stderr,带换行 | 日志/错误优先用它,stdout 留给数据 |
format! | 返回 String | 会分配堆内存;只要「打印」就用 println!,别 println!("{}", format!(...)) |
write! / writeln! | 写入实现了 fmt::Write 或 io::Write 的目标 | 见上面的双 trait 坑;必须 unwrap()/? 处理返回值 |
print! / eprint! | 不带换行的版本 | print! 不自动 flush,配合 std::io::stdout().flush() 用 |
断言与流程控制类
| 宏 | 成立条件 | 失败时 | 何时用 |
|---|---|---|---|
panic!("{}", x) | —— | panic! 展开/中止 | 不可恢复的错误;库代码里慎用 |
assert!(cond) | cond == true | panic | 断言「不可能发生」的内部不变量 |
assert!(cond, "msg {}", x) | 同上 | panic(带自定义消息) | 加上下文的上下文信息 |
assert_eq!(a, b) / assert_ne!(a, b) | a == b / a != b | panic,打印两侧的 Debug 与源码文本 | 测试首选;比 assert!(a == b) 信息量大得多 |
debug_assert! / debug_assert_eq! | 同 assert!,但只在 debug_assertions 打开时生效 | 同上 | 热路径上的昂贵检查 |
matches!(expr, pat) | 模式匹配成功 | 不 panic,返回 bool | 取代 match { ... => true, _ => false } |
unreachable!() | —— | panic,表示「逻辑上不可能到达」 | 穷尽了所有分支后的兜底 |
unimplemented!() | —— | panic,表示「尚未实现」 | 先占位、后补实现的骨架代码 |
todo!() | —— | panic,表示「待办」 | 同上,语义更明确地指向「要做」 |
dbg!(expr) | —— | 打印 文件:行号 expr = 值 并返回该值 | 临时调试;比 println! 多了位置与表达式文本,且不打断表达式 |
rust
fn main() {
let x = dbg!(1 + 1); // stderr: [src\main.rs:2:13] 1 + 1 = 2
assert_eq!(x, 2);
assert!(matches!(Some(3), Some(n) if n > 2));
let r: Result<i32, &str> = Err("boom");
// 只在「逻辑上不可能」时用 unreachable!,它会让 panic 与 MatchError 一样难以追踪
let v = match r {
Ok(n) => n,
Err(e) => unreachable!("上游已经保证不会是 Err,实际是 {e}"),
};
println!("{v}");
}⚠️ 陷阱:
unreachable!()/todo!()都会 panic。库作者要注意:todo!()放进公开 API 意味着下游调用会 panic,这比返回Err更糟。 生产代码里应当优先返回Result,把「未实现」变成类型可见的状态。
编译期「元数据」类宏
这一组宏是元编程的轻量武器:它们在编译期把位置、源码、环境变量变成常量。
| 宏 | 返回 | 说明 |
|---|---|---|
stringify!(a + b) | &'static str,内容为 "a + b" | 把 token 原样变字符串;后面再传给 stringify! 不会再展开(内层不展开) |
concat!("a", "b", 'c') | &'static str | 只接受字面量,编译期拼接;不能拼接变量 |
concat_idents!(a, b) | 标识符 | ⚠️ 不稳定,stable 上直接 cannot find macro concat_idents in this scope |
include!("file.rs") | 内联该文件的 item | 把文件内容当作当前模块的源码解析(不是字符串) |
include_str!("data.txt") | &'static str | 把文件内容编进二进制;文件必须在编译期存在,会被追踪为依赖 |
include_bytes!("data.bin") | &'static [u8; N] | 同上,二进制版本;适合内嵌图标/模型 |
env!("CARGO_PKG_VERSION") | &'static str | 取编译期环境变量;未定义则编译失败 |
option_env!("CARGO_PKG_VERSION") | Option<&'static str> | 同上,缺失时返回 None |
cfg!(feature = "x") | bool | 编译期条件表达式;不删除代码,只是求值成 true/false |
file!() | &'static str | 当前源文件路径 |
line!() / column!() | u32 | 当前行/列 |
module_path!() | &'static str | 当前模块路径,如 mycrate::inner |
rust
fn main() {
// 注意:env! 读的是「编译期环境变量」,这些变量由 cargo 注入,
// 所以这个块要在 cargo 工程里跑(直接 rustc 编译会报 CARGO_PKG_VERSION 未定义)
println!("{}", stringify!(1 + 2)); // 输出:1 + 2
println!("{}", concat!("a", "b", 'c')); // 输出:abc
println!("{}", env!("CARGO_PKG_VERSION")); // 输出:当前 crate 的版本号
println!("{}", option_env!("NOT_SET").is_none()); // 输出:true
println!("{}:{}", file!(), line!()); // 输出:src\main.rs:5
println!("{}", module_path!()); // 输出:crate 名
println!("{}", cfg!(windows)); // 输出:true
}两条容易误用的地方:
cfg!()与#[cfg]完全不同:cfg!求值成bool,两边代码都会被编译;#[cfg]会在编译前删掉不满足的项。想在 Windows 上用 Windows-only API, 必须用#[cfg(windows)],用cfg!(windows)会因为在 Unix 上也编译那段代码而失败。stringify!不会递归展开内层宏:stringify!(concat!("a", "b"))得到的是 字符串"concat!(\"a\", \"b\")",而不是"ab"。concat!恰恰相反,它只吃字面量。
🚀 进阶:
include_str!/include_bytes!的路径是相对于当前文件的, 且会被注册为编译依赖——改了文件 cargo 会自动重编。
format_args! 与临时值生命周期
format_args! 是 println! / format! / write! 的公共内核, 它返回 std::fmt::Arguments<'a>——一个借用了参数的「格式化配方」,不是字符串。 理解它的关键是:Arguments<'a> 里可能借用临时值,所以它天生只能就地消费。
rust
fn main() {
// ✅ 最常见也最推荐的用法:立刻交给别的宏消费
println!("{}", format_args!("args #{}", 1));
// ✅ 绑定到变量、当条语句里用掉,是允许的(借用不跨出这个作用域)
let args = format_args!("{}-{}", 1, 2);
let s = args.to_string();
println!("{s}"); // 输出:1-2
// ✅ 需要长期保存,就先转成拥有所有权的 String
let owned = format_args!("{}-{}", 1, 2).to_string();
assert_eq!(owned, "1-2");
}真正会失败的是这三种用法(都实测过):
rust
// 存进结构体却不写生命周期参数
struct Holder {
args: std::fmt::Arguments, // ❌ error[E0106]: missing lifetime specifier
}
// 从函数里「返回」一个借用局部变量的配方
fn make(n: i32) -> std::fmt::Arguments<'static> {
format_args!("{}", n) // ❌ error[E0515]: cannot return reference to temporary value
}error[E0106]: missing lifetime specifier
--> src\main.rs:3:21
|
3 | args: std::fmt::Arguments,
| ^^^^^^^^^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
2 ~ struct Holder<'a> {
3 ~ args: std::fmt::Arguments<'a>,
|error[E0515]: cannot return reference to temporary value
--> src\main.rs:3:5
|
3 | format_args!("{}", n)
| ^^^^^^^^^^^^^^^^^^^^^ returns a reference to data owned by the current function实践结论(这几条比背报错号有用):
Arguments适合立刻消费(println!/write!/panic!),不适合做「半个字符串」传来传去。- 想把它存起来、返回、塞进结构体 → 先
.to_string()或直接用format!。 - 真的要存,就要像
struct Holder<'a> { args: Arguments<'a> }这样把生命周期写出来, 而且只能活在参数还活着的那个作用域内。
还有一条实用差异:format! 返回拥有所有权的 String,format_args! 返回借用。 所以:
- 只是立刻打印 → 用
println!(内部就是format_args!),零分配。 - 需要把结果存起来或返回 → 用
format!,会分配。 - 写
println!("{}", format!("..."))是双重浪费(先分配String再打印)。
💡 对照:Python 的 f-string 先构造
str;Go 的fmt.Sprintf返回string。println!("{x}")在 Rust 里因为直接写 stdout 且不分配,是这三者中最省的路径。
延伸阅读
- 同一概念的第二种讲法(官方书中文版、Rust 圣经的逐章映射),见 附录 E · 对照阅读与组合学习法。
- 官方文档、中文资料、书单与工具的完整索引,见 附录 D · 学习资源与文档索引。