32-宏
17 分钟阅读
宏 (Macros)
面向 Rust 1.97.1 (stable, 2026-07)。本篇假设你熟悉 Go,凡有可比性处均给出 🐹 Go 对比。
热度:
hot高频 |common常见 |occasional偶尔 |advanced进阶少见
本篇能解决什么:
- 你是否只知道 Rust 有
println!、vec!,但不知道宏和函数到底差在哪? - 你是否想弄明白
macro_rules!、derive 宏、attribute 宏、function-like 过程宏各自干什么? - 你是否被
no rules expected this token、cannot find macro、unresolved module一类错误劝退过? - 你是否想知道 Rust 宏和 Go 的
go:generate、代码生成、反射,分别适合解决什么问题? - 你是否总听人说“卫生性(hygiene)”,但不知道这词落到代码里是什么意思?
术语速查表:
| 术语 / 缩略词 | 全称 / 读法 | 中文 | 一句话解释 | Go 里的近亲 |
|---|---|---|---|---|
| macro | — | 宏 | 编译前展开代码的工具 | go:generate/代码生成,部分近似 |
macro_rules! | — | 声明宏 | 用模式匹配 token 写的宏 | 文本模板生成,部分近似 |
| procedural macro | — | 过程宏 | 用 Rust 代码处理 TokenStream 的宏 | AST 代码生成 |
| derive macro | — | 派生宏 | #[derive(...)] 这类宏 | stringer 之类生成器,部分近似 |
| attribute macro | — | 属性宏 | #[route] 这类贴在条目上的宏 | 注解生成器 |
| function-like macro | — | 函数式过程宏 | sql!(...) 这种像函数调用的宏 | 生成器 DSL |
| token | — | 词法记号 | 宏匹配和生成的最小单位 | token |
TokenStream | — | 记号流 | 过程宏收到和吐出的 token 序列 | AST/token 流 |
| hygiene | — | 卫生性 | 宏展开后尽量避免和调用点局部变量意外冲突 | Go 无直接对应 |
$crate | — | 定义处 crate 路径 | 让导出宏稳定引用自己 crate 内部路径 | 包内绝对导入路径,概念接近 |
| reflection | — | 反射 | 运行时看类型信息的机制 | Go reflect |
| DSL | Domain-Specific Language | 领域特定语言 | 为某类问题设计的小语法 | builder/生成器 DSL |
说明:本表覆盖本篇出现的所有专业名词与缩略词;正文首次出现时仍会就地解释一次。
热度索引:
| 热度 | 题目 |
|---|---|
hot | Q1, Q2, Q3, Q4, Q8, Q10 |
common | Q5, Q6, Q7, Q9, Q13, Q14, Q15, Q16, Q17, Q18 |
occasional | Q11 |
advanced | Q12 |
Q1. 宏和函数到底差在哪?为什么 println! 不是函数?
Tags: hot macro function
适用版本: Rust 1.0+
一句话答案:
函数接收已经类型检查过的值;宏在编译期先接收 token 并展开成代码,再去编译展开结果,所以它能做到“可变参数、生成语句、生成类型或 impl”,这些普通函数做不到。
解答:
最直观的例子是 println!:
| |
函数没法接收这种“格式串 + 任意数量参数 + 生成一大段代码”的能力:
| |
| |
Go 对比:
| |
- Go 怎么做:很多可变参数需求直接靠普通函数加
...any。 - Rust 为什么不同:Rust 没有运行时反射式格式化作为默认路线,很多“语法层重复”问题交给宏在编译期解决。
- Go 程序员易踩的坑:把宏理解成“更怪的函数”;其实宏先展开、函数后调用,阶段完全不同。
记忆点:
- 宏处理 token,函数处理值。
- 宏能生成代码结构,函数不能。
Q2. macro_rules! 的基本形状是什么?
Tags: hot macro-rules
适用版本: Rust 1.0+
一句话答案:
macro_rules! 是“左边匹配输入 token,右边生成展开结果”的规则系统;你可以把它想成一个带语法类别的模板匹配器。
解答:
最小例子:
| |
带重复更像真实场景:
| |
| |
Go 对比:
| |
- Go 怎么做:类似代码通常直接手写、用生成器,或靠普通语法组合。
- Rust 为什么不同:声明宏擅长把“同一种语法模式反复出现”的样板代码收起来。
- Go 程序员易踩的坑:看不懂
$( ... ),*时,先把它翻译成“逗号分隔的 0 个或多个表达式”。
记忆点:
- 左边是匹配器,右边是展开器。
- 常见元变量类别有
expr、ident、ty、tt。
Q3. 声明宏和过程宏有什么本质区别?
Tags: hot proc-macro
适用版本: Rust 1.30+(过程宏稳定主线)
一句话答案:
声明宏 macro_rules! 靠模式匹配 token;过程宏则是“你写一个 Rust 函数去处理 TokenStream”。简单重复用声明宏,复杂 AST 改写和派生通常用过程宏。
解答:
声明宏:
| |
过程宏常见的是 derive:
| |
| |
Go 对比:
| |
- Go 怎么做:更多依赖
go:generate、模板、反射或手写工具。 - Rust 为什么不同:过程宏把“读输入代码再生成代码”放进编译器流程里,用户使用体验更一体化。
- Go 程序员易踩的坑:把所有宏需求都往过程宏上靠;多数场景
macro_rules!就够了。
记忆点:
- 简单重复用声明宏。
- 结构体/枚举派生代码常用过程宏。
Q4. 什么是卫生性(hygiene)?
Tags: hot hygiene
适用版本: Rust 1.0+
一句话答案:
卫生性(hygiene)是 Rust 宏用来避免“宏里临时变量名字和调用处局部变量意外撞车”的机制;宏自己定义的局部名,默认不会偷偷污染调用者的同名变量。
解答:
看这个例子:
| |
如果你想让调用者显式提供名字,就把名字作为 ident 传入:
| |
| |
Go 对比:
| |
- Go 怎么做:Go 没有语言内建的卫生宏,所以不存在这套编译期命名隔离规则。
- Rust 为什么不同:Rust 宏大量生成局部代码,若不做卫生隔离,变量名冲突会非常常见。
- Go 程序员易踩的坑:以为宏展开就是“纯文本替换”;Rust 宏不是 C 预处理器。
记忆点:
- Rust 宏不是简单文本替换。
- 宏内部局部名默认有卫生保护。
Q5. $crate 是干什么的?
Tags: common crate
适用版本: Rust 1.30+
一句话答案:
$crate 让导出的宏能稳定引用“定义该宏的 crate 自己”的路径,而不是错误地按调用方的模块作用域去找名字。
解答:
坏例子:
| |
好例子:
| |
Go 对比:
| |
- Go 怎么做:包路径通常直接按导入路径解析,不存在导出宏在别人包里展开的同类问题。
- Rust 为什么不同:宏展开位置和名字解析位置可能跨 crate,所以需要
$crate这种“回到定义处”的锚点。 - Go 程序员易踩的坑:在导出宏里写裸路径,结果一到别的 crate 用就炸。
记忆点:
- 只要宏要对外导出并引用本 crate 内部路径,就优先想
$crate。
Q6. 为什么会报 no rules expected this token?
Tags: common errors
适用版本: Rust 1.0+
一句话答案:
因为传给 macro_rules! 的 token 形状和它的匹配规则对不上;这通常不是“类型错了”,而是“语法形状没匹配上”。
解答:
匹配正确:
| |
多传一个逗号就不匹配:
| |
Go 对比:
| |
- Go 怎么做:这类错误更像函数参数个数不对。
- Rust 为什么不同:声明宏先做语法模式匹配,压根还没走到类型检查阶段。
- Go 程序员易踩的坑:看到报错就去怀疑类型;很多宏错误其实要先看 token 形状。
记忆点:
- 先对照宏规则,再看调用点 token。
- 宏报错常常发生在类型检查之前。
Q7. 过程宏有哪些种类? derive / attribute / function-like 各适合什么?
Tags: common derive
适用版本: Rust 1.30+
一句话答案:
derive 宏最适合“给结构体/枚举补 impl”;attribute 宏适合“改写某个函数、模块或结构体”;function-like 过程宏适合 sql!(...)、html!(...) 这种 DSL。
解答:
derive:
| |
function-like 的用法形状:
| |
Go 对比:
| |
- Go 怎么做:更多是外部生成器先改源码,再
go build。 - Rust 为什么不同:过程宏直接接进编译流程,用户只需写一个属性或调用一个宏。
- Go 程序员易踩的坑:把 derive 看成“反射”;它发生在编译期,不是运行时看类型。
记忆点:
- derive 补 impl。
- attribute 改条目。
- function-like 做 DSL。
Q8. Rust 宏和 Go 的 go:generate、反射分别怎么类比?
Tags: hot go-compare
适用版本: Rust 1.0+
一句话答案:
最粗略地说:macro_rules! 更像编译期语法模板,过程宏更像内嵌编译流程的代码生成器,Go 的 go:generate 更像编译前外部脚本,反射则是运行时能力,不是一个层级。
解答:
Rust 宏示意:
| |
Go go:generate 是另一条路线:
| |
| |
| |
Go 对比:
| |
- Go 怎么做:编译前用
go:generate产文件,运行时用reflect看类型。 - Rust 为什么不同:Rust 宏主要走编译期扩展,不鼓励用运行时反射兜大部分元编程需求。
- Go 程序员易踩的坑:把过程宏和反射混成一类;它们一个在编译期,一个在运行时。
记忆点:
go:generate近似外部代码生成。- 反射是运行时。
- Rust 宏的主战场是编译期。
Q9. 什么时候该优先用函数 / trait / 泛型,而不是宏?
Tags: common design
适用版本: Rust 1.0+
一句话答案:
只要函数、trait、泛型能清楚表达,就不要先上宏;宏更适合“必须生成语法结构”或“必须做编译期 DSL/派生”的场景。
解答:
优先函数:
| |
只有在你想隐藏重复语法时,宏才更有价值:
| |
Go 对比:
| |
- Go 怎么做:工程里通常优先普通函数、接口和生成器。
- Rust 为什么不同:Rust 宏很强,但也会让错误信息和阅读成本上升。
- Go 程序员易踩的坑:看到宏很强就过度使用,结果 API 变难懂、报错也更绕。
记忆点:
- 能不用宏,就先不用。
- 宏是为了减少重复和扩语法,不是为了“显得高级”。
Q10. 宏的稳定边界怎么理解?哪些是 stable,哪些别写成 stable?
Tags: hot stability
适用版本: Rust 1.97.1
一句话答案:
macro_rules!、derive/attribute/function-like 过程宏,都是 stable 主线;真正要小心的是某些实验性宏系统细节或新的 matcher 语法,不要把没稳定的扩展写成生产默认方案。
解答:
稳定主线:
| |
过程宏主线也稳定,但创建过程宏 crate 需要专门配置:
| |
| |
Go 对比:
| |
- Go 怎么做:代码生成链路更少和更外置。
- Rust 为什么不同:Rust 宏系统成熟且强大,但也有实验性边角。
- Go 程序员易踩的坑:看见博客里某个炫技 matcher 或实验特性,就误以为 stable 可直接上生产。
记忆点:
macro_rules!和主流过程宏是 stable。- 写教程或文档时,要明确区分稳定主线和实验玩法。
Q11. 宏报错时先怎么排查?
Tags: occasional debug
适用版本: Rust 1.0+
一句话答案:
先看“调用 token 是否匹配规则”,再看“展开后生成了什么代码”,最后才看类型检查;宏错误常常不是业务逻辑错,而是展开形状错。
解答:
一个简单宏:
| |
排查时常用工具是看展开结果。先安装 cargo-expand,再对目标 crate 展开:
| |
上面几条分别表示:安装工具、展开当前包默认目标、按二进制名展开、按库目标展开、只展开某个模块路径。展开输出就是宏真正生成的代码,对照它再查匹配规则通常比盯调用点更快。
| |
Go 对比:
| |
- Go 怎么做:调代码生成器时更像看生成后的
.go文件。 - Rust 为什么不同:过程宏和声明宏都嵌在编译流程里,所以“先看展开结果”尤其关键。
- Go 程序员易踩的坑:一直盯着调用点,不去看宏规则和展开结果。
记忆点:
- 先匹配,再展开,再类型检查。
cargo install cargo-expand,再用cargo expand看真实展开。
Q12. 过程宏 crate 为什么要单独建?
Tags: advanced proc-macro-crate
适用版本: Rust 1.30+
一句话答案:
因为过程宏本身要在编译器编译你的业务 crate 之前先被编译并加载,所以它必须作为 proc-macro = true 的独立 crate 存在,不能和普通业务库完全混在同一种角色里。
解答:
Cargo 配置:
| |
最小函数签名长这样:
| |
| |
Go 对比:
| |
- Go 怎么做:生成器通常是独立命令行程序。
- Rust 为什么不同:过程宏本质上也是“编译期插件”,只不过通过 crate 形式装进编译链路。
- Go 程序员易踩的坑:期望在同一个普通库 crate 里顺手定义过程宏并直接用;Rust 的编译顺序不允许这么简单。
记忆点:
- 过程宏 crate 是编译期插件 crate。
- 它之所以独立,是由编译时机决定的。
Q13. 我只是库用户,需要会写 macro_rules! 吗?
Tags: common macro-rules user
适用版本: Rust 1.0+
一句话答案:
多数应用/库用户会“调用宏”就够了;不必先学会写 macro_rules!。只有你要封装重复语法、写小 DSL,或维护导出宏时,才需要上手写。
解答:
日常你会用到 println!、vec!、assert_eq!、各类 #[derive(...)],这些都是“当用户”。会读报错、会查文档比会写匹配器更重要。真正要自己写声明宏,往往是:项目里同一段样板反复出现、想做测试辅助宏、或库要对外提供宏 API。过程宏门槛更高,库用户更少直接写(见 Q3、Q12)。
| |
| |
Go 对比:
| |
- Go 怎么做:普通用户几乎不写代码生成器;用标准库和手写代码即可。
- Rust 为什么不同:宏使用面更广,但“会用”和“会写”仍是两层技能。
- Go 程序员易踩的坑:以为学 Rust 必须先啃完整宏系统,结果卡在入门。
记忆点:
- 先会调用,再考虑编写。
- 写宏是为了消灭语法级重复,不是KPI。
Q14. 宏里 $()* / $()+ 重复匹配怎么读?
Tags: common macro-rules repetition
适用版本: Rust 1.0+
一句话答案:
$( ... )* 表示“这组模式重复 0 次或多次”;$( ... )+ 是“至少 1 次”。中间的分隔符(如 ,)写在重复器里,展开侧用同样的 $( ... )* 把捕获项逐个吐出。
解答:
把 $( $x:expr ),* 读成:“逗号分隔的 0 个或多个表达式,捕获到 $x”。右边写 $( push($x); )* 就是按匹配次数展开。+ 与 * 的差别只在是否允许空输入;还有 ? 表示 0 或 1 次。先对照 Q2 的基本形状,再看重复器,通常就能读懂 vec! 一类宏。
| |
| |
Go 对比:
| |
- Go 怎么做:可变参数是函数层
...T,不是编译期 token 重复器。 - Rust 为什么不同:声明宏在 token 层描述“语法可以重复几次”。
- Go 程序员易踩的坑:把
$*当成正则,忽略分隔符写在哪一层。
记忆点:
*= 0+,+= 1+,常配分隔符。- 匹配几次,展开侧就重复几次。
Q15. #[macro_export] / 导入宏的现代写法是什么?
Tags: common macro-export use
适用版本: Rust 1.0+;use 导入宏为现代主线
一句话答案:
库要对外提供声明宏时加 #[macro_export](宏会出现在 crate 根);调用方用 use your_crate::macro_name; 导入后再 macro_name!()。老式 #[macro_use] extern crate ... 已不作为新代码默认写法。
解答:
导出宏里引用本 crate 路径时记得用 $crate(见 Q5)。2018 edition 之后,宏可以像普通路径一样 use;同一 crate 内,宏需先定义再使用,或放在合适模块并 use 进来。过程宏则是依赖对应 crate 后 use derive/属性/函数式宏名字,而不是 macro_export。
| |
| |
Go 对比:
| |
- Go 怎么做:
import包路径,再调用导出符号。 - Rust 为什么不同:宏也曾靠
#[macro_use]注入命名空间;现代写法对齐普通use。 - Go 程序员易踩的坑:照抄旧教程的
#[macro_use] extern crate,和新 edition 项目风格打架。
记忆点:
- 导出:
#[macro_export]。 - 导入:优先
use path::macro_name;。
Q16. 能用函数/泛型解决时,为什么还要(或不该)上宏?derive 报错怎么定位?
Tags: common design derive debug
适用版本: Rust 1.0+
一句话答案:
函数/泛型能表达就优先它们(见 Q9);宏留给“必须生成语法/impl/DSL”的缺口。derive 报错先看属性是否合法、字段是否满足 trait bound,再用 cargo expand 看展开后的 impl。
解答:
该上宏的信号:要在编译期根据 token 生成 impl、可变语法、或检查字面量形状。不该上宏的信号:只是少打几行样板、却把类型错误变成晦涩的宏匹配错误。derive 出错时,消息常指向生成代码:确认 #[derive(Debug)] 等依赖的字段类型也实现了对应 trait;复杂 derive(serde 等)先读属性文档。展开排查:
| |
对照展开后的 impl 再改原类型定义,通常比死盯调用点快(与 Q11 同一套路)。
| |
| |
Go 对比:
| |
- Go 怎么做:优先普通函数/接口;特殊代码生成走
go:generate。 - Rust 为什么不同:derive 把“补 impl”变成一等体验,但也把部分错误推到生成代码里。
- Go 程序员易踩的坑:derive 一红就怀疑编译器,不看字段是否缺
Debug/Clone等 bound。
记忆点:
- 能函数/泛型就别先上宏。
- derive 报错:查 bound → 必要时
cargo expand。
Q17. include_str! / include_bytes! / env! / concat! 各干什么?
Tags: common include_str env concat
适用版本: Rust 1.0+
一句话答案: 它们都是编译期嵌入常量的内置宏:include_str!/include_bytes! 把源码旁文件嵌成 &'static str / &'static [u8];env! 读编译期环境变量;concat! 把字面量拼接成一个字符串字面量。
解答:
| 宏 | 得到什么 | 典型用途 |
|---|---|---|
include_str!("path") | &'static str | 嵌入 LICENSE、SQL、HTML 模板 |
include_bytes!("path") | &'static [u8] | 嵌入图标、证书、二进制资源 |
env!("VAR") | &'static str | 读 PATH,或 Cargo 注入的 CARGO_PKG_VERSION 等 |
concat!("a", "b") | &'static str | 拼字面量、拼进其他宏参数 |
路径相对于包含该宏调用的源文件;文件不存在则编译失败。env! 变量缺失也会直接编不过;若允许缺失用 option_env!。
| |
| |
和运行时 std::fs::read_to_string 的差别:include_* 把内容打进二进制,无该文件的机器也能跑,但改资源必须重新编译。
Go 对比:
| |
- Go 怎么做:
//go:embed嵌入文件;构建信息常用ldflags注入。 - Rust 为什么不同:用内置宏在表达式位置直接得到
'static数据。 - Go 程序员易踩的坑:把
include_str!当成运行时读文件;路径是编译期、相对源文件的。
记忆点:
include_*= 编译期打进二进制;env!= 编译期环境变量;concat!= 字面量拼接。- 缺文件/缺环境变量 → 编译失败,不是运行时
Err。
Q18. macro_rules! 分隔符和尾随逗号为啥总匹配失败?
Tags: common macro_rules trailing-comma
适用版本: Rust 1.0+
一句话答案: 重复匹配里的分隔符(如 ,)写在哪一层、是否允许尾随逗号,都是模式的一部分;调用处多一个逗号或少一个分隔符,就会变成 no rules expected this token(见 Q6、Q14)。
解答: 规则 ($($x:expr),+) 表示“至少一个表达式,两两之间有逗号”,默认不接受末尾多余逗号。要支持 foo!(1, 2,),得显式写可选尾随逗号,例如 ($($x:expr),+ $(,)?)。
「❌ 错误写法」——规则不收尾随逗号,调用却写了:
| |
「✅ 正确写法」——要么调用别写尾逗号,要么规则允许:
| |
分隔符也必须和规则一致:($($x:expr);+) 要用分号调用;把 , 写在 )* 外面还是里面,决定“重复体之间”还是“整段之后”的 token。匹配失败时先对照规则画 token,再改调用或改宏(见 Q11)。
Go 对比:
| |
- Go 怎么做:
...T是真正的可变参数,尾随逗号由语法统一允许。 - Rust 为什么不同:声明宏先做 token 模式匹配,逗号是模式的一部分,不是“参数列表糖”。
- Go 程序员易踩的坑:习惯函数调用随便加尾逗号,套到
macro_rules!上就红。
记忆点:
- 尾随逗号要规则里写
$(,)?(或等价形式)才收。 - 分隔符写错层 = 形状不匹配,优先查宏定义不是查类型。