Skip to content

声明宏

本章讲 Rust 的「写代码的代码」:macro_rules! 声明宏与三种过程宏(proc macro), 以及 cfg / build.rs 这类编译期元编程手段。 前置知识:变量与流程控制所有权与借用。 基准环境:rustc 1.98.1 / cargo 1.98.1Rust 2024 edition,Windows 11 + PowerShell。

本章目标

  • 能说清「为什么 Rust 没有可变参数函数」,以及宏在编译期展开这一点带来的全部后果。
  • 能读懂并写出 macro_rules!:片段说明符(fragment specifier)、重复(repetition)、 多条匹配臂、tt muncher,并知道每条臂的匹配优先级。
  • 能解释卫生性(hygiene)能保护什么、不能保护什么,并能在跨 crate 导出宏时正确使用 $crate::
  • 能区分三类过程宏的用途边界,写出一个 #[derive(...)]、一个属性宏和一个函数式宏。
  • 能用 cfg / cfg_attr / build.rs 做条件编译与代码生成,并知道 build.rs 什么时候是坏主意。
  • 能在一张决策表上回答:「这里该用宏、泛型、trait,还是 build.rs?」
  • 能识别 no rules expected this tokencannot find macrounexpected 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 的宏只有两条根本性质,其他所有规则都是它们的推论。

  1. 宏在编译期展开:展开发生在类型检查之前,产物是普通的 AST(抽象语法树)节点, 之后走和手写代码完全一样的类型检查、借用检查、单态化、优化。
  2. 宏操作的是语法,不是值:宏看到的是一串 token(词法单元),不是运行期的数据。

由此推出几条非常实际的结论:

  • 宏没有运行期开销(宏展开成普通代码,不存在「宏调用」这个运行期实体)。
  • 宏不能内省类型:展开时类型还没算出来,所以 macro_rules! 无法问「T 有没有实现 Display」。
  • 宏的错误信息先天较差:展开发生在类型检查前,编译器看到的报错位置是展开后的代码, 指向宏内部的 token 而不是你写的那一行。这是宏最真实的代价。
  • 宏可以生成任意语法:因为操作的是 token,所以宏能「发明」新语法,比如 vec![x; n] 这种 expr; expr 形式——普通函数不可能有这种调用语法。

与别的语言对比:宏这个位置上都坐着谁

先建立直觉,后面每一节都会回到这张表。

语言机制展开/生效时机操作对象能否安全生成标识符典型用途
C / C++预处理器宏 #define预处理,早于语法分析纯文本 token❌ 字符串拼接,无卫生性MIN(a,b)、条件编译
C++模板(template)/ constexpr编译期实例化类型与常量表达式✅ 语言一级公民泛型容器、编译期计算
C++20Concepts + if constexpr编译期类型约束与条件分支约束泛型、分支特化
Java注解处理器(APT)+ 字节码生成编译期生成新源文件Java 语法树 / 字节码✅ 但需要一整轮编译Lombok、MapStruct、Dagger
Java反射(reflection)运行期类的运行期元数据❌(字符串)框架注入、序列化
Python装饰器(decorator)运行期,函数对象是值函数对象✅(闭包/functools.wraps日志、缓存、注册路由
Python元类(metaclass)/ exec运行期类对象 / 字符串源码ORM 模型、动态 API
Gogo generate + 代码生成器构建前的独立一步源文件文本✅(生成完整文件)stringer、protobuf、mock
Go泛型(1.18+)编译期类型参数容器、算法
Lisp / Scheme宏(defmacro / syntax-rules读取/展开期S-表达式本身syntax-rules 卫生几乎一切(Lisp 里宏是日常)
Scala宏(inline + 引用 '{ }编译期Scala 3 AST (quotes)类型类派生、零成本封装
Rustmacro_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 编译器)。

宏的代价:先把丑话说在前面

宏不是免费午餐。写宏之前,请先确认下面四条你能接受:

  1. 可读性下降:读者必须知道宏展开成什么,才能理解代码。IDE 的「跳转到定义」在宏上常常失灵。
  2. 错误信息指向宏内部:用户看到的是展开后的位置,堆栈里会夹杂宏展开的 in this macro invocation
  3. 编译期成本:过程宏要额外编译一个 crate,而且每次编译都要真的执行一遍宏代码。
  4. 调试变难:断点只能打在展开后的位置上;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:expr1 + 2foo(a)
stmt一条语句(不含结尾分号)需要「执行一步」时用,比 expr$s:stmtlet a = 1foo();
ty一个类型写泛型辅助宏时用$t:tyVec<u8>&'a str
pat一个模式(2024 下与 pat_param 等价)拆解 match 臂时用$p:patSome(x)(a, b)
pat_param不含顶层 | 的模式需要在模式里再用 | 组合时用它$p:pat_paramSome(1)
ident一个标识符或关键字造名字、传函数名/变量名$n:identfoor#type
path一条路径传类型路径、函数路径$p:pathstd::vec::Veccrate::helper
tt单个 token 树(token tree)万能兜底:(...)/[...]/{...} 各算一个$t:tt1+(a, b)
item一个项(item)批量生成函数/结构体/impl$i:itemfn f() {}struct S;
block一个 { ... }要一整段语句序列$b:block{ let x = 1; x }
literal一个字面量(含负号数字)只需要字面量时用,比 expr 严格$l:literal42-3"s"true
meta一个属性内容解析 #[...] 里的东西$m:metacfg(test)doc = "..."
lifetime一个生命周期(含 '生成带生命周期的签名$l:lifetime'a'static
vis一个可见性修饰符(可为空)宏里转发可见性$v:vispubpub(crate)、``(空)

几条容易踩的细节:

  • literal 匹配 -3,虽然 -3 在 AST 里是「一元负号 + 字面量」, 但片段匹配器特判了这种情况;用 expr 也能匹配,只是约束更松。
  • expr 只能出现在「表达式可以出现的位置」。后面跟 =>,; 是合法的, 但 $x:expr 后面不能紧跟 + 之类的运算符——$a:expr + $b:expr 会直接报 error: $a:expris followed by+, which is not allowed for expr fragments。 解法是用 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

这两条实测结论值得记住:

  1. expr 片段不会自己吃掉尾逗号,必须在模式末尾显式写 $(,)?
  2. 把分隔符写在重复体内部$(, $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]
}

三个要点:

  1. 臂的顺序很重要($elem:expr; $n:expr) 必须放在 ($($x:expr),+ ...) 之前或之后都行 (因为分隔符 ;, 不同,不会歧义),但 () 空臂必须能匹配空输入。
  2. ::std:: 全路径:调用者可能定义了同名模块 std,全路径避免被遮蔽。
  3. 不写 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(DebugClonePartialEq……),做法就是宏。 下面这个宏做同一件事:为不同长度的同构元组实现 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::WriteVec<u8> / File / Stdout 实现的是 io::Write 搞混了会得到「String 没有 write_all」或「File 没有 fmtwrite_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::Writeio::Write 的目标见上面的双 trait 坑;必须 unwrap()/? 处理返回值
print! / eprint!不带换行的版本print! 不自动 flush,配合 std::io::stdout().flush()

断言与流程控制类

成立条件失败时何时用
panic!("{}", x)——panic! 展开/中止不可恢复的错误;库代码里慎用
assert!(cond)cond == truepanic断言「不可能发生」的内部不变量
assert!(cond, "msg {}", x)同上panic(带自定义消息)加上下文的上下文信息
assert_eq!(a, b) / assert_ne!(a, b)a == b / a != bpanic,打印两侧的 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! 返回拥有所有权的 Stringformat_args! 返回借用。 所以:

  • 只是立刻打印 → 用 println!(内部就是 format_args!),零分配
  • 需要把结果存起来或返回 → 用 format!,会分配。
  • println!("{}", format!("..."))双重浪费(先分配 String 再打印)。

💡 对照:Python 的 f-string 先构造 str;Go 的 fmt.Sprintf 返回 stringprintln!("{x}") 在 Rust 里因为直接写 stdout 且不分配,是这三者中最省的路径。



延伸阅读


内容以 rustc 1.98.1 · Rust 2024 edition 为基准