Rust 过程宏:接口选择、展开与诊断责任
语法边界与三类接口
先掌握Rust的struct、trait、泛型和模块。这里从零定义一个实际用途:把审计记录中选定字段变为有序标签和值,派生Report实现,运行时调用report()。全部工程是供阅读与未来自跑的理论源码,本批未执行、未编译。不继承其他页面的执行结论。
过程宏三类接口
过程宏三类接口分别是函数式、derive与属性宏。函数式入口接收调用括号内的TokenStream,用输出替换整个调用;derive接收struct/enum/union定义,追加生成项;属性宏分别接收属性参数和被标注项,输出替换被标注项。因此derive适合追加trait实现,属性宏若忘记返回原项就会删除它;三者不是同一套输入协议。
#[proc_macro]入口是一个TokenStream参数,#[proc_macro_derive(Report, attributes(report))]声明derive及其辅助属性,#[proc_macro_attribute]入口则有两个TokenStream参数。本课程只实现derive;这不限制读者比较另外两类的输入/输出语义。helper属性是配置语法,本身不执行独立的属性宏。
过程宏编译期边界
过程宏编译期边界是语法输入与代码生成:它看见字段名、类型写法、泛型和属性,看不见运行时字段值,也不能通过syn获得完整类型推断结果。Alias可能是类型别名,宏不能靠拼写判断它实现了Display。生成代码后仍需名称解析、trait检查与借用检查;过程宏不能绕过私有性或让不满足约束的值凭空合法。
本例编译时只构造impl Report,真正将7_u32变为字符串是在消费程序调用report()时。宏程序运行在构建环境并具有该进程可访问的资源,不能把“只生成代码”当作沙箱。本工程有意不读取网络、文件、环境变量或时间,输出只由输入token决定。不要把目标平台的运行时能力等同于构建主机宏程序的能力。
过程宏与函数选择
过程宏与函数选择应从语法需求出发:函数负责已有值的计算,macro_rules负责token模式匹配,过程宏适合需要解析字段与泛型的派生代码。为每个对象调用一次通用函数通常比设计新DSL容易;只有维护者确实在重复编写字段级trait实现时,derive的语法检查与诊断成本才可能值得。
本例用derive避免每个审计结构手写字段列表,返回顺序固定为声明顺序。它不实现通用序列化协议、日志后端或安全脱敏保证;skip只是不把该字段加入报告,其他字段的Display仍可能输出敏感信息。选择字段是调用方的业务决策。
过程宏三层crate
过程宏三层crate把编译期宏实现、运行时trait与消费端隔开。report-derive的Cargo声明proc-macro = true,导出derive入口;report-runtime是普通库,定义Report trait并再导出同名宏;consumer只依赖runtime入口。trait在类型命名空间,derive在宏命名空间,可以同名。宏crate不能在定义自己的crate里使用该过程宏,所以消费端是独立成员。
依赖方向是consumer→runtime→derive;derive输出引用runtime路径的token,并不需要在其Cargo依赖里反向引用runtime。这样避免循环依赖。runtime的trait签名是&self到标签与String组成的Vec,借用记录且分配结果;需要零分配接口时应先重设计trait,不能靠宏掩盖接口成本。
设计练习
把需求改为“将被标注函数替换为计时包装,并保留原函数可见性”。说明为什么不能简单沿用本例derive入口;列出必须保留的函数签名信息,以及遇到async函数时需要重新定义的计时范围。本题只要求设计,不要求生成计时器代码。
参考解答
展开设计推导
应考虑属性宏,因为输入是函数项且需要替换函数体。需保留可见性、属性、名称、泛型、参数模式、返回类型和where条件,不能只抓函数名。async函数“创建future的时间”和“轮询至完成的时间”不同,必须先选定语义;若没有这项契约,不应声称包装后测得业务耗时。接口不变也不能证明行为不变。
资料与后续
2026-10-17阅读英文Rust Reference过程宏与中文Rust参考手册。中文版本可辅助理解,语言规则以英文现行Reference为准。接着阅读解析与展开,再看完整工程。
TokenStream、泛型与诊断位置
输入协议:只接受命名字段struct;可见字段需要Display,skip字段不参与输出。输出协议:追加一个Report trait实现,保留原始结构。以下解释对应完整同源源码,不表示已经展开或编译运行。
TokenStream解析链路
TokenStream解析链路从编译器的proc_macro进入,用syn的DeriveInput建立语法结构,再以proc-macro2承载生成结果。TokenStream是token序列,不是源文件字符串,更不是带有完整类型语义的编译器AST。入口的parse_macro_input处理语法失败;内部expand返回syn::Result,避免把用户错误变成panic。
本例分三步:先解析结构级report属性,再检查Data/Fields分支,最后逐字段收集标签与生成语句。明确拒绝enum、union、tuple和unit struct;空的命名字段struct可产生空报告。保留syn节点让错误对应输入位置,不能先to_string再按逗号切割,因为嵌套泛型、字符串和分隔符都可能含逗号。
helper属性冲突
helper属性冲突必须在生成前拒绝:重复rename、重复skip、skip与rename同用、空标签与重复标签都有明确错误。结构级只允许crate路径,字段级只允许skip或rename;未知key不静默忽略。parse_nested_meta处理嵌套键值语法,LitStr检查rename/path值是字符串,Path再检查crate字符串是路径语法。
配置状态跨同一字段的多条report属性累积,因而把两个rename拆成两条属性也不能绕过检查。跳过字段不会占用标签;另一个可见字段可使用被跳过字段的名字。原始标识符如r#type默认标签为type。空report列表在本例等同没有配置;它不会扩展成任意新选项。需要更严格风格时应先定义新的输入契约。
quote结构化展开
quote结构化展开通过插值与重复把syn节点放回token流,#name保留标识符,#(#rows)*依次追加字段语句。它不同于用format拼Rust源码:标识符、路径、字面量各自仍有语法结构。源码中的::std::format!只存在于生成的运行时函数里,用来显示字段值;它不是宏作者用字符串拼装impl的工具。
quote_spanned把新造语句的span关联到字段类型,插入的原有token仍有自己的来源。report()内的局部rows与self字段访问处于函数体作用域,不向调用模块额外注入通用名称。生成代码只借用&self.field,不会移动String或泛型字段;返回的String属于结果,调用者不需延长记录借用来持有结果。
泛型与where保留
泛型与where保留依靠clone原始Generics、make_where_clause追加谓词,再用split_for_impl区分impl参数和类型实参。已有生命周期、const泛型与where条件都应保留;不能把<T: Clone>原样放到类型应用位置,也不能丢失默认参数在impl中的语法差异。本例只向非skip字段的完整类型追加Display约束。
AuditRow原有T: Clone,输出另加&'a str: Display和T: Display,并保留const N: usize。secret是[u8; N]且被skip,所以不给它追加Display;对所有T统一加Display会无端限制仅出现在跳过字段里的类型参数。宏不预判字段类型是否实现trait,最终由编译器根据生成谓词判断。与原有手写Report实现冲突时,编译器会拒绝重复impl。
依赖重命名路径
依赖重命名路径通过显式report(crate = "::renamed_report")处理,默认路径是::report_runtime。过程宏输出受调用处名称解析影响,绝对std路径避免调用模块自己的Vec、String或format遮蔽标准库;runtime路径则必须与消费端的依赖名字一致。语法正确的Path并不保证该模块存在或导出了正确trait。
本工程不引入自动发现依赖名的工具;改Cargo别名时必须同时更新helper属性。宏内部没有类似macro_rules的$crate捷径来自动指向另一个runtime crate。本例面向std程序,不声明no_std兼容;若改成core/alloc接口,还需重新定义分配器与可用路径条件。
诊断span责任
诊断span责任分为输入配置错误与生成代码类型错误。meta.error指向错误key,new_spanned关联字段或字面量,into_compile_error把错误变成正常编译诊断;类型不满足Display则由编译器在生成的where谓词和关联字段处报告。span改善定位,不会替宏证明类型正确,也不承诺不同工具链逐字相同的输出。
本例采用遇到第一个宏错误就返回的策略;适合教学且不会继续生成半合法impl。若产品需要一次展示多个独立字段错误,可以用syn::Error::combine累积,但先保证每个错误有明确来源。本例不使用不稳定诊断API,不把panic当作无效输入的常规反馈。
展开练习
手工写出struct Wrapper<T> { #[report(skip)] cache: Vec<T>, value: Option<T> }的impl头,假定runtime已正确引用。说明为什么直接加T: Display不足以保证该字段可格式化,并给出一种保持原trait接口的输入修改。
参考解答
展开约束推导
头部应类似impl<T> runtime::Report for Wrapper<T> where Option<T>: ::std::fmt::Display;cache不给约束。标准Option不因T实现Display就自动实现Display,因此这样的泛型impl不能为通常的Option实例提供可用报告。可把value改成业务newtype并由业务明确实现Display,或把该字段skip;不能让宏偷偷改成Debug并宣称接口一致。
作者API资料
2026-10-17核对syn解析库、Generics、ParseNestedMeta、syn::Error、quote、quote_spanned与proc-macro2。latest页面会变化,当前检索syn页面标识3.x;另核对固定syn 2.0.108泛型源码、属性解析源码与proc-macro2 1.0.103源码。quote固定版本专页未被浏览工具成功取回,API阅读不等于依赖解析或版本兼容性已编译验证。
继续阅读
- 排错
- 排错
- 排错