过程宏

12 分钟
本页目录

过程宏用 Rust 程序处理 token 并输出 token,适合声明宏难以表达的结构化解析和代码生成。它运行在宿主编译环境,输出才交给目标 crate 编译;它不是可以查询任意变量真实类型或 trait 实现的通用编译器插件。

调用者的 TokenStreamderive 输入类型;attribute 输入属性与被标注项1解析并检查语法结构syn 提供 AST;这里还没有任意类型推导结果2生成 TokenStreamquote 插值,保留泛型、约束与诊断位置3调用者继续编译生成项参与名称解析、类型与借用检查4宏执行环境与最终程序的目标平台可能不同。过程宏在宿主运行,输出代码进入目标 crate

三种宏如何改变调用位置

类别输入输出行为
#[derive(MyTrait)]被派生的 struct、enum 或 union在原定义后追加生成项,保留原类型定义
#[my_attr(...)]属性参数及被标注项两个 token 流替换被标注项,可输出多个项
my_macro!(...)分隔符内的 token 流替换宏调用,须符合所在语法位置

attribute 宏如果忘记把原函数输出,就会真的移除原函数;derive 不能靠输出一个同名新结构体修改原字段。宏也可能报错、panic 或陷入无限执行,应把错误输入的行为作为接口设计的一部分。Rust 过程宏规则

一个保留泛型的 derive 工程

创建 workspace 根 Cargo.toml:

[workspace]
members = ["hello-derive", "demo"]
resolver = "3"

hello-derive/Cargo.toml:

[package]
name = "hello-derive"
version = "0.1.0"
edition = "2024"

[lib]
proc-macro = true

[dependencies]
syn = "2"
quote = "1"

hello-derive/src/lib.rs:

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(Hello)]
pub fn derive_hello(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    if matches!(input.data, syn::Data::Union(_)) {
        return syn::Error::new_spanned(&input.ident, "Hello does not support unions")
            .to_compile_error().into();
    }
    let name = &input.ident;
    let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl();
    let expanded = quote! {
        impl #impl_generics crate::Hello for #name #type_generics #where_clause {
            fn hello_name() -> &'static str {
                ::core::stringify!(#name)
            }
        }
    };
    expanded.into()
}

demo/Cargo.toml:

[package]
name = "demo"
version = "0.1.0"
edition = "2024"

[dependencies]
hello-derive = { path = "../hello-derive" }

demo/src/main.rs:

trait Hello {
    fn hello_name() -> &'static str;
}

#[derive(hello_derive::Hello)]
struct Example<T> where T: Copy {
    value: T,
}

fn main() {
    let value = Example { value: 42_u32 };
    assert_eq!(value.value, 42);
    assert_eq!(Example::<u32>::hello_name(), "Example");
    println!("{}", Example::<u32>::hello_name());
}

运行 cargo run -p demo,应打印 Example。这个教学宏明确要求调用 crate 根存在 Hello trait;正式公共库应设计稳定的 trait 路径或可配置路径,并考虑依赖被重命名的情况,不要把这个约定隐含给所有用户。

为什么要拆开泛型三部分

impl、Example 和 where T: Copy 出现的位置与语法职责不同,不能把整个 generics 对象在所有位置重复插值。split_for_impl 为它们提供对应形式,并处理 impl 中不应重复的默认参数等细节。syn Generics

如果生成方法读取字段并要求 Display 等 trait,还需要添加恰当约束。不能为了方便给全部泛型参数一律加同一个 bound,字段可能使用 PhantomData、关联类型或不涉及该参数;应根据生成代码的真实需求设计。

示例使用 expanded 作为变量名;gen 在 Rust 2024 中是保留关键字,旧教程的 let gen 不能原样用于该 edition,除非写成原始标识符 r#gen。gen 关键字

同一个 derive,验证三类泛型与默认参数

保持 hello-derive 不变,把 demo/src/main.rs 替换为下面的完整程序。这个例子同时验证生命周期、类型参数、const 参数和 where 子句,并在子模块中使用宏。

trait Hello { fn hello_name() -> &'static str; }
mod model {
    #[derive(hello_derive::Hello)]
    pub struct Record<'a, T = u8, const N: usize = 2>
    where T: Copy,
    {
        pub name: &'a str,
        pub values: [T; N],
    }
}
fn main() {
    let name = String::from("sample");
    let record: model::Record<'_> = model::Record {
        name: &name, values: [1, 2],
    };
    assert_eq!(record.name, "sample");
    assert_eq!(record.values, [1, 2]);
    assert_eq!(<model::Record<'_, u8, 2> as Hello>::hello_name(), "Record");
}

生成的实现头在结构上相当于 impl<'a, T, const N: usize> crate::Hello for Record<'a, T, N> where T: Copy。impl 声明处不重复默认参数,应用类型处却必须传入 T 和 N;where 子句跟在类型之后。这三种位置的 token 不是同一段语法,直接反复插值整份声明可能生成非法代码。

这里根 trait 在子模块中仍由 crate::Hello 找到。如果将 trait 移到别处,就要同步改变生成路径或设计可配置入口;“子模块调用成功”不等于“任意依赖布局都自动适配”。

生成代码没用到字段,就不该强加字段约束

下面也是可替换的 demo/src/main.rs。Hello 只返回类型名,不读取或克隆字段,因此即使 T 没有 Clone、Copy 或 Display,实现也应该成立。

trait Hello { fn hello_name() -> &'static str; }
struct NoTraits;
#[derive(hello_derive::Hello)]
struct Wrapper<T>(std::marker::PhantomData<T>);
fn main() {
    let _value = Wrapper::<NoTraits>(std::marker::PhantomData);
    assert_eq!(Wrapper::<NoTraits>::hello_name(), "Wrapper");
}

若以后增加打印字段功能,需要约束真正被格式化的字段类型,而不是机械给所有 T 加 Display。比如字段为 <T as Trait>::Item 时,所需能力可能落在关联类型上;字段为 PhantomData 时,T 未必需要具备相同能力。宏作者要从生成方法实际执行的操作反推约束。

同一组泛型,为什么拆成三份?

默认参数是最容易暴露错误复制的位置:结构声明可以写 T=u8,impl 的参数声明却不应照抄默认值,Record 的应用位置只写 T。依次切换这些语法要素,观察 split_for_impl 解决的是位置相关的语法组织,而不是字段类型检查。

正在呈现知识画面
同一组泛型,为什么拆成三份?

impl 声明参数,类型应用传入参数,where 保留约束;默认值不重复写进 impl。

syn 与 quote 不是类型检查器

syn 解析语法树,quote 通过插值构造 token。看到字段类型别名 Alias,不代表宏能查询它最终等于什么类型;通过 token 字符串猜测类型也容易受路径、泛型和格式变化影响。

过程宏整体上是不卫生的,生成的名字和路径会受调用环境影响。优先使用合适的限定路径,避免与用户项冲突,并用输入 span 把错误指向相关声明。TokenStream 转成字符串再做字符串替换,通常会丢失结构和诊断质量。

辅助属性也需要完整语法契约

如果 derive 希望接受 #[hello(...)],必须在 #[proc_macro_derive(Hello, attributes(hello))] 中注册相应 helper attribute,并在解析代码中处理它。注册只是让编译器允许该属性存在,不会自动执行配置,也不会替宏验证它的位置或内容。

例如准备支持 #[hello(trait_path = "crate::api::Hello")] 时,应先定义以下行为;本篇上面的最小 Hello 实现尚未提供这个选项。

输入宏应明确决定的结果
未配置路径使用文档规定的默认路径
配置合法路径字符串解析为 syn::Path 后用于生成代码
未知键名指向该键并报告不支持的选项
重复指定路径拒绝重复或明确覆盖规则,避免偶然取最后一个
路径语法无效在输入字符串位置报告解析错误
属性写在不支持的位置明确拒绝,避免静默忽略

syn 的 parse_nested_meta 适合常见的键值/嵌套属性语法,LitStr 可以进一步解析为 Path;自定义 DSL 则可实现 Parse。不要先把整个属性转成字符串,再依靠逗号和等号 split,因为括号分组、转义和嵌套都会破坏这种解析方式。syn 2 属性解析

属性语法错误键名、重复键、参数格式不支持的结构例如 Hello 拒绝 union生成代码的类型错误由目标 crate 的类型检查发现优先保留输入 span,给出具体原因和可修改的位置把错误留在最了解它的阶段,并指向输入位置不要把所有失败都变成 proc macro panicked,也不要用字符串猜类型这三类检查的责任不同;宏能解析语法,不意味着它拥有类型检查器的全部信息。

crate 依赖方向

宏定义必须位于 proc-macro crate 根,不能在定义它的同一个 crate 中直接使用;可由独立使用者、集成测试等调用。该 crate 可以有私有辅助函数和模块,但不能像普通库一样导出任意公开运行时 API。

workspace 成员当然可以依赖另一个成员提供的过程宏,限制是依赖图不能成环。常见结构是运行时接口库与 derive 库分工,必要时由接口库再导出宏;生成的代码引用某条路径不等于宏 crate 自己必须依赖那个运行时 crate。

宏执行可以访问宿主文件系统等资源,所以构建应控制外部输入、避免依赖偶然的当前目录和不可追踪的环境变化。跨编译时,宏内 cfg 看到宿主配置;需要目标条件通常应生成目标代码中的 cfg,或采用明确传入的构建信息。

生成的目标路径与宏自己的构建依赖

若宏输出 ::runtime_api::Trait,这是要求使用者编译生成代码时能解析到该路径,并不表示宏自身正在调用 runtime_api 的代码。宏 crate 可以只依赖 syn/quote 来生成 token;运行时 API crate 再重导出 derive,也不必因此制造反向依赖环。

依赖重命名会改变使用方可见的 crate 名字。公共宏可以提供路径配置,或采用经过验证的依赖名解析方案;仅写死一个看起来绝对的路径,解决了部分名称冲突,却没有解决重命名问题。过程宏里也不能把声明宏专用的 $crate 当作通用路径补丁。

诊断与展开结果的用法

预期的错误输入宜用 syn::Error 等生成 compile_error!,保留位置和明确消息,不用 panic 充当所有错误的报告方式。上面的 union 会在类型名处给出拒绝原因。

cargo expand 是额外安装的工具,可帮助查看生成结构。它显示的是文本化展开结果,不能完整保留卫生性等编译器内部信息,因此输出不保证复制后行为完全等价,也不是最终汇编。cargo-expand

验证过程宏时至少检查简单类型、泛型及 where 子句、模块位置和不支持输入。只看到示例代码展开得“像一个 impl”,还不能证明生成库适合真实调用者。

错误样例要编译失败,成功样例要真的被调用

用例应验证的行为
普通 struct生成实现可调用,原字段仍存在
生命周期 + 类型 + const 泛型实现头与类型应用参数合法
默认泛型参数不把默认值复制到 impl 泛型声明
子模块中的类型生成路径按约定解析
泛型参数没有额外 trait不因宏生成而多出无关约束
union明确报 Hello does not support unions
trait 不存在或被改名不能假装生成代码已经通过名称检查

可以用专门的编译测试框架管理 pass/fail 用例,也可以在小型 workspace 中分别编译独立样例。诊断断言宜关注宏提供的核心消息和关键位置;完整 rustc 输出可能随版本变化,更新快照时应检查变化原因。

cargo expand 帮助检查“生成了什么”,运行样例检查“接口是否按预期工作”,失败样例检查“哪些输入明确不支持”。这三种证据互相补充;打印出漂亮的 token 并不足以保证泛型、路径和诊断在真实使用方成立。