Skip to content

第 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 会停止工作——serdetokio#[tokio::main])、sqlxclapaxumrocketactixnomproptest……几乎所有主流库都重度依赖宏。

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 匹配到 123 三次,展开得到:

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 + 3foo(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可见性修饰pubpub(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_iterfoo_lenfoo_ptr 这种基于已有名字的派生标识符,声明宏在 2021 edition 前基本做不到(paste crate 用 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!(...) 时,它会:

  1. 把宏调用括号内的 token 打包成 TokenStream
  2. 调用 my_macro 函数,传入这个 TokenStream
  3. 函数返回一个 TokenStream
  4. 编译器把返回的 TokenStream 插入到调用点

"在编译期运行的 Rust"——这四个字是过程宏的全部魔力。你可以做声明宏做不到的任何事:

  • 读入类型定义、提取字段列表、生成 impl 块
  • 解析 SQL 字符串、连接真实数据库验证语法、生成类型安全的 Query
  • 读取 JSON Schema 文件、生成对应的 Rust 类型
  • 做 FFI 绑定、根据 C 头文件生成 Rust 声明

代价也显著:

  • 过程宏必须在独立的 crate 里(proc-macro = true)。这个 crate 会被编译为编译器插件——它是一个 dylib,在宿主编译器里动态加载。
  • 所以使用过程宏的 crate 要先编译 proc-macro crate,再编译自己。增加编译时间。
  • proc-macro crate 不能被其他普通 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。结构高度一致:

  1. parse_macro_input! 把原始 TokenStream 解析成 DeriveInput(来自 syn)——一个结构化的类型表示。
  2. expand_derive_serializeexpand_derive_deserialize 是核心生成逻辑(后续章节重点分析)。
  3. 出错时用 syn::Error::into_compile_error 转成编译错误的 TokenStream——这样错误会直接显示在用户代码里。
  4. .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_deriveproc_macroproc_macro_attribute 函数导出。它不能导出普通类型、trait、函数给其他代码用。所以 Serialize trait 必须放在另一个 crate(serde_coreserde)。

原因 2:编译优化。 主 crate 的 Cargo.toml 里,proc-macro crate 是可选依赖。用户如果不用 derive 宏(自己手写 impl),可以 default-features = false 关掉,省去编译 serde_derive 和它的传递依赖(synquoteproc-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 宏的标准范式。 sqlxdieselclaptokiotokio-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_serializede::expand_derive_deserialize

这说明派生宏做的第一件事不是"理解 Rust 类型",而是"把 token 变成语法树"。serde_derive/src/lib.rs:77-80 同时引入 proc_macro::TokenStreamproc_macro2::{Ident, Span}quotesyn::parse_macro_input,正好对应本书第 6-8 章的工具链顺序:

阶段工具serde_derive 入口里的位置
编译器传入 tokenproc_macro::TokenStream函数参数
解析为 Rust ASTsyn::parse_macro_inputlib.rs:115 / lib.rs:123
生成输出 tokenquote / proc_macro2下游 ser.rsde.rs 返回
返回给 rustc.into()lib.rs:118 / lib.rs:126

这个入口非常薄,真正复杂度被推到后面:第 10 章讲架构分层,第 11 章讲属性解析,第 12、13 章讲代码生成。入口薄是生产级宏的好味道,因为它让"编译器 ABI"和"业务生成逻辑"隔离开。你自己写派生宏时也应保持这个形状:入口只做 parse、错误转换和转发,不在入口里堆字段遍历和 quote 模板。

5.11 下一步:从"知道有宏"到"写出宏"

到这里你应该建立了三个层面的认知:

  1. 为什么 Rust 需要三套宏:没反射、没继承、复杂 API 简化、DSL。
  2. 三种宏的分界:声明宏是模板,过程宏是函数;派生宏/属性宏/函数式过程宏是过程宏的三种使用形态。
  3. 宏的宏观位置:展开发生在 AST 之后、类型检查之前;宏只处理 token,不处理语义。

下一章 我们深入过程宏的第一块基石——TokenStream。你会看到 proc_macro::TokenStreamproc_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 模板变量上卡住。打好这块地基值得投入。

动手实验

  1. 写一个声明宏 log_debug!,用法 log_debug!(x, y, z),展开成 println!("x = {:?}, y = {:?}, z = {:?}", x, y, z)。这是 Rust 标准库 dbg! 的简化版,注意处理变长参数。

  2. 用 cargo expand 观察实际展开

    bash
    cargo install cargo-expand

    在一个 serde 项目里跑 cargo expand,找到 #[derive(Serialize)] 展开后的代码。对照本章末的例子看是否一致。

  3. 对比声明宏和过程宏的编译时间:新建两个 crate,分别依赖 serde_derive(过程宏路径)和 serde 但手写 impl Serialize(无宏)。用 cargo build --timings 对比首次编译时间,理解 proc-macro 带来的编译开销。

  4. 思考题:假如 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 多路选择。看过后你会对声明宏的边界有更深体感。

基于 VitePress 构建