阶段三 · 元编程与底层

过程宏与派生宏

一句话总结

过程宏是"用 Rust 代码生成 Rust 代码"的程序:它接收一段代码的 token 流,运行编写好的逻辑,输出新的 token 流.
天天用的 #[derive(Debug)],#[tokio::main],serde 的派生,背后全是过程宏.

前置回顾

上一篇讲的声明宏是"模式匹配 + 模板替换",能力受限于模式.当需要读取结构体字段,生成 trait 实现等更复杂的代码生成时,就得升级到过程宏.本篇讲清它的工作原理和标准开发套路.

与声明宏的本质区别

声明宏是"模式匹配 + 模板替换",能力受限于模式.过程宏则完全不同,它是一个真正会被编译执行的函数,输入和输出都是代码本身.

维度 声明宏 macro_rules! 过程宏 procedural macro
编写方式 声明匹配规则 编写处理 token 的 Rust 函数
能力 模式替换 任意逻辑:遍历字段,生成 impl
所在位置 普通代码中即可定义 必须放在独立的 crate
典型代表 vec!,println! #[derive(...)],serde

声明宏像是"填空模板":结构早已固定,只能把值填进预留的空格.
过程宏则是雇了一位程序员助手,把一段代码递给他,他能读懂结构,思考逻辑,再亲手写出一段全新的代码交还.

三种过程宏

过程宏按"如何被调用"分为三类,各自解决不同问题.

// 1. 派生宏 (derive macro):为结构体/枚举自动实现特征
#[derive(Debug, Clone)]
struct User { name: String }

// 2. 属性宏 (attribute macro):自定义属性,可改写它修饰的项
#[route(GET, "/users")]
fn list_users() { }

// 3. 函数式过程宏 (function-like):看起来像声明宏,但内部是过程逻辑
let query = sql!(SELECT * FROM users WHERE id = 1);
类型 调用形式 典型用途
派生宏 #[derive(MyTrait)] 自动实现特征(最常见)
属性宏 #[my_attr] 路由注册,改写函数(如 #[tokio::main])
函数式 my_macro!(...) 嵌入式 DSL,编译期校验

核心:TokenStream 进,TokenStream 出

所有过程宏的签名都围绕一个类型:TokenStream--它代表一串 Rust token(代码被切分后的最小语法单元).过程宏的工作本质就是一个 TokenStream → TokenStream 的转换.

源代码                 过程宏函数               展开后的代码
#[derive(Debug)] ┌─────────────────┐ impl Debug for User {
struct User {...} │ fn(TokenStream) │ ──► fn fmt(...) {...}
│ │ -> TokenStream│ }
└──────────►│ 解析→生成 │
└─────────────────┘
输入 token 流 运行逻辑 输出 token 流
use proc_macro::TokenStream;

#[proc_macro_derive(Hello)]
pub fn derive_hello(input: TokenStream) -> TokenStream {
// input 是被 #[derive(Hello)] 修饰的那段代码的 token 流
// 返回值是要追加到程序里的新代码的 token 流
todo!()
}

过程宏必须独立成 crate

过程宏需要在编译"使用它的代码"之前就被编译成可执行的逻辑,存在先有鸡还是先有蛋的次序问题.Rust 的解法是:过程宏必须放在一个单独的 crate,并在 Cargo.toml 里标注 [lib] proc-macro = true.这个 crate 在编译期作为编译器的"插件"运行.

两大基石:syn 与 quote

直接操作裸 TokenStream 极其痛苦.生态里有两个几乎必用的 crate,把过程宏的开发变得可行.

syn -- 解析器:把 TokenStream 解析成结构化的语法树(AST),让开发者能方便地访问结构体名,字段,字段类型等.

quote -- 代码生成器:用类似模板的语法写出要生成的代码,并产出 TokenStream.用 #变量 把数据插值进模板.

syn 是"读",quote 是"写":syn 像把一篇文章拆解成主谓宾的语法分析器,quote 则像按提纲填词造句的写作工具.
过程宏的典型流程就是 syn 读进来 → 处理 → quote 写出去.

动手看一个派生宏

下面是一个完整的派生宏,为任意结构体自动实现一个打印自身类型名的 Hello 特征.它浓缩了过程宏的标准套路.

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

#[proc_macro_derive(Hello)]
pub fn derive_hello(input: TokenStream) -> TokenStream {
// 1. syn 解析输入,拿到结构化的语法树
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident; // 结构体名,如 User

// 2. quote 生成新代码,#name 处插值
let expanded = quote! {
impl Hello for #name {
fn hello() {
println!("Hello from {}!", stringify!(#name));
}
}
};

// 3. 转回 TokenStream 返回
expanded.into()
}

使用方接下来就能这样写:

#[derive(Hello)]
struct User;

User::hello(); // 输出:Hello from User!

这正是 #[derive(Debug)] 的真相

标准库的 #[derive(Debug)] 做的事和上例同构,只是更复杂:它用 syn 解析出结构体的每一个字段,再用 quote 生成一个遍历打印所有字段的 fmt 方法.

理解了这个套路,就明白了为何 Debug 能自动派生而 Display 不能--前者有确定的字段遍历规则,后者的"美观格式"无法被程序推断.

什么时候才该写过程宏

过程宏威力巨大,但开发成本高,会拖慢编译,报错也更晦涩.它不是日常工具,而是消除特定重复样板的重武器.

优先级:普通代码 → 声明宏 → 过程宏

遇到重复代码时,按这个顺序逐级升级:能用函数或泛型解决就别碰宏;模式化的语法重复用声明宏;只有当需要读取类型结构,遍历字段,生成 trait 实现这类声明宏做不到的事时,才动用过程宏.

框架作者(如 Web 路由,序列化库)是过程宏的主要编写者;应用开发者更多是使用它们而非编写.

代价提醒

引入过程宏会显著增加项目的编译时间(syn/quote 本身就是较大的依赖),且宏内部的逻辑错误难以调试.在动手前先确认:这段样板真的多到值得一个过程宏?很多时候一个 macro_rules! 或一个泛型函数就够了.

快速回顾

  • 过程宏 = 编译期运行的代码生成程序,能力远超声明宏的模式替换
  • 三种类型:派生宏(最常见),属性宏,函数式过程宏
  • 本质是 TokenStream → TokenStream 的转换,必须独立成 proc-macro crate
  • syn 负责读(解析 AST),quote 负责写(生成代码),是标准组合
  • #[derive(Debug)] 的真相就是遍历字段 + 生成 impl 的派生宏
  • 升级顺序:普通代码 → 声明宏 → 过程宏,按需而非默认使用

动手练习

  1. 搭建 proc-macro crate:新建一个 proc-macro = true 的 crate,照着例子实现 #[derive(Hello)],在另一个 crate 中引用并验证输出.
  2. 扩展字段信息:扩展该派生宏,让生成的方法额外打印结构体的字段数量(提示:从 syn::Data::Struct 中取出字段并 .len()).
  3. cargo expand 探索:找一个常用的库(如 serde,clap),用 cargo expand 查看它的 #[derive] 实际展开成了什么,对照本篇的套路理解.