宏:从声明宏到过程宏的边界
课程概览 · 第 16 章
宏能在编译期接收 token 并生成 Rust 代码,适合消除函数签名无法表达的语法重复。但它也会让错误位置、控制流和实参求值次数离调用点更远。正确的使用方式不是“能写宏就写宏”,而是先让函数、泛型和 trait 承担正常的运行时逻辑。
学习目标与默认选择
学完本篇,你应当能够:
- 根据重复的性质在函数、trait、闭包、声明宏和过程宏之间做选择;
- 读懂
macro_rules!的匹配器、片段类型和转写器; - 写出只对实参求值一次、支持重复和尾逗号的声明宏;
- 理解宏规则顺序、卫生性、
$crate与宏调试方式; - 区分派生宏、属性宏和函数式过程宏的输入、能力与构建成本。
默认选择是:普通控制流留在函数里;声明宏只处理语法层重复;过程宏只在类型结构或 DSL 确实能换来可观收益时引入。
宏之前:函数、泛型与 trait 是否已经足够
宏接收的是 token,不是已经类型检查过的值;它会先展开成 Rust 代码,再让编译器检查。这带来强大的表达力,也带来较差的错误定位、IDE 跳转和可读性。下表是实用的选择顺序:
| 需求 | 优先工具 | 原因 |
|---|---|---|
| 重复一段运行时行为 | 函数 | 签名清晰,可测试,可组合 |
| 同一算法适配多种类型 | 泛型或 trait | 类型约束显式,错误定位好 |
| 需要借用调用点局部变量 | 闭包 | 无需生成语法 |
| 输入本身是 Rust 语法或声明 | 宏 | 函数参数无法接收语法结构 |
| 需要根据类型结构批量生成实现 | derive 过程宏 | 手写实现重复且易漏 |
例如“校验前缀”是普通运行时行为,应该先写函数:
1 | |
函数能返回 Result 并由 ? 传播,调用点的控制流和失败类型都可见。只有当需求包括“捕获调用者写下的表达式文本”或“接收任意 token 形状”时,声明宏才有不可替代的价值;标准断言宏正属于前者。
声明宏 macro_rules!:按 token 模式生成代码
最小结构:匹配器与转写器
macro_rules! name { pattern => expansion } 的左侧是匹配器,右侧是转写器。调用 name!(...) 时,编译器用输入 token 匹配左侧,将捕获的变量替换到右侧,然后继续编译展开后的代码。
1 | |
$value:expr 表示捕获一个表达式。宏不是字符串替换:它工作在 token 和语法类别上,因此能正确处理嵌套括号、运算符优先级等 Rust 语法。常用 fragment specifier 如下:
| 片段 | 匹配内容 | 示例 |
|---|---|---|
expr | 表达式 | a + b、call() |
ty | 类型 | Vec<u8> |
ident | 标识符 | name |
path | 路径 | std::fmt::Debug |
pat | 模式 | Some(value) |
item | 一个项目 | fn run() {} |
block | 一个代码块 | { work(); } |
literal | 字面量 | 42、"ok" |
片段类型是宏 API 的一部分。能接收任意表达式就写 expr,只允许名字就写 ident;约束越准确,调用错误越早、越容易理解。
求值一次:宏最容易藏下的 bug
第一个 squared! 示例有隐藏问题:squared!(next()) 会把 next() 展开两次,副作用执行两次。函数参数天然只求值一次,宏必须自己保证这个性质。
1 | |
双层花括号让展开结果成为一个块表达式,局部变量不会泄漏到调用者的外层作用域。任何宏只要在转写器中多次使用 $expr,都应立即检查“带副作用的实参会执行几次”。断言宏、日志宏和集合构造宏尤其容易遇到这一问题。
重复:逗号分隔列表怎样展开
$(...)* 表示重复零次或多次,$(...)+ 表示至少一次,分隔符放在重复组内部。例如一个教学用的 count_values!:
1 | |
第一个 * 匹配以逗号分隔的任意多个表达式,第二个 * 在输出中重复表达式,$(,)? 接受一个可选尾逗号。真实的 vec! 宏比这个复杂得多:它还支持 vec![value; count] 形式,并会考虑分配和元素求值。
规则顺序、卫生性与 $crate
一个声明宏可有多条规则,编译器从上到下选择第一条能匹配的规则。因此更具体的规则应放前面,兜底规则放后面。
宏引入的局部变量具有一定卫生性:调用点恰好有同名变量时,宏内部新建的 let value 不会意外覆盖它。反过来,宏希望使用调用点传入的 $value,就必须通过捕获变量显式引用。卫生性降低了名称冲突,但不能让宏绕过借用检查、类型检查或可见性。
可导出的宏不要用调用者可能重命名的 crate 路径引用自己 crate 的项目,而应使用 $crate:
1 | |
$crate 在展开时指向定义宏的 crate,即使用户在 Cargo.toml 中把依赖改名,也不会失效。#[macro_export] 会把声明宏导出到 crate 根;这是很强的公开承诺。库作者应谨慎导出宏,并为其输入、展开效果、评估次数和 panic/错误行为写清文档。
宏的作用域与调试
现代 Rust 中,声明宏可以像其他项目一样通过路径导入和调用;但宏的定义位置、#[macro_use] 等历史规则仍可能在旧代码中出现。新代码优先显式路径或显式导入,避免“某个宏为什么突然可见”的隐式依赖。
阅读宏时不要只看调用点,要同时看展开后会得到什么。排查问题的顺序通常是:先最小化调用;再查看编译器报错中的展开信息;必要时使用 cargo expand 查看近似展开结果;最后为宏写覆盖不同输入形状和副作用实参的测试。宏生成的是普通 Rust,展开后仍须接受同样严格的类型、借用和可见性检查。
过程宏:三种以代码操作语法树的方式
过程宏是单独的 proc-macro crate,在编译期间接收 TokenStream 并返回 TokenStream。它的能力比 macro_rules! 强:可以解析 Rust 语法、读取结构体字段、生成大量 impl;代价是依赖、编译时间、诊断路径和维护复杂度都更高。
派生宏:#[derive(...)]
派生宏附着在 struct、enum 或 union 上,通常为该类型生成 trait 实现。标准库的 Debug、Clone、Eq 等是最常见例子;生态中的 serde::Serialize、thiserror::Error 也是派生宏。它适合“类型结构决定重复实现”的场景。
1 | |
这不是运行时反射。宏在构建时读取两个字段,生成对应序列化/反序列化代码,运行时直接执行已编译的代码。使用时仍应理解生成的边界:字段名是否进入 wire format,私有字段是否被序列化,默认值、重命名和跳过规则是否会改变兼容性。
属性宏:改写项目
属性过程宏写成 #[some_crate::attribute],它接收带属性的整个项目并返回替代代码。Web 框架的路由、异步运行时入口、测试框架注册常使用它。
1 | |
这段代码不是语言内建的“异步 main”。tokio::main 通常会生成创建运行时并 block_on 该 async 函数的辅助代码。属性宏能显著减少框架样板,但也可能隐藏启动策略、线程模型、feature 需求或函数签名约束。遇到框架行为不符合预期时,应先看该属性的文档和展开结果,而不是把它当作普通注释。
函数式过程宏:像函数一样接收 DSL
函数式过程宏以 name!(tokens) 调用,外观接近声明宏,但实现是 Rust 程序,可解析更自由的输入。SQL、正则、嵌入式查询和编译期 schema 校验常用它。
1 | |
宏可以在构建期校验 SQL 与数据库 schema 的匹配关系,并生成带类型的结果映射。收益是把一部分运行时错误提前到编译期;代价是构建环境可能需要数据库连接或离线元数据,CI 和本地环境必须一并配置。不要仅因语法“短”就引入过程宏,应评估它对构建可复现性和错误排查的影响。
从需求到工具的决策流程
当你看到重复代码时,可以按下面顺序做决定:
- 这只是同一段运行时逻辑吗?提取函数。
- 只是参数类型不同吗?考虑泛型、trait 或 trait 的默认方法。
- 需要捕获调用点的表达式、变量名或语法形状吗?考虑
macro_rules!。 - 需要根据 struct/enum 的字段批量生成 impl,或解析专用 DSL 吗?才考虑过程宏。
- 即使宏合适,能否把业务判断留在普通函数里,只让宏处理薄薄的一层语法?
例如 assert_eq! 使用宏是合理的:它要接收任意表达式、只在失败时格式化,并在消息中保留表达式文本。反过来,“保存任务后记录审计日志”属于业务动作,应是函数或 trait 方法;把 return、break、? 等控制流藏进业务宏,会让读者无法从调用点判断函数何时退出。
常见失败场景与修复方向
| 现象 | 根因 | 修复方向 |
|---|---|---|
file not found for module | 写了 mod x;,但文件路径不符合约定 | 检查 x.rs 或 x/mod.rs,及父模块位置 |
module x is private | 路径中某一段未公开 | 设计所需的最小 pub/re-export,不要盲目全公开 |
| 外部测试读不到私有字段 | 集成测试是独立 crate | 用公共行为断言;内部细节放单元测试 |
| feature 关闭时仍编译失败 | 用了 cfg! 而不是 #[cfg] | 用属性删除不适用的项目或代码块 |
| 宏实参执行两次 | 转写器重复使用 $expr | 先绑定到局部变量,确保只求值一次 |
derive(Clone) 后性能变差 | 无意中复制大字段 | 借用、Arc 或重设 API;不要用克隆掩盖所有权问题 |
| 宏报错难定位 | 错误发生在展开后 | 最小化输入、看展开结果、为宏写单独测试 |
所有模块都 pub | 内部实现被误当作 API | 私有默认,crate 根 re-export 最小门面 |
小结:让宏保持薄而透明
宏生成的是普通 Rust,最终仍要通过同样的类型、借用和可见性检查。宏最适合把重复的语法外壳压缩掉,而把业务判断、错误传播和资源管理留给普通函数。调用者能够从函数签名看懂控制流,维护者也能从展开结果追踪行为,这才是宏带来收益而不制造隐患的边界。
自测
- 为什么普通运行时行为应优先提取函数,而不是宏?
方向:函数签名、类型错误、跳转和测试都更清晰,且控制流显式。 - 为什么
$expr * $expr形式的宏可能有 bug?
方向:带副作用的表达式会求值两次;应先绑定局部变量。 $(,)?在逗号分隔宏中表达什么?
方向:接受一个可选尾逗号,调用形式更符合 Rust 集合字面量习惯。- 导出的声明宏为何推荐用
$crate?
方向:它始终指向定义宏的 crate,不受依赖改名影响。 - 三种过程宏分别适合什么任务?
方向:derive 按类型字段生成实现;属性宏改写项目;函数式宏解析 DSL 或复杂 token 输入。






