Appearance
第 5 章 Rust 宏系统全景:声明宏、过程宏与 derive 宏
5.1 从一个小小的烦恼说起
想象你正在写 Rust 代码。你定义了十几个简单的数据结构,每一个都需要打印出来看——于是你给每一个都写 impl Debug:
rust
impl std::fmt::Debug for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("User")
.field("id", &self.id)
.field("name", &self.name)
.finish()
}
}写到第三个结构体,你开始不耐烦——这些代码完全是机械生成的。结构体有什么字段,就依次输出什么字段。没有任何判断、没有任何取舍。如果让你用键盘"手动"重复这种代码,你会觉得自己是个廉价工人,不是程序员。
然后你写下这一行:
rust
#[derive(Debug)]
struct User {
id: u64,
name: String,
}问题消失了。这一行不是调用任何函数——它是在编译期让 Rust 为你写那二十行 impl Debug。你告诉编译器"看着办吧",编译器按照一个你看不到的模板自动填空。
这就是"宏"最直白的价值:让机械代码自己写自己。从语言实现者的角度看,宏是一个代码生成系统;从用户角度看,宏是一个"魔法缩写"。
Rust 的宏系统比 C 的 #define 和 C++ 的 template 都复杂得多。它有三种不同的宏——声明宏(macro_rules!)、过程宏(proc_macro)、派生宏(derive 宏)——功能各异、用法完全不同。一个 Rust 工程师一辈子可能只用过 println!,另一个可能天天写过程宏;两个人用的都叫"宏",但几乎是两套独立技能。
本章要给你一张清晰的地图——三种宏各自能做什么、不能做什么、什么时候该用哪一种。这张地图是读 serde_derive 源码的前置知识,也是将来你写自己的 derive 宏的坐标系。
丛书卷一《Rust 编译器与运行时揭秘》第 14 章从编译器视角讲了宏的展开机制——宏的 token 流如何进入 AST、如何在 HIR 之前被处理。那一章关注"编译器做了什么";本章关注"宏写起来是什么样"。两章互补,建议交叉对照阅读。
5.2 为什么 Rust 需要宏
这个问题值得严肃回答。很多人把"有宏"当作某种语言特性 checkbox——有就好,多多益善。其实不是。宏是最昂贵的语言特性之一:它让代码难以阅读(你看到的不是实际运行的代码)、让编译器慢(需要展开和再解析)、让 IDE 支持复杂(跳转定义跨越宏边界)、让错误信息难懂(错误可能指向展开后的代码)。
Rust 还坚持支持三套完整的宏系统。为什么?
原因 1:没有运行时反射。 Java 可以运行时拿到 User.class 然后遍历字段;Python 可以 dir(user);Go 可以 reflect.TypeOf(u)。这些语言都不需要宏——反射替代了宏的大部分使用场景(序列化、ORM、DI 容器都靠反射)。Rust 主动放弃了运行时反射(为了零成本和二进制体积),那些功能必须在编译期用宏实现。第 1 章讲过 Serde 为什么不能走反射路径——Data Model + 过程宏是它对"没有反射"的整体回应。
原因 2:没有继承。 Java/C# 有类继承,很多框架通过"继承某个基类"来共享代码。Rust 只有 trait(接口),不能继承。如果你想让多个类型共享某种样板代码(比如所有 ORM 实体都需要 find_by_id 方法),没有宏就只能重复写。derive 宏是 Rust 社区对"代码共享"的主流答案。
原因 3:复杂的 API 简化。 println!("x = {}", x) 背后做了什么?它在编译期解析格式字符串、根据 {} 的数量检查参数个数、为每个 {} 选择对应类型的格式化函数——这些都是类型安全的,不是运行时字符串处理。没有宏,只能写成 println("x = ", x) 这种肉眼无法对齐的形式。println! 提供了"看起来像字符串插值、但在编译期类型检查"的体验。
原因 4:领域专用语法。 sqlx::query!("SELECT id FROM users WHERE name = $1", name) 是一个过程宏——它在编译期真的去连数据库,验证 SQL 语法、解析列类型、生成类型安全的 Rust 代码。这种"在 Rust 里嵌入另一种语言"的能力没有宏就无法实现。
所以 Rust 的宏不是"锦上添花",是"不得不有"。如果删除所有宏,半个 crates.io 会停止工作——serde、tokio(#[tokio::main])、sqlx、clap、axum、rocket、actix、nom、proptest……几乎所有主流库都重度依赖宏。
5.3 宏的三种形态:一张地图
Rust 宏分三种,它们的职责完全不同:
声明宏(macro_rules!):形如 vec![1, 2, 3]、println!("{}", x)、assert_eq!(a, b)。用模式匹配规则把输入 token 替换成输出 token。写法简单、功能受限。用户可以在普通 crate 里直接写,不需要单独的 proc-macro crate。
过程宏(proc_macro):三种子类型共享同一套机制——写一个 Rust 函数,编译期被调用,输入是 TokenStream,输出也是 TokenStream,中间做任意 Rust 计算。
- 函数式过程宏:用法形如
my_macro!(...)。和声明宏调用语法一样,但实现端是完全自由的 Rust 代码。典型例子:sqlx::query!、html!。 - 派生宏(derive 宏):
#[derive(Debug, Serialize)]。接收一个类型定义,生成它的 trait 实现。serde_derive就是这类。 - 属性宏:
#[tokio::main]、#[wasm_bindgen]。附着在函数或其他项上,可以重写整个项。
所有三种过程宏共享同一个"TokenStream 进、TokenStream 出"的模型,但注册方式和用法不同。
这张地图上有一个关键的分界线:声明宏 vs 过程宏。
| 声明宏 | 过程宏 | |
|---|---|---|
| 实现方式 | 模式匹配 + 模板替换 | 任意 Rust 代码 |
| 写在哪里 | 普通 crate 里直接写 | 独立 proc-macro crate |
| 输入处理 | 受限的 $x:expr/$x:ty 等 fragment | 完整 TokenStream |
| 可读源码 | 可以 | 只能通过 cargo expand 间接看 |
| 典型用法 | 样板缩写、控制流宏 | 类型级代码生成、DSL |
| 编译成本 | 低 | 高(独立 crate + syn/quote 解析) |
| 错误信息 | 粗糙 | 可以定制(Span 指向准确位置) |
这张表是本章反复回来查的工具。不同场景选不同种类——选错了要么做不出来,要么编译时间翻倍。
5.4 声明宏:文本层的模式匹配
先看最简单的声明宏。vec! 是 Rust 标准库里最常见的声明宏:
rust
// Rust 标准库的简化版本
macro_rules! vec {
() => { Vec::new() };
($($x:expr),*) => {
{
let mut v = Vec::new();
$(
v.push($x);
)*
v
}
};
}读这段代码:
macro_rules!开始声明宏定义,名字叫vec。()是第一个"分支"——没有参数时,展开成Vec::new()。($($x:expr),*)是第二个分支。这里$x:expr是一个 fragment specifier,表示"匹配一个表达式";$(...)+,*是 repetition——匹配任意个(包括 0 个)用逗号分隔的表达式。=>后面是展开模板。$(v.push($x);)*意思是"对每个匹配到的$x,生成一次v.push($x);"。
用户写 vec![1, 2, 3],编译器匹配第二个分支,$x 匹配到 1、2、3 三次,展开得到:
rust
{
let mut v = Vec::new();
v.push(1);
v.push(2);
v.push(3);
v
}声明宏的核心机制就是这样——模式匹配输入 tokens,按模板重写。它处理的不是字符串(不像 C 的 #define),而是已经初步解析过的 token 序列,所以天然具备一些结构感知能力(知道哪里是表达式、哪里是类型)。
fragment specifiers 的完整清单(Rust 参考手册):
| Fragment | 匹配的东西 | 举例 |
|---|---|---|
expr | 表达式 | 2 + 3、foo(1) |
ty | 类型 | Vec<u8>、&mut str |
pat | 模式 | Some(x)、_ |
item | 顶层项 | struct Foo;、fn main() {} |
block | 块 | { let x = 1; x } |
stmt | 语句 | let x = 1; |
path | 路径 | std::collections::HashMap |
ident | 标识符 | foo |
meta | 属性内容 | derive(Debug) |
lifetime | 生命周期 | 'a |
literal | 字面量 | 42、"hi" |
tt | 单个 token tree | 任意 |
vis | 可见性修饰 | pub、pub(crate) |
每个 fragment 都告诉解析器"我期望什么类型的 token 序列"。解析器按对应的语法规则提前吃进 token,匹配成功则绑定到变量名($x)。
声明宏能做什么
- 集合字面量:
vec![]、hashmap! { "a" => 1, "b" => 2 }(第三方库 maplit) - 格式化输出:
println!、format!、write!、eprintln! - 控制流增强:
assert!、assert_eq!、debug_assert!、unreachable!、todo! - 构造器缩写:
thread_local!、lazy_static!
声明宏做不了什么
- 生成 trait 实现:
macro_rules!很难拿到一个类型的所有字段名。你可以传入类型名,但无法让宏"看见"字段。这正是 Serde 不用声明宏做 derive 的根本原因——要读类型结构。 - 和类型系统交互:声明宏不知道表达式的类型。
macro_rules! halve { ($x:expr) => { $x / 2 } }能工作,但它不知道$x是 i32 还是 f64。 - 产生新标识符:想生成
foo_iter、foo_len、foo_ptr这种基于已有名字的派生标识符,声明宏在 2021 edition 前基本做不到(pastecrate 用 hack 勉强实现)。 - 报告精细错误:声明宏错误信息通常是"no rules matched",用户不知道是哪个 token 出了问题。
对"模板化样板代码缩写"这个狭窄任务,声明宏足够用。一旦想走深——需要遍历字段、生成新类型、接入类型信息——就得切到过程宏。
5.5 过程宏:一个在编译期运行的 Rust 函数
过程宏是完全不同的野兽。它是一个普通的 Rust 函数,编译期被编译器调用,输入是一段 TokenStream,输出也是一段 TokenStream。中间可以做任何事——读取文件、访问网络(虽然不推荐)、调用 Rust 的任何库。
来看最简单的过程宏"骨架":
rust
// 一个独立的 proc-macro crate
// Cargo.toml 里 [lib] proc-macro = true
use proc_macro::TokenStream;
#[proc_macro]
pub fn my_macro(input: TokenStream) -> TokenStream {
// 这里是普通 Rust 代码,在编译期运行
let _ = input;
"fn generated() {}".parse().unwrap()
}用户端用法:
rust
my_macro!(随便写什么);
fn main() {
generated(); // 来自宏生成的函数
}当编译器遇到 my_macro!(...) 时,它会:
- 把宏调用括号内的 token 打包成 TokenStream
- 调用
my_macro函数,传入这个 TokenStream - 函数返回一个 TokenStream
- 编译器把返回的 TokenStream 插入到调用点
"在编译期运行的 Rust"——这四个字是过程宏的全部魔力。你可以做声明宏做不到的任何事:
- 读入类型定义、提取字段列表、生成 impl 块
- 解析 SQL 字符串、连接真实数据库验证语法、生成类型安全的 Query
- 读取 JSON Schema 文件、生成对应的 Rust 类型
- 做 FFI 绑定、根据 C 头文件生成 Rust 声明
代价也显著:
- 过程宏必须在独立的 crate 里(
proc-macro = true)。这个 crate 会被编译为编译器插件——它是一个 dylib,在宿主编译器里动态加载。 - 所以使用过程宏的 crate 要先编译 proc-macro crate,再编译自己。增加编译时间。
proc-macrocrate 不能被其他普通 crate 当作库依赖——proc_macro类型只在编译器内部可用。- 过程宏的稳定性:编译器对过程宏 ABI 有隐含约束,Rust 版本升级偶尔会影响旧宏。
过程宏的三种子类型
函数式过程宏(function-like):调用语法是 my_macro!(...)。
rust
#[proc_macro]
pub fn make_struct(input: TokenStream) -> TokenStream {
// input 是括号里的全部 token
// 可以任意解析生成
...
}派生宏(derive):调用语法是 #[derive(MyTrait)]。
rust
#[proc_macro_derive(MyTrait)]
pub fn derive_my_trait(input: TokenStream) -> TokenStream {
// input 是被 derive 的类型定义(struct/enum)的完整 TokenStream
...
}
// 带属性支持
#[proc_macro_derive(MyTrait, attributes(my_attr))]
pub fn derive_my_trait_with_attrs(input: TokenStream) -> TokenStream {
// 现在用户可以写 #[derive(MyTrait)] #[my_attr(...)]
...
}Serde 就是派生宏。看它的实际注册代码(来自 serde_derive/src/lib.rs:113):
rust
// serde_derive/src/lib.rs:113
#[proc_macro_derive(Serialize, attributes(serde))]
pub fn derive_serialize(input: TokenStream) -> TokenStream {
let mut input = parse_macro_input!(input as DeriveInput);
ser::expand_derive_serialize(&mut input)
.unwrap_or_else(syn::Error::into_compile_error)
.into()
}
// serde_derive/src/lib.rs:121
#[proc_macro_derive(Deserialize, attributes(serde))]
pub fn derive_deserialize(input: TokenStream) -> TokenStream {
let mut input = parse_macro_input!(input as DeriveInput);
de::expand_derive_deserialize(&mut input)
.unwrap_or_else(syn::Error::into_compile_error)
.into()
}两个入口函数,分别处理 Serialize 和 Deserialize。结构高度一致:
parse_macro_input!把原始 TokenStream 解析成DeriveInput(来自 syn)——一个结构化的类型表示。expand_derive_serialize或expand_derive_deserialize是核心生成逻辑(后续章节重点分析)。- 出错时用
syn::Error::into_compile_error转成编译错误的 TokenStream——这样错误会直接显示在用户代码里。 .into()把proc_macro2::TokenStream转回proc_macro::TokenStream(这两个类型很相似但不相等,第 6 章会详细讲)。
Serde 的整个宏系统就是从这 15 行代码开始——往下推理就是数千行的"如何把类型结构生成 impl 块"。
属性宏:调用语法是 #[my_attr] 或 #[my_attr(args)]。
rust
#[proc_macro_attribute]
pub fn my_attr(args: TokenStream, item: TokenStream) -> TokenStream {
// args 是括号内的参数(可能为空)
// item 是被修饰的项(函数、struct、impl 等)
...
}典型用法:#[tokio::main] 把一个 async fn main() 重写成带 runtime 启动的同步 main。
属性宏的独特能力是可以替换原项——不只是往旁边加 impl 块,而是重写 item 本身。它和派生宏最大的差别在这里。
5.6 三种宏的选择决策树
你要写一个宏——先问自己:
快速规则:
- 纯文本替换(集合字面量、格式化、断言)→ 声明宏
- 生成 trait impl → 派生宏
- 重写/包装函数 → 属性宏
- 需要复杂语法但不附着在某个 item 上 → 函数式过程宏
Serde 的 #[derive(Serialize)] 显然是派生宏——它给类型加 impl Serialize,不修改类型本身。
5.7 宏在 Rust 编译流程中的位置
要深入理解宏,必须看它在整个编译流程中的位置。丛书卷一第 14 章详细讲过编译管道,这里回顾一下关键时序:
关键洞察:宏展开发生在 AST 构建和类型检查之间。 这意味着:
- 宏展开时没有类型信息。
$x:expr只告诉你$x是表达式,不告诉你类型是 i32 还是 String。 - 宏展开时没有名称解析。你写
Vec::new(),宏不知道Vec指向哪个具体类型——那是后续阶段的事。 - 宏可以生成任意代码,后续类型检查会对生成的代码做完整检查。错误可能发生在展开后的 AST 上,导致报错位置很奇怪。
宏和类型检查的交互方式:
宏说"我生成这段代码",然后闭嘴。类型检查器接手,对生成的代码做完整语义检查。如果宏生成了类型不对的代码,错误由类型检查器报告——可能指向用户代码中的宏调用位置,也可能指向宏生成代码的某一行。Span 的管理是宏实现者的责任,第 8 章会详细讲。
一个常见困惑:为什么 Serde 的 #[derive(Serialize)] 能"看到"字段类型?字段的类型写在代码里了啊。答案是——宏能看到类型名,但不能解析类型。 当用户写:
rust
#[derive(Serialize)]
struct User {
id: u64, // 宏只看到 token "u64",不知道它代表什么类型
name: String, // 同样,只看到 token "String"
}宏处理的是 token 层面的信息。u64 是一个 Ident token,String 也是一个 Ident。宏生成的代码会写 self.id.serialize(serializer)——至于 u64 有没有 Serialize 实现,由后续类型检查决定。如果没有(比如你 derive 了一个没有定义 Serialize 的类型),错误信息会是"SomeType: Serialize is not satisfied"——在生成代码上报错,但指向用户的 derive 行。
这种**"宏只处理 token、类型检查处理语义"**的分层,是 Rust 宏系统的根本设计。它让宏的实现者不需要懂类型系统,也让编译器可以对宏生成的代码做和手写代码完全相同的语义检查。
5.8 过程宏 crate 的组织:为什么是三个 crate
一个用了 derive 宏的库通常有这种结构:
my-lib/
├─ my-lib/ ← 主 crate(用户引用的)
├─ my-lib-derive/ ← proc-macro crate,写宏逻辑
└─ my-lib-internals/ ← 可选,两者共享的工具Serde 就是典型代表。看 serde/Cargo.toml 的主结构:
serde/
├─ serde/ ← 主 crate, re-export 层
├─ serde_core/ ← 核心 trait 定义
├─ serde_derive/ ← proc-macro crate
├─ serde_derive_internals/ ← serde_derive 内部共享工具
└─ test_suite/ ← 测试为什么要分三个甚至四个 crate? 三个技术原因:
原因 1:proc-macro crate 的限制。 标记了 proc-macro = true 的 crate 只能被 proc_macro_derive、proc_macro、proc_macro_attribute 函数导出。它不能导出普通类型、trait、函数给其他代码用。所以 Serialize trait 必须放在另一个 crate(serde_core 或 serde)。
原因 2:编译优化。 主 crate 的 Cargo.toml 里,proc-macro crate 是可选依赖。用户如果不用 derive 宏(自己手写 impl),可以 default-features = false 关掉,省去编译 serde_derive 和它的传递依赖(syn、quote、proc-macro2)——syn 本身约 80000 行 Rust 代码(本书基于版本 2.0.117,wc -l syn/src/*.rs),再加 quote/proc-macro2,合起来数万行,不用就不编译。
原因 3:功能分层。 serde_core 是 #![no_std] 友好的核心库,嵌入式场景可以只用它;serde crate 在 serde_core 基础上加了 std 支持。分层让一个代码库能服务多种场景。
这种"核心 + derive + 共享工具"的三段式,是 Rust 生态里 derive 宏的标准范式。 sqlx、diesel、clap、tokio(tokio-macros)、async-trait 等主流库都遵循这种结构。
看 serde 的依赖关系:
关键依赖:
- 用户只引用
serde,通过features = ["derive"]可选启用 derive。 serde依赖serde_core(无条件)和serde_derive(可选)。serde_derive依赖 syn/quote/proc-macro2——过程宏的"三件套"(第 6-8 章的主角)。
5.9 和其他语言的宏对比
为了让 Rust 宏的独特之处更清楚,对比几个你可能熟悉的系统:
C/C++ 预处理器(#define):纯文本替换,在词法分析之前就完成。没有任何结构感知——你写 #define ADD(a, b) a + b,展开 ADD(1, 2) * 3 得到 1 + 2 * 3 = 7 而不是 9。这就是为什么所有 C 宏都要疯狂加括号 ((a) + (b))。Rust 的声明宏至少知道 $x:expr 是一个完整表达式,不会被优先级破坏。
C++ 模板(templates):是一套独立的图灵完备编译期计算系统,但写法诡异(SFINAE、enable_if、variadic templates)。功能强大,复杂度爆炸。Rust 的过程宏比 C++ 模板更直白——就是普通 Rust 代码,只是运行时机在编译期。
Java/Kotlin 注解处理(annotation processing):比较接近 Rust 过程宏——编译期运行 Java 代码,读取注解和类定义,生成新代码。Lombok、Dagger 就是这样工作的。区别是 Java 注解处理是"生成新文件"模型,Rust 过程宏是"在调用点直接替换 token"模型——后者更集成。
Lisp 宏:是这一切的祖先。Lisp 宏可以处理完整的 S 表达式(代码即数据),可以做任何事。Rust 声明宏的模式匹配机制直接借鉴了 Common Lisp 的 syntax-rules。区别是 Lisp 宏不需要独立 crate——它运行在 Lisp 解释器里,随时展开。
Rust 宏的独特定位是:在 C 预处理器的便利性和 Lisp 宏的完备性之间,提供一个类型安全、IDE 友好(虽然不完美)、零运行时开销的中间点。声明宏是简单侧,过程宏是完备侧。
5.10 三个例子:感受三种宏的差异
最后用三个真实例子对比三种宏的"手感"。
例子 1:vec! — 声明宏的优雅
rust
// 实际使用
let v: Vec<i32> = vec![1, 2, 3];
// 简化源码
macro_rules! vec {
($($x:expr),* $(,)?) => {
<[_]>::into_vec(Box::new([$($x),*]))
};
}短小精悍。声明宏无法处理"不定数量异构类型"以外的复杂逻辑,但对 vec! 这种"填充一个集合"的场景完美够用。
例子 2:sqlx::query! — 函数式过程宏的威力
rust
// 实际使用
let user = sqlx::query!(
"SELECT id, name FROM users WHERE email = $1",
email
)
.fetch_one(&pool)
.await?;
// user.id 是 i64,user.name 是 String——类型是从数据库实际 schema 推导出来的!这个宏编译期真的连到你配置的数据库、解析 SQL、查询列类型、生成一个带具体字段类型的 Rust 结构体。声明宏做不到——这需要"执行任意 Rust 代码"的能力。
例子 3:#[derive(Serialize)] — 派生宏的典范
rust
// 用户写
#[derive(Serialize)]
struct User {
id: u64,
name: String,
}
// 宏展开后(cargo expand 可观察)
impl serde::Serialize for User {
fn serialize<__S>(&self, serializer: __S) -> Result<__S::Ok, __S::Error>
where __S: serde::Serializer,
{
let mut state = serializer.serialize_struct("User", 2)?;
state.serialize_field("id", &self.id)?;
state.serialize_field("name", &self.name)?;
state.end()
}
}派生宏读取 struct User 的定义、拿到字段列表 [id, name]、按照 Serialize trait 的协议生成 impl。这个模板对所有 struct 都成立,但每次生成的具体代码因字段数量和名字不同。这正是第 12 章"Serialize 代码生成"要详细展开的内容。
5.10.1 serde_derive 的真实入口只有两扇门
把概念落到源码,serde_derive/src/lib.rs:113-124 只有两个公开的派生入口:#[proc_macro_derive(Serialize, attributes(serde))] 和 #[proc_macro_derive(Deserialize, attributes(serde))]。两者形状完全一致:接收 proc_macro::TokenStream,用 parse_macro_input!(input as DeriveInput) 解析成 syn::DeriveInput,再交给 ser::expand_derive_serialize 或 de::expand_derive_deserialize。
这说明派生宏做的第一件事不是"理解 Rust 类型",而是"把 token 变成语法树"。serde_derive/src/lib.rs:77-80 同时引入 proc_macro::TokenStream、proc_macro2::{Ident, Span}、quote 和 syn::parse_macro_input,正好对应本书第 6-8 章的工具链顺序:
| 阶段 | 工具 | 在 serde_derive 入口里的位置 |
|---|---|---|
| 编译器传入 token | proc_macro::TokenStream | 函数参数 |
| 解析为 Rust AST | syn::parse_macro_input | lib.rs:115 / lib.rs:123 |
| 生成输出 token | quote / proc_macro2 | 下游 ser.rs、de.rs 返回 |
| 返回给 rustc | .into() | lib.rs:118 / lib.rs:126 |
这个入口非常薄,真正复杂度被推到后面:第 10 章讲架构分层,第 11 章讲属性解析,第 12、13 章讲代码生成。入口薄是生产级宏的好味道,因为它让"编译器 ABI"和"业务生成逻辑"隔离开。你自己写派生宏时也应保持这个形状:入口只做 parse、错误转换和转发,不在入口里堆字段遍历和 quote 模板。
5.11 下一步:从"知道有宏"到"写出宏"
到这里你应该建立了三个层面的认知:
- 为什么 Rust 需要三套宏:没反射、没继承、复杂 API 简化、DSL。
- 三种宏的分界:声明宏是模板,过程宏是函数;派生宏/属性宏/函数式过程宏是过程宏的三种使用形态。
- 宏的宏观位置:展开发生在 AST 之后、类型检查之前;宏只处理 token,不处理语义。
下一章 我们深入过程宏的第一块基石——TokenStream。你会看到 proc_macro::TokenStream 和 proc_macro2::TokenStream 的关系、Span 如何管理错误指向、TokenTree 的五种变体,以及为什么所有过程宏库最终都选择了 proc-macro2 而不是标准库的 proc_macro。
然后第 7 章讲 syn——把 TokenStream 解析成结构化 AST 的库。第 8 章讲 quote——用模板反向生成 TokenStream 的库。第 9 章你会写下自己的第一个 derive 宏,从零实现一个 #[derive(Builder)]。
这五章(5-9)是后续读 serde_derive 源码的前置知识。 跳过它们直接读第 10 章(serde_derive 架构),你会在每一个 syn::DeriveInput、#variant_names 模板变量上卡住。打好这块地基值得投入。
动手实验
写一个声明宏
log_debug!,用法log_debug!(x, y, z),展开成println!("x = {:?}, y = {:?}, z = {:?}", x, y, z)。这是 Rust 标准库dbg!的简化版,注意处理变长参数。用 cargo expand 观察实际展开:
bashcargo install cargo-expand在一个 serde 项目里跑
cargo expand,找到#[derive(Serialize)]展开后的代码。对照本章末的例子看是否一致。对比声明宏和过程宏的编译时间:新建两个 crate,分别依赖
serde_derive(过程宏路径)和serde但手写impl Serialize(无宏)。用cargo build --timings对比首次编译时间,理解 proc-macro 带来的编译开销。思考题:假如 Rust 编译器未来加入了运行时反射(不会发生,但假设),Serde 会变成什么样?哪些特性会简化,哪些会丢失?(提示:类型安全的零成本抽象是不能和反射共存的,选择反射等于选择性能损失。)
延伸阅读
- The Little Book of Rust Macros:声明宏的权威教程,800+ 条规则和技巧。配合本章作为声明宏的深度扩展。
- Rust Reference - Macros:Rust 参考手册的宏章节,涵盖声明宏和过程宏的完整语法。
- proc-macro-workshop:dtolnay(Serde 作者)设计的过程宏练习题集,强烈推荐作为第 9 章前的热身。
- cargo-expand 文档:不写过程宏的人也要装这个工具。
- 丛书卷一《Rust 编译器与运行时揭秘》第 14 章"声明宏与过程宏的展开机制":从编译器视角看宏展开——TokenStream 如何进入 AST、hygiene 机制如何避免标识符冲突、recursion limit 从哪里来。本章是"宏写起来是什么样"的视角,卷一那一章是"编译器做了什么"的视角,建议交叉阅读。
- 丛书《Tokio 源码深度解析》第 14 章"select! 宏展开与公平调度":
tokio::select!是声明宏的巅峰作——在模式匹配上模拟出了 async 多路选择。看过后你会对声明宏的边界有更深体感。