宏系统:macro_rules! 与过程宏的完整实践
第 16 章你已经知道"vec! 是宏不是函数"。但真到要自己写一个宏的时候,绝大多数人卡在三个点上:片段分类符记不住、重复语法 $()* 看不懂、过程宏一写就报一堆看不懂的错。这一章把宏当成一门"小语言"来教:先讲清三套宏各自解决什么问题,再手搓一个能用的 hashmap!,最后完整走一遍"从零写一个自定义 derive"。学完你应该能看懂 serde 的报错、能自己写 cargo expand 去查问题。
四种宏,各管一段:先把地图铺开
Rust 的"宏"其实是一族机制,它们的实现方式、能做的事、出错时的体验完全不同。选错种类是最常见的返工原因。
| 种类 | 长相 | 本质 | 典型用途与限制 |
|---|---|---|---|
| 声明宏 macro_rules! |
name!(...) |
基于词法 token 的模式匹配,在 AST 之前展开 | 减少重复模板代码:vec!、hashmap!、lazy_static 风格初始化。不能做新的语法、拿不到类型信息(它只看 token 长什么样)。 |
| derive 过程宏 | #[derive(Foo)] |
函数式:把结构体/枚举的定义喂给你的函数,你吐回新代码 | 自动生成 impl:Serialize、Clone、Error、ToSchema。只能"附加"代码,不能修改原定义。 |
| 属性宏 | #[foo(bar)] |
函数式:把属性参数 + 被标注的 item 原文一起给你,你把 item 整个替换掉 | 改函数体/改签名:#[tokio::main]、#[instrument]、#[utoipa::path]、#[tauri::command]。必须原样把 item 吐回去,否则那个函数就消失了。 |
| 函数式过程宏 | foo!(...) |
函数式:输入一段 token,输出一段 token | 做 DSL:sqlx::query!、html!、quote!。看起来像 macro_rules!,但内部能做任意 Rust 计算。 |
论为什么要分成"声明"和"过程"两套
① 声明宏是"就地文本替换",必须稳定且快。它在 rustc 早期阶段展开,只依赖 token 序列,因此编译快、错误定位直接指向调用点。代价是它看不见类型,也无法读取 #[attr] 的内容做判断。
② 过程宏是"用 Rust 写编译器插件"。它拿到的是通过 syn 解析出的真实语法树,可以遍历字段、看类型、看属性参数,于是 serde 能针对每个字段生成不同的序列化代码。代价是必须放在独立的 proc-macro crate、编译更慢、错误信息需要你手动构造。
③ 判断标准一句话:需要"根据字段名/类型/属性做不同处理"就用过程宏;只是"少写重复的样板"就用 macro_rules!。能用声明宏解决的,绝对不要上过程宏——前者编译快十倍,维护成本低一个数量级。
macro_rules! 的三件事:分类符、重复、卫生性
把 macro_rules! 想象成一个"词法模式匹配器":左边是模式,右边是展开结果,多条规则从上往下试,第一条匹配成功就停止。
片段分类符(fragment specifier)全家福
| 分类符 | 匹配什么 | 实战说明 |
|---|---|---|
$x:expr | 一个表达式 | 最常用。注意后面只能跟 =>、,、;,跟着别的 token 编译器会报 expected one of ...。 |
$x:ident | 一个标识符 | 用来传变量名、函数名、字段名。配合 stringify!($x) 能拿到字符串名字。 |
$x:ty | 一个类型 | 泛型参数、返回类型常用。解析歧义多,能不用就不用。 |
$x:path | 一条路径 | std::collections::HashMap 这种。比 :ty 更明确,优先级更高。 |
$x:pat | 一个模式 | match 分支里用。注意它不接受 | 或,需要或模式请用 :pat_param(旧名)或加括号。 |
$x:stmt | 一条语句 | 用得少,因为匹配边界模糊,容易和 block 打架。 |
$x:block | 一个 { ... } | 传一段代码块,比 :expr 更明确。 |
$x:item | 一项(函数/结构体/impl) | 用来动态生成函数、结构体。 |
$x:tt | 一个 token 树 | 万能兜底:既能匹配单个 token,也能匹配一整个 {...}。写 DSL 时首选。 |
$x:literal | 一个字面量 | 只匹配 123、"abc" 这种,不匹配变量。比 :expr 严格。 |
$x:meta | 一条属性内容 | 配合 #[derive($x)] 这类场景。 |
$x:lifetime | 一个生命周期 | 'a 这种。$x:vis(可见性)也常一起用。 |
最小可用:三条规则 + 尾巴逗号容忍
现在写出真正实用的那个:hashmap!。标准库只有 vec!,没有 hashmap!,而写测试数据时它极其顺手。
hashmap!:演示重复语法 $( ... )sep*
重复语法的完整形态只有一句话要记:$( 内容 )分隔符 重复符。分隔符可以省略,重复符可以是 *(零或多次)、+(一次或多次)、?(零或一次)。嵌套重复也合法,但内层重复必须至少引用一个内层引入的元变量,否则编译器会告诉你"重复体内的元变量未定义"。
嵌套重复:生成二维结构
卫生性(hygiene):宏里的 x 和外面的 x 是两个人的 x
$x 被求值两次(甚至多次)
这是声明宏最经典、也最难发现的坑。看这个手写的 max!:
macro_rules! max { ($a:expr, $b:expr) => { if $a > $b { $a } else { $b } } }
如果调用 max!(expensive(), 0),expensive() 会被调用两次(先比较、再返回),在有副作用时结果直接错了,在无副作用时白算一遍。正确处理是"先用 let 绑一次,再复用变量":
过程宏实战:从零写一个自定义 derive
过程宏有一个绕不开的硬约束:必须放在一个独立的 crate 里,且这个 crate 的 Cargo.toml 要声明 proc-macro = true。这个 crate 里只能导出过程宏,不能导出普通的函数和类型,也不能被同一 crate 内的代码 use。
第一步:建一个 proc-macro crate(放在 workspace 的 crates/ 下)
第二步:写 derive 宏本体 —— 给结构体生成一个 describe() 方法
第三步:在业务 crate 里用它
属性宏与函数式过程宏的骨架(都是同一个套路)
论syn / quote / proc-macro2 三件套到底谁负责什么
① proc-macro2 是"可移植的 TokenStream"。标准库的 proc_macro::TokenStream 只能在过程宏进程里用,没法在普通单元测试里构造。所以 syn 和 quote 全都在 proc-macro2::TokenStream 上工作,只有最外层进出时做一次 .into() 转换。这也是为什么你能给自己的宏写单元测试——直接喂一个 quote!{ struct A; } 进去断言输出即可。
② syn 负责"输入解析"。它把 token 流变成 DeriveInput / ItemFn / Expr 这些结构化类型,你才能遍历字段、读属性。解析失败时它会给出带 Span 的错误,Span 能映射回用户源码的行列,这是过程宏错误信息可读性的唯一来源。
③ quote 负责"输出生成"。它提供一个近似 Rust 语法的模板:#var 插入变量、#( ... )* 重复、#var.foo() 做插值调用。它不会做语法检查,拼错的部分会原样进入输出,最后在用户代码里报错——这是过程宏调试困难的根本原因,所以必须配合下面讲的 cargo expand。
调试宏:cargo expand 是你的显微镜
宏最难的不是"写不出来",而是"报错指向宏调用点,你根本不知道展开成了什么"。Rust 生态给了一个标准答案。
常用调试手段(按使用频率排序)
① 用了 .unwrap() 而不是 parse_macro_input!。unwrap() 抛出的是 panic,用户看到的是 proc macro panicked 加一堆内部栈;parse_macro_input! 会把 syn 的带位置错误原样变成编译错误,指向用户代码的准确位置。
② 自己拼错误信息时没有 Span。要用 syn::Error::new_spanned(tokens, "消息") 而不是 Error::new(Span::call_site(), ...),前者能让 IDE 和编译器把波浪线画在出问题的那个字段上。如果要做"多错误一起报",引入 proc-macro-error2 或 syn::Error::combine。
另外一条常被忽略的:quote! 生成的结构体/枚举必须用 #name 而不是硬编码名字,否则泛型参数(struct Foo<T>)会因为缺少 impl<T> 而报 "cannot find type T"。泛型结构体请用 ast.generics.split_for_impl() 拿 impl_generics / ty_generics / where_clause 三段拼。
泛型结构体的正确处理(这是 derive 宏最常见的翻车点)
① 选种类的顺序:能用 macro_rules! 就别上过程宏;需要"按字段名/属性生成不同代码"才用 derive,需要"改写被标注的 item"才用属性宏。
② 片段分类符记住三个高频的::expr(表达式,后面只能接 => , ;)、:ident(标识符)、:tt(万能兜底)。
③ 重复语法就一句:$( 内容 )分隔符 重复符,重复符是 */+/?。
④ 卫生性保护宏内部的局部变量不污染调用方,但传进来的 $ident 是调用方的颜色,所以 make_getter!(name) 才能生成 fn name()。
⑤ 宏里的 $x 会被求值多次,一律先 let a = $a; 绑一次再复用。
⑥ 过程宏必须独立 crate,内部逻辑抽成 expand(&DeriveInput) 这类纯函数,就能用 proc-macro2 写单元测试。
⑦ cargo expand 是宏的显微镜,写宏不装它等于闭着眼睛开车。
1.(排错题)写 macro_rules! m { ($x:expr == 0) => {...} } 报错,为什么?
查看答案
:expr 后面只能跟 =>、,、;。你写了 == 属于"后面跟了其他 token",编译器会报 expected one of `=>`, `,`, `;`。修法:把 == 0 也放进 ($x:expr) 里由调用方写,或者用 $x:tt 逐个匹配 token。
2.(概念题)下面的宏有卫生性问题吗?macro_rules! m { () => { let x = 1; x + 1 } },调用方也有个 x。
查看答案
没有。宏内部 let x 带的是"宏定义处的语法上下文",和调用方的 x 是两个不同的标识符,不会冲突。这正是 hygienic macro 的价值:调用方不用关心宏内部用了什么变量名。但注意:结构体字段名、类型名、泛型参数不走卫生性,所以宏里生成 let T = ... 之类的标识符仍可能与调用方冲突。
3.(实操题)为什么 hashmap! 里必须先写 () => {...} 这条空分支?
查看答案
因为 $( ... ),+ 要求至少一个元素。不写空分支的话,hashmap!{} 匹配不到任何规则,报 "no rules expected this token"。要么补 () => 分支,要么改用 * 并处理零元素时 HashMap::new() 的类型推断问题(零元素时无法推断 K/V 类型,所以显式分支更安全)。
4.(架构题)为什么过程宏必须单独一个 crate?为什么它不能被自己 crate 里的代码使用?
查看答案
过程宏是"编译期运行的插件"。rustc 编译某个 crate 时,需要先把它的过程宏编译成可执行代码并加载进编译器进程执行。如果宏和业务代码在同一个 crate,就变成"编译 A 需要先运行 A"的循环依赖。所以 Cargo 强制拆开,并且 proc-macro crate 只能导出过程宏(不能导出普通函数/类型),也因此无法在自身内被 use。
5.(实战题)你的 derive 宏在 struct Response<T> 上报 cannot find type `T` in this scope,怎么改?
查看答案
泛型要拆三段。用 ast.generics.split_for_impl() 得到 (impl_generics, ty_generics, where_clause),然后写 impl #impl_generics #name #ty_generics #where_clause { ... }。只写 impl #name 会丢掉 <T> 声明,导致类型参数在 impl 作用域里不存在。