第一次被 Rust 宏震到,多半是在vec![1, 2, 3]或者println!("{}", x)面前:明明函数做不到的可变参数,宏却举重若轻。但等你真的上手写自己的宏,才发现完全是另一回事——macro_rules!的匹配规则像谜语,过程宏的编译错误能绕晕人,更别说quote!里一个#放错位置,生成代码就悄悄变了样。这篇文章不是从语法手册抄概念,而是把我在实际项目中从声明式宏到过程宏踩过的路径梳理一遍,重点放在「为什么这样写」和「出问题时怎么排查」,适合正在写库、想做 DSL、或者被宏报错折磨的 Rust 开发者当作一份带路笔记。
1. 开始写宏之前:先搞清声明式宏与过程宏的分工
1.1 宏的「操作对象」是 token 树,不是字符串
很多人第一次写宏时,下意识把它当成 C 语言那套文本替换来用,这是最危险的误解。Rust 宏处理的是 token 树,不是一段一段的字符流。
token 树是编译前端把源代码切碎之后形成的最小结构,比如标识符、字面量、标点符号,以及被括号包裹的整段分组。macro_rules!的匹配器就是在这些 token 上做模式匹配,展开过程则是把匹配到的 token 原样搬进转写模板。这带来一个关键好处:优先级问题不会像文本宏那样炸掉。我写make_getter!(Point, x, i32)这种宏时,$ty:ty匹配到的i32是独立 token,生成的&self.x就是&self.x,不会因为调用处写了A + B之类的表达式就产生解析歧义。
理解这一点后,你就不会问「为什么宏里不能直接拼字符串再喂给编译器」这种问题了。宏是在编译器眼里做手术,不是拿着剪刀剪源码。
1.2 两种宏的适用边界
Rust 宏大体分两类:
- 声明式宏(
macro_rules!):在 token 树层面做模式匹配和替换。它不「理解」类型系统,不读取语义信息,适合生成结构高度规则化的代码,比如vec!、format!这种靠表达式列表解决问题的场景。 - 过程宏(procedural macro):接收
TokenStream,解析成 AST,做完变换后再吐回TokenStream。它本质上是一个编译期运行的函数,能做更复杂的逻辑判断,也更容易生成大型代码骨架。
我见过不少人一上来就写过程宏,结果只是为了生成几行重复的impl。其实那可能用macro_rules!就够了。反过来,有的需求明明需要读字段类型、根据属性配置决定生成内容,却非要在macro_rules!里硬憋,最后写出一堆@标记和递归分支,维护起来比过程宏还痛苦。
我的判断标准很简单:如果宏要「看图识字」,也就是根据用户代码里的结构、类型、属性做出不同反应,优先过程宏;如果只是「套模板」,声明式宏更轻量。另外记住,宏不是万能胶。如果重复代码是可以通过函数、泛型、trait 解决的,就先别碰宏。宏的调用点和展开结果之间隔着语义鸿沟,滥用宏会把易用性活生生做成「魔法咒语」。
2. 声明式宏实战:macro_rules! 的匹配规则与递归套路
2.1 片段分类符决定你能吃掉什么
macro_rules!的匹配器由$变量名:片段分类符构成。片段分类符是整个匹配系统的核心,选错了,要么匹配不上,要么展开结果不符合预期。
我先列一份常用的对照表,后面写宏时可以直接查:
| 分类符 | 匹配内容 | 常见坑 |
|---|---|---|
ident | 标识符 | 不能匹配self、关键字等特殊标识符 |
expr | 表达式 | 表达式在模式位置不通用,某些上下文不合法 |
ty | 类型 | dyn Trait、impl Trait等有额外限制 |
path | 路径 | 匹配的是路径形式,不等于类型 |
literal | 字面量 | -1里的-是标点,不属于 literal |
tt | 单个 token 树 | 最通用,等于万能后门 |
meta | 属性内容 | 常用于解析#[...]内部 |
item | 完整的项 | 函数、结构体、impl 等都能匹配 |
block | 代码块 | 匹配的是{}包裹的区域 |
stmt | 语句 | 某些位置不允许,使用时要注意 |
我最常用的是expr、ty和tt。expr在大多数场景够用,而且语义清晰,比如:
macro_rules! define_message { ($name:ident, $typ:ty, $default:expr) => { pub fn $name() -> $typ { $default } }; } define_message!(answer, u32, 40 + 2);这里$default:expr匹配到的是40 + 2这个表达式树,展开后完整放进函数体里。如果把这段逻辑换成一个接受字符串再重新解析的方案,早就被优先级问题坑死了。
tt则是一种「先吃进来再说」的策略。当你不确定用户会写什么语法,或者想实现的 DSL 灵活度过高时,用tt捕获原始 token,再转发给后面的匹配分支做精细处理。它是所有匹配规则的底牌,但也是失控的起点。tt捕获的内容不会帮你做任何合法性检查,所有校验都得自己在后续分支里完成。
2.2 重复规则:*、+、?的写法与转发
写宏必然绕不过重复:要么接受任意多个参数,要么生成任意多段代码。macro_rules!在匹配器和转写器里都支持$(模式) 分隔符 重复符号,重复符号有三种:
*:零个或多个+:一个或多个?:零个或一个
比如我想让用户传多个字段名,生成一个包含这些字段的结构体:
macro_rules! struct_with_fields { ($name:ident { $( $field:ident: $ty:ty ),* $(,)? }) => { pub struct $name { $( pub $field: $ty ),* } }; } struct_with_fields!(Person { name: String, age: u32 });这段代码有两个细节值得说。第一,$( $field:ident: $ty:ty ),*表示参数之间用逗号分隔,重复任意次;第二,末尾的$(,)?允许用户在最后一个字段后面额外加逗号。很多宏收到的输入来自自动化生成代码,末尾可能带逗号,不处理这个细节,用户就会看到莫名其妙的「unexpected token」报错。
另一个常见需求是把匹配到的内容转发给另一个宏。转发时要注意,重复段里变量名必须保持一致,且同一层重复里只能用同一套重复次数。不同层级的重复嵌套,比如$( $( $x:ident ),* ; )*,内部和外部的重复次数是独立的,展开时要分别处理。
2.3 递归下推和 TT muncher:吃掉一个 token,再继续
声明式宏还有一个杀手锏:递归。经典的count_exprs!是理解递归宏的最小案例:
macro_rules! count_exprs { () => { 0 }; ($head:expr $(, $tail:expr)*) => { 1 + count_exprs!($($tail),*) }; } assert_eq!(count_exprs!(1, 2, 3), 3);每次匹配先吃掉开头的表达式,把剩下的 tail 再递归传给自身,直到空匹配。这种「每次消耗一个 token,再拿着残余继续匹配」的思路,社区里叫 TT muncher,是声明式宏处理不定长、无固定结构语法的核心手段。
它的进阶形态是在内部维护「状态分支」。常见写法是用@作为标记分隔内部规则和对外入口。给一个概念性的结构:
macro_rules! parse_tokens { (@collect $acc:expr; ) => { $acc }; (@collect $acc:expr; $head:tt $($rest:tt)*) => { parse_tokens!(@collect handle_token!($acc, $head); $($rest)*) }; ($($tokens:tt)*) => { parse_tokens!(@collect initial_value!(); $($tokens)*) }; }外部调用只暴露最后一个分支,内部递归走@collect分支。这样做的好处是:用户永远无法误触内部规则,因为普通合法标识符不能以@开头(至少不会出现在正常调用里),规则之间隔离得干净。
递归宏绕不开的一个限制是recursion_limit。当宏递归深度或者展开生成的代码规模超过默认限制,编译器会报recursion limit reached。处理方式是在 crate 根或使用处加:
#![recursion_limit = "256"]但加了还是要反思:递归深度大通常意味着宏设计偏复杂,能拆成过程宏的地方就别硬撑。
2.4 卫生性与$crate:宏生成代码不能想当然写路径
Rust 宏对局部变量、标签有卫生性保护,但宏生成的项级名称(比如辅助函数名)并不完全卫生。我在一个公开库的宏里写过类似这样的代码:
#[macro_export] macro_rules! make_id { () => { crate::next_id() }; }问题在于,这个宏被下游 crate 调用时,crate::next_id()里的crate指的是下游自己的 crate,而不是我这个库。这种错误非常经典。解决办法是使用$crate:
#[macro_export] macro_rules! make_id { () => { $crate::next_id() }; }$crate会自动展开为宏定义所在 crate 的路径,这是所有#[macro_export]宏的必修课。同理,宏内部要引用辅助类型、辅助函数时,都要走$crate::前缀,别指望用户环境中存在同路径。
顺带提醒:宏生成代码里的辅助标识符,最好加上不冲突的前缀,比如__internal_helper。过程宏更是如此,因为过程宏没有$crate这种语法糖,通常得自己拼用::some_crate::...或者借助辅助 crate 查找机制来定位依赖路径。能少用裸标识符就少用,命名冲突这种事,上线之后才暴露就晚了。
3. 过程宏项目的搭建:Cargo.toml、syn 与 quote 的配合
3.1 过程宏必须单独建 crate,这是硬约束
过程宏和普通函数不同,它要求 crate 类型是proc-macro。在Cargo.toml里这样声明:
[package] name = "example-macro" version = "0.1.0" edition = "2021" [lib] proc-macro = true [dependencies] syn = { version = "2", features = ["full"] } quote = "1" proc-macro2 = "1"有几个容易踩的点。第一,proc-macro = true的 crate 里,对外导出的只有过程宏相关的函数,不能再导出普通库函数。如果你既想给用户提供宏,又想提供配套的普通 API,通常的做法是拆成两个 crate:一个是xxx-macro(过程宏实现),另一个是主 crate(用户直接依赖,把宏 re-export 出去)。这也是生态里许多派生宏库的标准结构。
第二,依赖的版本管理要克制。syn、quote、proc-macro2是宏开发的三大件,但它们的 API 在 major version 之间改动不小。我一般会锁住大版本,并且只在必要的时候升级。过程宏 crate 是编译链路上跑得最勤的部分,依赖不稳定就是在给自己埋雷。
3.2 为什么离不开 syn 和 quote
当你第一次面对TokenStream时,会觉得它就是一堆 token 的序列,想从中提取某个函数的名字、某个字段的类型,手写遍历 token 会让人发疯。syn做的就是把 TokenStream 解析成语义化 AST。比如syn::DeriveInput就是解析一个 struct/enum/union 的完整结构,里面直接有ident、generics、data这些字段,不用自己操心括号层级和标点。
quote则负责反向操作:用接近源码的语法生成 TokenStream。quote! { ... }里的内容写起来就像写普通 Rust 代码,用#变量做插值。插值规则是 token 级别的,不是字符串拼接。比如:
let name = syn::Ident::new("Point", proc_macro2::Span::call_site()); let expanded = quote! { impl #name { pub fn origin() -> Self { Self { x: 0, y: 0 } } } };这段代码生成的 impl 块会自动带上Point这个名字,并且 token 结构是完整的。你要是用format!拼源码再 parse,等于绕一大圈,还容易把标识符切开、破坏卫生性。
proc-macro2是这个生态里的关键粘合剂。proc_macro::TokenStream只能在过程宏函数里使用,不方便在普通代码里构造和测试;proc_macro2::TokenStream则是纯数据结构的 token 流,可以在普通 crate 中用,也可以单元测试。转换很简单,结尾.into()就能从proc_macro2转到proc_macro的TokenStream。
3.3 三种过程宏入口:函数式、派生宏、属性宏
过程宏分三种,输入输出的差异决定了使用场景:
| 类型 | 标记 | 输入 | 输出 |
|---|---|---|---|
| 函数式过程宏 | #[proc_macro] | 宏调用括号内的 token | 替换整个宏调用的 token 流 |
| 派生宏 | #[proc_macro_derive(Name)] | 被#[derive(Name)]标记的整个 item | 只能追加代码,不能修改原 item |
| 属性宏 | #[proc_macro_attribute] | 属性参数 + 被修饰的整个 item | 可以整体替换 item |
最小函数式过程宏是这样的:
use proc_macro::TokenStream; use quote::quote; #[proc_macro] pub fn answer(_input: TokenStream) -> TokenStream { quote! { 42u32 }.into() }调用answer!()之后,编译器看到的就是42u32。函数式过程宏适合做 DSL:输入是括号里的一串自定义语法,输出是一段完整的 Rust 代码。
派生宏的限制要特别强调:不能修改原 item,只能追加代码。比如#[derive(Debug)]并没有把用户的 struct 改掉,它只是在旁边生成了额外的impl Debug for 用户类型。所以派生宏返回的 token 流通常是一个或多个impl块,绝对不能把输入的 struct 吞掉再重新吐一个。
属性宏则拥有最大的自由度。#[test]、#[tokio::main]这类宏可以把一个函数整个包装起来,返回一个全新的函数定义。这个能力很强,也更容易写坏——后面我会单独细讲。
4. 手写派生宏:解析 DeriveInput 与生成泛型 impl
4.1 用 parse_macro_input! 打开输入
写派生宏的第一步,是把输入的TokenStream解析成DeriveInput。标准姿势是这样的:
use proc_macro::TokenStream; use quote::quote; use syn::{parse_macro_input, DeriveInput}; #[proc_macro_derive(Describe)] pub fn derive_describe(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); let name = &input.ident; let expanded = quote! { impl Describe for #name { fn describe() -> &'static str { stringify!(#name) } } }; expanded.into() }parse_macro_input!是syn提供的小工具,解析失败时会自动生成一条指向用户代码位置的compile_error!并提前返回。注意它要求函数返回proc_macro::TokenStream,所以这里才需要最后.into()。
DeriveInput里的关键字段有三个:ident(类型名)、generics(泛型参数)、data(结构体/枚举/联合体三选一)。多数派生态的实用派的先看data,因为字段遍历逻辑都藏在这里。
4.2 处理结构体的字段:Named、Unnamed、Unit 三种形态
结构体有三种形态:命名结构体struct Point { x: i32 }、元组结构体struct Point(i32, i32)、单元结构体struct Unit;。派生宏如果只覆盖其中一种,用户换一种写法就报错,那体验就很糟。
假设我要生成一个DefaultConfig的实现,为每个字段生成默认值。命名结构体的写法:
let data = &input.data; let fields: Vec<_> = match data { syn::Data::Struct(s) => s.fields.iter().collect(), _ => { return syn::Error::new_spanned( &input.ident, "DefaultConfig 只支持 struct", ) .to_compile_error() .into() } };拿到字段列表后,需要区分字段名。命名结构体用field.ident,元组结构体没有字段名,只能用索引0, 1, 2...。在quote!里生成构造表达式时,这个差异会直接影响代码形态。如果全用Self { field: Default::default(), ... },遇到元组结构体就废了。
处理方式通常是在解析阶段就先想好要生成的目标形态,比如统一生成一段构造Self的代码,用字段名或索引填充。这里我常犯的错误是直接在quote!里用#( #field.ident ),*去遍历,却忘了元组结构体的ident是None。正确的做法是先收集一个Vec<TokenStream>或者Vec<proc_macro2::Ident>,再做模式归一化。
4.3 泛型参数是新手最陌生的坑:split_for_impl
很多人在派生宏里栽在泛型处理上。如果你这样写:
impl #generics Describe for #name { ... }看着合理,但编译出来大概率是错的。问题在于,generics展开后是<T: Clone>这种定义泛型的语法,而 impl 关键字后面的位置确实需要定义泛型,这部分没问题;但类型名后面需要的却是具体类型参数,比如Foo<T>,而不是Foo<T: Clone>。
正确做法是用split_for_impl:
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); let expanded = quote! { impl #impl_generics Describe for #name #ty_generics #where_clause { fn describe() -> &'static str { stringify!(#name) } } };split_for_impl()返回三部分:impl_generics(用于impl后面)、ty_generics(用于类型名后面)、where_clause(保留原有的 where 约束)。这是派生宏生成带泛型类型实现时要背下来的模板,几十个派生宏几乎都是这个骨架。
另一个常用工具是syn::parse_quote!。当你想在生成代码里显式构造一个带类型的 AST,比如手写let x: $ty = Default::default();,直接用quote!拼接可能有坑,而parse_quote!可以帮你把一段伪代码解析成想要的 AST 节点,语义更清楚。它可以配合quote!一起用,是整理复杂生成的常规手段。
4.4 helper attribute:给派生宏加配置项
只生成固定代码的派生宏,能力有限。给用户留「开关」是刚需。比如#[derive(Describe)]时,允许在字段上写#[describe(skip)],让该字段不参与生成。
第一步是声明辅助属性。在#[proc_macro_derive(...)]里列出来:
#[proc_macro_derive(Describe, attributes(describe))] pub fn derive_describe(input: TokenStream) -> TokenStream { ... }注意,attributes(describe)里的describe是你允许用户在 item/字段上写的属性名字,不是宏自己的名字。声明完之后,用户就能写#[describe(skip)]了。
第二步是在遍历字段时读取这个属性:
for field in &s.fields { let mut skip = false; for attr in &field.attrs { if attr.path().is_ident("describe") { attr.parse_nested_meta(|meta| { if meta.path.is_ident("skip") { skip = true; } Ok(()) })?; } } }这段代码里?不能随便丢:parse_nested_meta返回syn::Result<()>,如果用户写了#[describe(unknown_option)],这里会得到错误,最终转成编译错误让用户看到。派生宏的配置项解析,本质上就是「允许的选项白名单 + 明确报错」,别搞成静默忽略。
4.5 报错要优雅:syn::Error 而不是 panic
过程宏里 panic 是最偷懒也最坑人的做法。一旦 panic,编译错误信息通常只显示「proc macro panicked」,用户根本不知道是自己哪里写错了。
正确做法是使用syn::Error:
let error = syn::Error::new_spanned( &input.ident, "Describe 不支持 enum,请改用其他宏", ); return error.to_compile_error().into();new_spanned会把错误定位到输入的某个 token 上,让用户的 IDE 直接高亮出错位置。.to_compile_error()会把错误转成一个调用compile_error!的 token 流,编译器看到后以普通编译错误形式呈现。
如果你在解析过程中累积了多个错误,可以先放进Vec<syn::Error>,最后统一合并,避免一次只暴露一个错误让用户改完又冒出一个。
5. 属性宏与函数式过程宏:改写用户代码与构建 DSL
5.1 属性宏的入口参数和「包装」模式
属性宏的签名和另外两种比,最大的区别是它有两个输入参数:
#[proc_macro_attribute] pub fn timed(args: TokenStream, item: TokenStream) -> TokenStream { ... }第一个args是#[timed(...)]括号内的内容,第二个item是被修饰的整个项。属性宏最典型的应用是「包装」:解析出原始函数,在外面包一层新逻辑,再输出完整函数。
我之前写过一个给同步函数计时的属性宏,代码大概这样:
#[proc_macro_attribute] pub fn timed(_args: TokenStream, item: TokenStream) -> TokenStream { let input_fn = parse_macro_input!(item as syn::ItemFn); let vis = &input_fn.vis; let sig = &input_fn.sig; let block = &input_fn.block; quote! { #vis #sig { let __start = std::time::Instant::now(); let __result = { #block }; eprintln!("elapsed: {:?}", __start.elapsed()); __result } } .into() }这个宏的精髓是把原始函数体#block放到一个内部块里执行,再在整个函数前后插入计时逻辑。注意#vis #sig这两段要原封不动带出去,否则用户的pub fn会变成私有函数,或async fn会丢异步标记。属性宏的自由度大,也意味着你对原 item 的每个组成部分都要负责。
一个常见问题是:属性宏解析item时,你并不知道调用处是在 crate 根还是模块里,也不清楚用户的代码版本——所以能少用 unstable 语法就少用,别让一项简单包装变成用户换编译器的理由。
5.2 函数式过程宏:适合设计「微型 DSL」
函数式过程宏输入是括号里的全部 token,输出可以完全替换宏调用处。它比macro_rules!更适合复杂 DSL 的解析,因为解析逻辑就是普通 Rust 代码,能循环、能报错、能组合。
举个例子,我想定义一个路由表 DSL:
use example_macro::routes; routes! { GET /ping => handle_ping, POST /users => handle_create_user, }宏内部每次匹配方法 + 路径 + => + 处理器这样的结构,手动构造一个 token 迭代器,反复消费。写这种宏时,我一般会把解析拆成小函数,每个函数只处理一层语法,最后组装成一个match表达式返回。
函数式过程宏还有个优势:可以把「编译期校验」做得很彻底。用户写了一个不支持的 HTTP 方法,宏解析阶段就能报错,而不是等运行期才崩。这也是宏系统最值得投资的地方——把错误提前到编译期。
5.3 span 技巧与错误定位:让报错指到用户的字段上
用quote!生成代码时,所有新生成的 token 默认都挂在宏调用的位置(Span::call_site())。如果生成代码有语义错误,编译器会报在宏调用那一行,而不是真正有问题的用户代码上。对使用者来说,这种报错基本等于废话。
要改善体验,就要把输入 token 的 span 带进生成结果。最常用的工具是quote_spanned!:
let field_span = field.ty.span(); quote_spanned! { field_span => #field_name: #default_value }quote_spanned!允许你指定生成 token 的起始 span,这样当这段生成代码出错时,编译器会跳到用户写那个字段的位置附近。
在这个问题上,我个人的习惯是:先保证宏能正确生成,再花时间打磨报错。很多宏库初期只追求功能,结果用户一遇到问题就瞎猜。真正受认可的宏库,往往在 span 传递上花了大功夫。报错信息本身就是宏 API 的一部分,就好比一个函数连异常信息都懒得写,你还敢放心用吗。
6. 调试、测试与发布的实战经验
6.1 cargo expand 是宏开发者的显微镜
写宏最大的痛苦是「看不到中间产物」。cargo expand是解决这个问题的第一工具:
cargo expand它会把宏展开后的真实代码打印出来。我每次改完一个宏,都会先跑一遍cargo expand看生成的代码是否符合预期,而不是直接编译一个庞大的项目然后看报错。
没有安装cargo-expand时,也可以用 rust-analyzer 的「Expand macro recursively」功能,在编辑器里直接看展开结果。还有一条路是用 rustc 的-Zunpretty=expanded(nightly 下),但配置繁琐,日常推荐前者。
看展开结果要注意关键点:目标 trait 是否真的实现对了?泛型参数有没有莫名其妙多出约束?辅助函数是否生成了不想要的可见性?这些一眼就能扫出来。
6.2 单元测试与 UI 快照测试
过程宏 crate 的测试有个特殊性:你不能像普通函数那样直接在单元测试里调用导出的宏。所以我会把宏的核心逻辑拆到普通模块里,比如expand.rs,让#[proc_macro_derive]入口只负责「解析 + 委托 + 转换」。这样expand模块里的函数可以接受proc_macro2::TokenStream并返回proc_macro2::TokenStream,方便在单元测试里模拟调用:
#[test] fn test_expand_derive() { let input: proc_macro2::TokenStream = "struct Point { x: i32 }".parse().unwrap(); let output = expand_describe(input); let output_str = output.to_string(); assert!(output_str.contains("impl Describe for Point")); }不过字符串断言比较脆弱,最好还是用trybuild做 UI 测试。trybuild可以编译一组测试文件,并把编译错误与.stderr快照做对比。它是验证宏报错信息的利器:
#[test] fn ui() { let t = trybuild::TestCases::new(); t.compile_fail("tests/ui/*.rs"); }在tests/ui/下放几个故意写错的例子,比如不支持 enum 却用了#[derive(Describe)],跑一次测试生成.stderr快照,之后每次改动宏如果导致报错信息变化,测试就会失败。更新快照的命令是:
TRYBUILD=overwrite cargo testUI 测试做起来繁琐,但它是「宏报错也是产品的一部分」这个理念落地的关键。没有它,你很难敢在版本迭代里动报错文案。
6.3 依赖 feature 克制与编译时间
过程宏本身会增加编译时间,尤其是syn这种重量级 crate。syn有两个常开的大 feature:full表示支持解析所有 Rust 语法,derive表示只需解析 derive 相关的输入。默认状态下,如果你只写#[proc_macro_derive],用features = ["derive"]就足够,完全不需要full。
我见过不少项目图省事,直接开full,编译时间凭空多出几十秒。正确做法是:先按最小 feature 写,报错了再按需添加。另外,syn和quote大版本升级往往伴随不小的 API 调整,宏 crate 的依赖锁最好盯紧一点。
还有一个冷门经验:宏展开会影响增量编译的缓存粒度。一个频繁使用的巨型派生宏,可能会让每个调用它的 crate 都重新编译。如果你的库用了很多过程宏,考虑把大宏拆小,或者提供「不使用宏的手写模板」作为退路。对上层用户来说,少一个宏调用,可能就是少几十秒等待。
6.4 几个容易翻车的细节总结
第一,生成代码里调用外部库时,别写裸路径。用$crate::(声明式)或完整路径(过程宏)写清楚。用户把他的 crate 改个名字、换一个依赖版本,宏生成的代码不能跟着崩。
第二,派生宏里别忘union。DeriveInput::data除了Struct和Enum,还有Union。如果不处理它,至少也要给出一个明确的报错,而不是让语法错误从你生成代码里冒出来。
第三,宏生成的标识符要防冲突。给辅助函数加__前缀,并标上#[doc(hidden)]。尽量不要生成fn helper()这种名字——用户随手定义了一个同名的局部函数,收获的就是殃及池鱼式报错。
第四,宏的版本兼容性要保守。Rust 每个版本迭代都可能调整语法的边缘情况,你在宏里用了不稳定的 token 形态,过几个版本就可能失效。我写宏的一个习惯是:先在最小 Rust 版本上跑一次 trybuild,再考虑「新写法很帅」的事。稳定压倒炫技。
最后再分享一个小技巧:当你在quote!里发现#数量和重复层级对不上时,别硬着头皮调。把目标代码拆成一个普通函数模板,先用真实类型跑通,再一步步替换成#变量。宏的本质是「把代码模板参数化」,模板本身必须是能编译通过的代码,这个底线先守住,后面的展开结果才不会离谱。我在实际写宏时,几乎总是先写一段手写的目标代码,再用宏替身替换,反复对比展开结果。先写对一次,再写宏,能省下至少一半的调试时间。