ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Rust派生宏完全指南:从编译原理到自定义实现

Rust派生宏完全指南:从编译原理到自定义实现 写派生宏Derive Macro之前我先说句实话这玩意儿是 Rust 里最容易被低估、也最容易劝退新人的一块。很多人第一次看到#[derive(Serialize)]或者#[derive(Debug)]以为就是编译器魔法点两下就完事。但当你真正需要给自己的类型实现一个自定义派生宏时你面对的不再是“写业务代码”而是“写生成代码的代码”思维层次直接翻了一倍。这篇东西就是要把这层窗户纸捅破讲清楚派生宏在编译过程中到底发生了什么、由谁触发、怎么执行以及你会用到的那些工具syn、quote、proc_macro2各自的角色。适合谁看已经用过derive但好奇背后原理的人想手写第一个过程宏但不知道从哪下手的 Rust 开发者以及被各种宏报错折磨到怀疑人生的朋友。我会尽量把每一步掰开揉碎包括那些官方文档里含糊其辞的坑。我默认你至少写过一点 Rust知道 struct、trait 和 impl 的基本语法但不需要你写过任何宏。1. 先从全局看派生宏到底是个什么工种1.1 为什么需要派生宏它和普通宏差在哪你写#[derive(Clone)]的时候本质上是让编译器替你生成一份impl Clone for MyStruct的代码。但你有没有想过为什么编译器“知道”怎么生成因为在标准库里Clone这个 trait 被标记为一个可以自动推导的 trait编译器内置了对应逻辑。Rust 标准库里的 Debug、PartialEq、Eq、PartialOrd、Ord、Hash、Default 都属于这类“语言内置派生”。但问题来了标准库之外的类型比如你自己定义的一个#[derive(MyTrait)]编译器根本不认识MyTrait更不知道该怎么生成实现。这时候就需要你亲手写一个过程宏procedural macro告诉编译器“当你看到#[derive(MyTrait)]时请调用我写的这段代码我给你一段 TokenStream你把它展开成真正的 impl 代码。”这里有个关键区别声明式宏macro_rules!是“文本替换”级别的匹配你写什么匹配规则就替换成什么模式匹配对象是 token 序列而过程宏接收的是一整个语法树级别的 TokenStream你可以对它做任意复杂的分析、遍历、重组。派生宏是过程宏三大类里最特殊的一种它不改变原有类型定义只在旁边“添加”额外的实现代码。1.2 过程宏家族的三大成员derive、attribute、function-like要真正理解派生宏你得先把过程宏家族的三个兄弟分清楚派生宏Derive Macro通过#[derive(SomeTrait)]挂在 struct、enum、union 上。输入是“被装饰的类型定义本身”输出是“额外的 impl 代码”。它最常见的特征是输入和输出都围绕一个类型且不会影响原类型的可用性。属性宏Attribute Macro如#[serde(rename_all camelCase)]、#[tokio::main]。输入是一整个 item可能加参数输出可以是替换后的新 item。它可以包裹函数、结构体、模块甚至整个文件。函数式宏Function-like Macro如vec![...]、format!(...)。调用方式像函数输入是任意 token 流输出也是任意 token 流。三者在底层的实现机制完全一样都是“TokenStream 进TokenStream 出”只是调用的“语法槽位”不同。派生宏之所以单独拎出来讲是因为它有一套额外的约定可以声明helper attribute可以给enum的每个变体附加数据而且它被调用的时机是在类型解析之后、展开到最终代码之前这个时机窗口决定了它能用哪些信息、不能碰哪些信息。1.3 编译流程里的一小块宏展开发生在哪里我把 Rust 编译过程简化成四个阶段词法分析、语法分析AST 构建、宏展开、类型检查和代码生成。宏展开发生在 AST 构建之后、类型检查之前。这意味着一个关键事实你在派生宏里拿到的 TokenStream是没有经过类型检查的类型定义。编译器把struct Foo { name: String }的 token 流给你但它不会告诉你String在当前的命名空间里到底解析到哪个类型。这就是为什么派生宏里的所有路径解析都要格外小心后面我会详细讲这个坑。另外一点宏展开的过程是自底向上、逐层递归的。编译器先展开内部最深的#[derive]再逐步往上展开外层 item。如果你的派生宏生成了新的代码而这些新代码里又含#[derive]那编译器会在同一轮展开后重新进入宏展开流程继续递归。这也是为什么过程宏的性能会比较敏感每个#[derive]都是一次独立的展开写得太复杂就会拖慢编译。2. 拆开来看三个 crate 的分工与协作2.1 proc_macro官方 API 与 TokenStream 的本质proc_macro是 Rust 编译器提供的官方 crate也是唯一一个只能在过程宏 crate 里使用的 crate。它定义了宏入口函数的签名#[proc_macro_derive(MyTrait)] pub fn my_trait_derive(input: TokenStream) - TokenStream { // input 是原类型定义的 token 流 // 返回值是追加的 token 流 }这个TokenStream本质上是token 的有序集合一个 token 是词法分析后的最小单元标识符、关键字、字面量、标点符号、分组Group。但这里有个让人难受的事实官方proc_macro::TokenStream的 API 极其简陋你几乎没法方便地遍历、修改它连逐个 token 取出来都要先转成proc_macro2::TokenStream。所以实际开发中几乎所有人都会依赖两个辅助 cratesyn和quote。这里有件特别容易忽略的事proc_macro::TokenStream和proc_macro2::TokenStream虽然名字像但它们不是同一个类型。在 proc-macro crate 里函数签名要求用官方的proc_macro::TokenStream而 syn 和 quote 操作的是proc_macro2::TokenStream。所以标准的写法是#[proc_macro_derive(MyTrait)] pub fn my_trait_derive(input: proc_macro::TokenStream) - proc_macro::TokenStream { let input proc_macro2::TokenStream::from(input); // 用 syn 解析 input // 用 quote 生成 output proc_macro::TokenStream::from(output) }每次转换看起来都是在“复制”但实际上是零拷贝还是深拷贝完全取决于编译器实现和默认 feature 配置不必深究。你只需要记住入口出口都用proc_macro::TokenStream中间处理都用proc_macro2。2.2 syn把 TokenStream 变成结构化语法树syn存在的意义是把一团零散的 token 解析成结构化的 Rust 语法树节点。你不再需要手动判断“这个位置是不是 struct 关键字”、“后面是不是{”syn 直接帮你解析成DeriveInput这个类型。use syn::{DeriveInput, parse_macro_input}; #[proc_macro_derive(MyTrait)] pub fn my_trait_derive(input: proc_macro::TokenStream) - proc_macro::TokenStream { let input parse_macro_input!(input as DeriveInput); // input.ident 类型名 // input.data struct / enum / union 的具体数据 // input.attrs 所有 #[...] 属性 // input.generics 泛型参数 let output generate_impl(input); output.into() }DeriveInput就是 syn 为“所有能被 derive 挂载的 item”准备的标准结构。它包含四块attrs属性列表、vis可见性、ident类型名、generics泛型参数、dataDataDataStruct、DataEnum、DataUnion 三选一。大多数派生宏的核心逻辑就是匹配Data的三种情况然后遍历字段或者变体。syn 的解析看起来方便但它有一个隐藏的“体量”问题syn 是个相当大的 crate编译很慢。很多对编译时长敏感的项目会把 syn 的features裁剪到最小。比如你只处理 struct 不处理 enum可以关掉full特性只开derive。这个优化我们在后面实操环节会具体演示。2.3 quote把 token 模板“印”出来syn负责读quote负责写。quote!宏让你能用近似 Rust 源码的语法直接拼装 token 流use quote::quote; fn generate_impl(input: DeriveInput) - proc_macro2::TokenStream { let name input.ident; quote! { impl MyTrait for #name { fn describe() - String { Hello from MyTrait.to_string() } } } }#name这种写法不是普通的字符串插值而是把name这个Ident直接“拼接”为 token 流里的一个标识符。quote 的工作方式是遍历你写的模板把#(...)内部的内容展开为 token再拼接到输出流中。可以把它理解为“有类型的模板引擎”。需要用#(...)来插值一段代码比如循环生成字段匹配分支。后面实操部分你会频繁用到这个。quote 还提供一个format_ident!宏用于动态生成标识符比如get_#field_name这种名字。2.4 proc_macro_crate 与工程配置它们之间的“官方约定”写过程宏时你的 crate 必须设置proc-macro true。这个设置在 Cargo.toml 里属于“特殊形态”的 crate它只能导出过程宏不能导出其他普通函数或类型供外部调用。它还限制了你能导入什么依赖所有依赖必须是 proc-macro 生态兼容的比如syn、quote都是。[package] name my-derive version 0.1.0 edition 2021 [lib] proc-macro true [dependencies] syn { version 2.0, features [full, extra-traits] } quote 1.0 proc-macro2 1.0注意[lib] proc-macro true意味着不能再用 lib 里其他模块公开非宏的东西但宏内部的私有模块没问题你只是不能pub use普通函数而已。还有一件常被忽略的事proc-macro crate 的目标平台是 host运行编译器的机器不是 target编译目标。所以不能在里面写任何依赖目标架构的代码。更不要试图在过程宏里直接引用你主 crate 的类型——过程宏 crate 和主 crate 在编译阶段是完全隔离的宏只是“一段生成代码的程序”运行时发生在编译器的进程里而生成的结果代码在目标程序里。这个边界想清楚很多困惑会迎刃而解。3. 动手实操写一个能用的派生宏3.1 目标实现一个简易版的 Debug 派生宏理论说太多没用我带你写一个真正能跑的派生宏。我们做一个MyDebugtrait效果类似标准库的Debug但输出格式是“字段名: 值”的形式。先定义 trait 本身放在主 crate// 主 crate 里定义一个 trait pub trait MyDebug { fn my_debug(self) - String; }然后我们写一个派生宏#[derive(MyDebug)]让用户只要写#[derive(MyDebug)] struct Point { x: i32, y: i32, } let p Point { x: 1, y: 2 }; println!({}, p.my_debug()); // 输出: Point { x: 1, y: 2 }这个例子麻雀虽小五脏俱全需要解析 struct 字段、需要处理字段名和字段类型、需要拼装String格式化的代码、还需要考虑泛型参数。完整过程如下。3.2 搭建工程与配置步骤分成两个 crate主 crate应用代码和宏 crateproc-macro。为什么要分开因为 proc-macro crate 的代码不能直接被同一个 crate 内部引用这个过程宏的入口函数不能用普通函数调用的方式来调用它是被编译器在编译期“按名字”找出来的。混在一起会直接影响编译流程。宏 crate 目录结构myderive/ ├── Cargo.toml └── src/ └── lib.rsCargo.toml 内容[package] name myderive version 0.1.0 edition 2021 [lib] proc-macro true [dependencies] syn { version 2.0, features [full] } quote 1.0 proc-macro2 1.0如果只处理 struct 不考虑 enum其实还可以更精简把syn的 features 改成[derive, parsing, printing, clone-impls, proc-macro]。但对于教程先开full省心实践时再根据编译时间优化。主 crate[package] name app version 0.1.0 edition 2021 [dependencies] myderive { path ../myderive }3.3 核心实现逐层解析 DeriveInput 并生成 impl先写最基本的框架解析输入匹配 struct然后生成代码。注意暂时只处理struct遇到 enum 或 union 就报一个友好的编译错误use proc_macro::TokenStream; use quote::quote; use syn::{parse_macro_input, DeriveInput, Data, Fields}; #[proc_macro_derive(MyDebug)] pub fn my_debug_derive(input: TokenStream) - TokenStream { let input parse_macro_input!(input as DeriveInput); let name input.ident; // 生成字段表达式列表 let field_exprs match input.data { Data::Struct(data) match data.fields { Fields::Named(fields) { // 命名字段 fields.named.iter().map(|f| { let field_name f.ident.as_ref().unwrap(); quote! { let value format!({:?}, self.#field_name); parts.push(format!({}: {}, stringify!(#field_name), value)); } }).collect::Vec_() } Fields::Unnamed(fields) { // 元组结构体用 _0, _1, ... 作为字段名 (0..fields.unnamed.len()).map(|i| { let idx syn::Index::from(i); quote! { let value format!({:?}, self.#idx); parts.push(format!(_{}: {}, #idx, value)); } }).collect::Vec_() } Fields::Unit Vec::new(), }, Data::Enum(_) | Data::Union(_) { return syn::Error::new_spanned( input.ident, MyDebug 暂时只支持 struct ).to_compile_error().into(); } }; let expanded quote! { impl MyDebug for #name { fn my_debug(self) - String { let mut parts Vec::new(); #(#field_exprs)* format!({} {{ {} }}, stringify!(#name), parts.join(, )) } } }; TokenStream::from(expanded) }这个代码看着简单里面藏了不少门道。#(#field_exprs)*是反复拼接的语法把field_exprs里的每段 token 依次放入模板相当于循环展开。注意每个field_exprs的元素都是完整的语句所以最后会生成一串parts.push(...)调用。Fields::Named分支里field_name是OptionIdent但因为是 Named所以一定存在用unwrap()没问题。Fields::Unnamed分支用syn::Index::from(i)生成元组结构体的索引字段。Fields::Unit就是struct Foo;没有任何字段直接生成空列表。stringify!(#name)这个宏把类型名转换成字符串字面量比name.to_string()更高效也能直接在format!里使用。3.4 泛型支持一个常被漏掉的细节上面的代码没有处理泛型意味着struct WrapperT { inner: T }会编不过。因为生成出来的 impl 块里根本没有声明T这个泛型参数。正确处理泛型的方法是用input.generics里的split_for_impl()方法let (impl_generics, ty_generics, where_clause) input.generics.split_for_impl(); let expanded quote! { impl #impl_generics MyDebug for #name #ty_generics #where_clause { // ... } };这三个值分别是impl块里用的泛型参数列表带 bound、类型后面加的参数列表、where 子句。用它们拼进模板才能生成如下合法的代码implT MyDebug for PointT where T: MyDebug { fn my_debug(self) - String { ... } }还有一个细节如果T本身不是MyDebug那你在my_debug里用format!({:?}, self.inner)就会报错。业界常见做法是给 impl 块加一个追加的 where 约束where T: MyDebug。想做成“自动加约束”可以让split_for_impl()返回的impl_generics和ty_generics分别配合quote!加上额外 bound。完整写法可以这样let mut generics input.generics.clone(); generics.make_where_clause().predicates.push(syn::parse_quote! { T: MyDebug }); let (impl_generics, ty_generics, where_clause) generics.split_for_impl();这样最终的 impl 就会带上T: MyDebug的约束。对于多类型参数的 struct需要逐个遍历泛型参数并添加约束这里不再展开但思路完全一样。3.5 helper attribute给派生宏传参数的手段有些派生宏需要接受额外参数比如#[derive(MyDebug)]也许想支持#[my_debug(skip)]跳过某个字段。这个过程宏中有一个叫helper attribute的机制。在#[proc_macro_derive(MyDebug, attributes(my_debug))]中声明 helper attribute 后用户就能在字段上写#[my_debug(skip)]而这些属性会出现在field.attrs中。比如#[proc_macro_derive(MyDebug, attributes(my_debug))] pub fn my_debug_derive(input: TokenStream) - TokenStream { // ... }然后在解析字段时fields.named.iter().filter(|f| { !f.attrs.iter().any(|attr| attr.path().is_ident(my_debug)) })这样就能在生成的代码里跳过被标记的字段。helper attribute 本质上不会对原类型产生任何影响它只是让用户在类型/字段上可以写这个属性语法上合法而已。这个机制是派生宏相对其他两种过程宏最有特色的能力因为属性宏只能装饰在 item 级别而 helper attribute 可以细粒度到字段、变体。4. 报错排查经验十个遇到的坑五个能毁掉一天4.1 类型解析时的绝对路径陷阱::core::fmt::Debug我之前写过“给字段类型加约束”的逻辑结果生成的代码在用户的 generic 上爆了一堆 borrow checker 错误。后来我意识到问题出在Debug的路径解析上。你生成的代码会被插入到用户 crate 的模块上下文中但你不能假设用户的 crate 里有没有一个叫Debug的本地 trait或者他 use 了哪个Debug。标准做法是在生成代码里引用 trait 时用::core::fmt::Debug或::std::fmt::Debug取决于是否关心 no_std。同理你生成的代码里所有来自你宏 crate 的类型、trait也要用完整的::myderive::MyDebug路径而不是裸写MyDebug。举个例子前面 3.3 节代码里impl MyDebug for #name就埋了一个雷如果用户 crate 里定义了一个叫做MyDebug的本地模块这个 impl 会解析错误。更稳的写法是引用你宏 crate 的全路径quote! { impl ::myderive::MyDebug for #name { ... } }当然这样做的前提是你的宏 crate 和主 crate 的包名你都知道。在宏 crate 内部你要拿到自己包名可以用proc_macro_crate::crate_name(myderive)这个工具它在一些 monorepo 和改名的场景下很有用。但这个属于进阶技巧不细说了。4.2 TokenStream 生命周期与to_compile_error()在宏里生成错误信息的方式不是panic!也不是println!而是返回一个“编译错误 token”。syn 提供了syn::Error::new_spanned来创建然后用to_compile_error()转成 TokenStream 返回。return syn::Error::new_spanned( input.ident, MyDebug 暂时只支持 struct ).to_compile_error().into();这样用户就能在编译时看到这个错误并且错误位置还能指向出问题的类型名。千万不要 panicpanic 会让用户看到一堆内部 panic 信息和宏展开栈对定位问题毫无帮助。4.3 遇到的哪些问题可以靠cargo expand一眼看穿宏写完了怎么调试最有效的手段是看展开结果。安装cargo-expand底层调cargo rustc -Zunprettyexpanded然后运行cargo expand能看到你的宏实际生成了什么代码。这是调试宏的黄金工具比任何 print 调试都强。找问题的时候顺便提一句宏展开后的代码往往很长不要试图全看找报错的行号对应的位置往回追溯你的quote!模板哪部分生成得不对。常见的问题模式生成的代码里多了一个分号导致空语句format!拼接出的字符串少了个转义#(...)*的嵌套层级搞错把两层的 token 直接内联导致语义全歪。这些靠肉眼看展开结果都能秒懂。4.4 多次展开与递归宏的注意点如果你的宏生成代码里又包含了#[derive(OtherTrait)]那编译器会在宏展开后的第二轮继续展开。这意味着你可以在宏里“生成宏”但要注意两点性能每一层递归都会增加编译时长写太复杂的宏链会让编译显著变慢。展开顺序编译器并不保证多个#[derive]之间的执行顺序它们在同一轮内是并行的、顺序不定的。不要在生成代码里依赖另一个 derive 的什么副作用。4.5 名称冲突与 hygiene卫生性问题Rust 的宏在 token 层面有卫生性hygiene机制但过程宏的卫生性比macro_rules!弱你在quote!里写的局部变量名比如parts、value是有可能跟用户代码产生冲突的。只不过因为你在类型内部生成一个函数局量作用域通常很窄最常见的冲突发生在你生成了一个辅助函数比如fn __my_debug_helper()而用户模块里恰好也有这个函数。你在quote!里的变量名和字段名一模一样。解决方式用不易冲突的前缀命名比如__mydebug_parts。或利用quote!的format_ident!(__mydebug_{}, field_name)产生“带有混合站点 hygiene 的标识符”。所谓 hygiene 就是编译器对“这个标识符属于哪个宏展开位置”的追踪但过程宏里实际有效的还是避免撞名别再依赖 hygiene 兜底。4.6OptionIdent与Index的边界细节在解析字段时Fields::Named的每个Field里ident是OptionIdentFields::Unnamed是None。很多人上手就写field.ident.unwrap()结果在元组结构体上直接 panic。正确做法是先匹配Fields::Named再unwrap()匹配Fields::Unnamed时用位置索引_0,_1。永远不要在解析字段前假设结构体是命名字段。Rust 的结构体有三种形态尤其是struct Foo(i32)和struct Foo {}长得高度相似容易写岔。4.7 通用错误速查表症状原因解决编不过且报错在宏内部通常是quote!模板拼出的代码结构非法用 cargo expand 查看实际生成代码报错找不到 trait/类型裸写类型名没带全路径改成::crate_name::TraitNamegeneric 类型无法调用 trait 方法impl 泛型缺 boundmake_where_clause 添加约束字段名解析成变量误把self.field_name写成field_name检查拼接时的前缀enum 匹配不上没处理所有变体用 match 穷尽处理或用_ 报错编译速度突然很慢syn 的 full feature 拖累裁剪 featureshelper attribute 不生效忘了在proc_macro_derive里声明 attributes检查声明语法5. 工程级经验如何让派生宏更好维护5.1 解析逻辑和生成逻辑分离我在生产项目里看到的宏常见问题是“解析和生成混在一起”一个函数里既遍历字段又立刻 quote 拼模板。小宏没问题宏一旦复杂起来就乱。我的建议是分层第一层把 DeriveInput 转换成自己的中间结构比如叫MyTypeInfo包含字段名、字段类型、是否跳过等第二层再根据这个中间结构生成 TokenStream。好处是你可以单独为中间结构写单元测试用syn::parse_quote!快速构造输入验证解析逻辑是否正确生成逻辑也能单独测试输出是否含有特定代码片段。struct MyTypeInfo { name: syn::Ident, fields: VecMyFieldInfo, } struct MyFieldInfo { name: FieldName, // Named 或者 Index skip: bool, }这样宏的代码量虽然增加了但可维护性显著提升。尤其是当你后续要支持 enum、支持泛型别名、支持属性参数时没有中间结构就是一场灾难。5.2 单元测试和 UI 测试proc-macro crate 的单元测试可以直接用proc_macro2::TokenStream做输入不需要真的去调编译器。syn::parse_quote!可以写let input: DeriveInput syn::parse_quote! { struct Point { x: i32, y: i32 } };然后调用你的解析函数断言生成的MyTypeInfo。这是便宜又快速的测试方式。更保险的是UI 测试即编译一段故意出错的代码断言编译器输出的错误信息符合预期。trybuild这个 crate 专为这种场景设计。它能捕获编译错误信息跟期望的.stderr文件对比非常适合测试宏的错误分支。很多知名 crate 都在用它比如serde、thiserror。5.3 编译时长优化精简 syn 的 featuresyn 是个重量级 crate全量编译可以拖到两三秒以上。对于 proc-macro crate你可以精简特性如果你的宏只处理 derive 场景用features [derive]这个组合只包含解析DeriveInput所需的部分。如果你需要处理 attribute 参数里的表达式再加features [parsing]。如果不需要完整语法的所有节点别开full。全文只处理DeriveInput、Fields、Attribute这些核心类型的话derive就够了。具体取舍要结合你的宏需求。反模式纯粹因为懒就开 full会让依赖这个宏的每个项目的编译时间都多出不少。一个大型 monorepo 里一个胖 syn 依赖会被多级传播影响面比你想象的大。5.4 版本兼容与文档注释过程宏 crate 一旦发布改接口要谨慎。用户可能在编译器升级后遇到你生成的代码在新版本里不合法的情况所以版本要严格控制。另外你的宏 crate 最好在 lib.rs 顶部写清楚“它能处理什么、不能处理什么”尤其要写明对 enum 和 union 的支持程度。用户写#[derive(MyDebug)]之前不会去看源码全靠文档。实际上我见过太多人因为文档里没说“不支持 enum”硬用宏然后对着编译错误折腾半天。写清楚省得你开 issue。6. 从编译期到运行期派生宏带来哪些思维转变写完一个派生宏你会发现一个事实你写的代码运行在两个不同的世界里。一个是编译器内部的“宏执行世界”你的 Rust 代码proc-macro crate 本身在编译时运行这个世界的输入是源码的 token输出是一段新的 token。另一个是最终用户的“运行时世界”你生成的impl代码在这里执行。这两个世界通过TokenStream交互而你写的宏代码本身永远不出现在最终的程序里。这个思维转变是学习派生宏最大的坎也是最大的乐趣。当你习惯了“写代码来生成代码”你会发现很多重复的模式可以被优雅地消灭#[derive(Builder)]可以自动生成 Builder 结构体#[derive(Error)]可以自动生成Display实现#[derive(From)]可以生成类型转换。有一个实践心得每次写宏之前先手写一份展开后的代码照着它来设计 API。我就吃过亏先设计“宏长什么样”结果发现生成的代码必须要处理很多边界情况不得不反复修改宏逻辑。正确的路径是先想清楚“最终 impl 长什么样”再倒推需要的输入信息和拼接方式。这个顺序反了写宏就变成了不断和借用检查器搏斗的过程。派生宏不是银弹。它适合“同一个 trait 在很多类型上重复实现”的场景不适合“每个类型的实现差异很大”的场景。如果每个类型的 impl 都不一样用宏生成的代码只会变成一堆 attribute 参数和 if 分支维护成本会急速上升。界定的标准很简单你能用一句话说清“这个 trait 对任意类型的实现规则”吗能就做派生宏要绕三句话就别做。规则模糊意味着宏逻辑也会模糊测试也会模糊。最后分享一个我自己常用的土办法在写宏之前先用macro_rules!给两三个具体类型手写一遍模板再用代码生成把它泛化。很多关于“哪些部分必须由用户提供、哪些部分可以由宏推导”的决策在这个粗糙阶段就会暴露出来。泛化完成后再迁移到真正的过程宏折腾成本会低得多。写宏这事本质上是在替编译器打工。你干得越漂亮使用者就越感觉不到宏的存在——就像#[derive(Debug)]一样大家早就忘了编译器内部还有这么一套完整的推导机制。而你现在知道了这条路可以从#[derive]一直通到一套属于你自己的小型编译器插件系统。希望这篇文章能帮你省掉几个深夜排查的时间。
返回列表