Serde 元编程

第 5 章 Rust 宏系统全景:声明宏、过程宏与 derive 宏

作者 杨艺韬 · 8,072 字 · 发布于 · 更新于

5.1 从一个小小的烦恼说起

想象你正在写 Rust 代码。你定义了十几个简单的数据结构,每一个都需要打印出来看——于是你给每一个都写 impl Debug:

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()
    }
}

写到第三个结构体,你开始不耐烦——这些代码完全是机械生成的。结构体有什么字段,就依次输出什么字段。没有任何判断、没有任何取舍。如果让你用键盘"手动"重复这种代码,你会觉得自己是个廉价工人,不是程序员。

然后你写下这一行:

#[derive(Debug)]
struct User {
    id: u64,
    name: String,
}

问题消失了。这一行不是调用任何函数——它是在编译期让 Rust 为你写那几行 impl Debug。你告诉编译器"看着办吧",编译器按照一个你看不到的模板自动填空。

这就是"宏"最直白的价值:让机械代码自己写自己。从语言实现者的角度看,宏是一个代码生成系统;从用户角度看,宏是一个"魔法缩写"。

Rust 的宏系统比 C 的 #define 复杂得多。本章按三种宏来讲——声明宏(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 方法),trait 默认方法和 blanket impl 能覆盖与字段无关的部分,但要按每个类型的字段逐一生成的代码,没有宏就只能重复写。derive 宏是 Rust 社区处理这类样板的主流做法。

原因 3:复杂的 API 简化。 println!("x = {}", x) 背后做了什么?它在编译期解析格式字符串、根据 {} 的数量检查参数个数、为每个 {} 选择对应类型的格式化函数——这些都是类型安全的,不是运行时字符串处理。没有宏,只能写成 println("x = ", x) 这种肉眼无法对齐的形式。println! 提供了"看起来像字符串插值、但在编译期类型检查"的体验。

原因 4:领域专用语法。 sqlx::query!("SELECT id FROM users WHERE name = $1", name) 是一个过程宏——配置了 DATABASE_URL 且没开离线模式时,它在编译期真的去连数据库,验证 SQL 语法、解析列类型、生成类型安全的 Rust 代码。这种"在 Rust 里嵌入另一种语言"的能力没有宏就无法实现。

所以 Rust 的宏不是"锦上添花",是"不得不有"。如果删除所有宏,crates.io 上大量 crate 会停止工作——serde、tokio(#[tokio::main])、sqlx、clap、axum、rocket、actix、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 标准库的简化版本
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 三次,展开得到:

{
    let mut v = Vec::new();
    v.push(1);
    v.push(2);
    v.push(3);
    v
}

声明宏的核心机制就是这样——模式匹配输入 tokens,按模板重写。它处理的不是字符串(不像 C 的 #define),而是已经初步解析过的 token 序列,所以天然具备一些结构感知能力(知道哪里是表达式、哪里是类型)。

fragment specifiers 的清单——rustc 1.96 的 NonterminalKind::from_symbol(compiler/rustc_ast/src/token.rs:1136-1171)总共认 16 个符号,其中 guard 锁在未稳定的 macro_guard_matcher 特性后面(compiler/rustc_feature/src/unstable.rs:577,1.96.0 引入,tracking issue #153104;门禁的报错点在 compiler/rustc_expand/src/mbe/macro_rules.rs:1399-1409,提示语是 "guard fragments in macro are unstable")——注意它和模式里的 guard_patterns 特性(compiler/rustc_feature/src/unstable.rs:538)不是一回事。稳定通道上能用的是下面 15 个:

Fragment 匹配的东西 举例
expr 表达式;rustc 按调用处 edition 分派——2024 及以后是 NonterminalKind::Expr(Expr),之前是 Expr(Expr2021 { inferred: true })(compiler/rustc_ast/src/token.rs:1152-1158) 2 + 3、foo(1)
expr_2021 2024 版之前的表达式规则 2 + 3
ty 类型 Vec<u8>、&mut str
pat 模式;2021 及以后是 Pat(PatWithOr)(含顶层 | 交替),之前是 Pat(PatParam { inferred: true })(compiler/rustc_ast/src/token.rs:1144-1150) Some(x)、_、A | B
pat_param 模式,但不含顶层 | 交替(2015/2018 版 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)

pat/expr 各自多出一个"带年份"的兄弟,是因为它们的语法在版本纪元之间扩过一次:rustc 按调用处的 edition 决定 pat、expr 走哪一套规则,pat_param、expr_2021 则让你显式钉死老规则。每个 fragment 都告诉解析器"我期望什么类型的 token 序列"。解析器按对应的语法规则提前吃进 token,匹配成功则绑定到变量名($x)。

声明宏能做什么

  • 集合字面量:vec![]、hashmap! { "a" => 1, "b" => 2 }(第三方库 maplit)
  • 格式化输出:println!、format!、write!、eprintln!
  • 控制流增强:assert_eq!、debug_assert!、todo!
  • 构造器缩写:thread_local!、lazy_static!

声明宏做不了什么

  • 接到 #[derive] 上去:macro_rules! 只能看见调用点交给它的 token。你可以把整个 struct 定义原样喂进宏、让它遍历字段(社区确有这种写法),但稳定通道上声明宏不能注册成 derive 宏——#[derive(Foo)] 这条路只对 #[proc_macro_derive] 标注的函数开放(让 macro_rules! 写 derive 规则的 macro_derive 特性 1.91.0 引入,仍未稳定,见 compiler/rustc_feature/src/unstable.rs:575),宏没办法"附着"到一个已经写好的类型上去读它的字段。这正是 Serde 不用声明宏做 derive 的根本原因。
  • 和类型系统交互:声明宏不知道表达式的类型。macro_rules! halve { ($x:expr) => { $x / 2 } } 能工作,但它不知道 $x 是 i32 还是 f64。
  • 产生新标识符:想生成 foo_iter、foo_len、foo_ptr 这种基于已有名字的派生标识符,声明宏在稳定 Rust 上做不到。concat_idents! 不是"至今不稳定",而是已经被删掉了——rustc 1.96 的 removed-features 表把它记为 1.90.0 移除(tracking issue #29599),提示语直接写着 "use the ${concat(..)} metavariable expression instead"(compiler/rustc_feature/src/removed.rs:322-323);而这个替代品仍锁在 macro_metavar_expr_concat 特性门后面(1.81.0 引入,tracking issue #124225,见 compiler/rustc_feature/src/unstable.rs:581),只有 nightly 能用。稳定通道上社区还是用 paste crate 绕过,而 paste 本身就是一个过程宏。
  • 报告精细错误:匹配失败时只能得到 rustc 的通用诊断——"no rules expected ..."或"unexpected end of macro invocation"(compiler/rustc_expand/src/mbe/diagnostics.rs:398-407),能指出卡在哪个 token,却换不成贴合领域的提示,顶多专门写一条 compile_error! 分支。

对"模板化样板代码缩写"这个狭窄任务,声明宏足够用。一旦想走深——需要遍历字段、生成新类型、接入类型信息——就得切到过程宏。

5.5 过程宏:一个在编译期运行的 Rust 函数

过程宏是完全不同的野兽。它是一个普通的 Rust 函数,编译期被编译器调用,输入是一段 TokenStream,输出也是一段 TokenStream。中间可以做任何事——读取文件、访问网络(虽然不推荐)、调用 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()
}

用户端用法:

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 会被编译成一个动态库,由宿主编译器在展开时加载。
  • 所以使用过程宏的 crate 要先编译 proc-macro crate,再编译自己。增加编译时间。
  • proc-macro crate 对外只能导出宏——普通类型、trait、函数都不能从它导出给别的 crate 用,proc_macro 的类型也只在编译器展开宏的过程中才能工作。
  • 过程宏的稳定性:对宏作者稳定的是 proc_macro 的 API,不是二进制 ABI——proc-macro 动态库必须由与宿主编译器同一版本的 rustc 编译,升级 Rust 后要重新编译。

过程宏的三种子类型

函数式过程宏(function-like):调用语法是 my_macro!(...)。

#[proc_macro]
pub fn make_struct(input: TokenStream) -> TokenStream {
    // input 是括号里的全部 token
    // 可以任意解析生成
    ...
}

派生宏(derive):调用语法是 #[derive(MyTrait)]。

#[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):

// 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_serialize 或 expand_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)]。

#[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 三种宏的选择决策树

你要写一个宏——先问自己:

flowchart TD
    Start[我需要什么样的宏?] --> Q1{需要看到类型字段?}
    Q1 -->|需要| PM[过程宏]
    Q1 -->|不需要| Q2{需要精细错误信息?}
    Q2 -->|需要| PM
    Q2 -->|不需要| Q3{可以用模式匹配表达?}
    Q3 -->|可以| DM[声明宏 macro_rules!]
    Q3 -->|不能| PM
    PM --> Q4{附加在类型上生成 trait?}
    Q4 -->|是| D[派生宏 derive]
    Q4 -->|不是| Q5{附加在函数/item 上重写?}
    Q5 -->|是| A[属性宏]
    Q5 -->|不是| F[函数式过程宏]
    style DM fill:#e0ffe0
    style D fill:#fff0d0
    style A fill:#fff0d0
    style F fill:#fff0d0

快速规则:

  • 纯文本替换(集合字面量、格式化、断言)→ 声明宏
  • 生成 trait impl → 派生宏
  • 重写/包装函数 → 属性宏
  • 需要复杂语法但不附着在某个 item 上 → 函数式过程宏

Serde 的 #[derive(Serialize)] 显然是派生宏——它给类型加 impl Serialize,不修改类型本身。

5.7 宏在 Rust 编译流程中的位置

要深入理解宏,必须看它在整个编译流程中的位置。《Rust 编译器与运行时揭秘》专栏第 1 章详细讲过编译管道,第 14 章 §14.1.2 专门讲了宏展开在其中的位置,这里回顾一下关键时序:

flowchart LR
    Src[源代码 .rs 文件] --> Lex[词法分析]
    Lex --> Tok[Token 流]
    Tok --> Parse[语法分析]
    Parse --> AST[AST]
    AST --> MacroExp[宏展开<br/>macro expansion]
    MacroExp --> NameRes[名称解析]
    NameRes --> HIR[HIR]
    HIR --> TypeCk[类型检查]
    TypeCk --> MIR[MIR 优化]
    MIR --> LLVM[LLVM IR]
    LLVM --> Machine[机器码]
    style MacroExp fill:#fff0d0

关键洞察:宏展开发生在 AST 构建和类型检查之间(宏调用先以占位节点进入 AST,展开器再把它替换成展开后的 AST,展开出的新代码继续被解析、再展开,直到没有宏为止)。这意味着:

  • 宏展开时没有类型信息。$x:expr 只告诉你 $x 是表达式,不告诉你类型是 i32 还是 String。
  • 宏展开时没有类型层面的名称解析。要说清楚的是:名称解析并不是整块排在展开之后——rustc 的展开是一个循环,每一轮都要先把已经拼出来的那部分 crate 里的 use 尽量解析掉、把新展开出的名字登记进模块表,才谈得上解析下一个宏调用的宏名(rustc-dev-guide《Macro expansion》一章把这个循环逐步列了出来)。但解析器/展开器解析的是"这个宏名指向哪个宏"这一层,不是"Vec 是哪个类型、它有没有 new"。你写 Vec::new(),宏不知道 Vec 指向哪个具体类型——那是后续阶段的事。
  • 宏可以生成任意代码,后续类型检查会对生成的代码做完整检查。错误可能发生在展开后的 AST 上,导致报错位置很奇怪。

宏和类型检查的交互方式:

宏说"我生成这段代码",然后闭嘴。类型检查器接手,对生成的代码做完整语义检查。如果宏生成了类型不对的代码,错误由类型检查器报告——可能指向用户代码中的宏调用位置,也可能指向宏生成代码的某一行。Span 的管理是宏实现者的责任,第 6 章讲 Span 本身,第 8 章讲 quote_spanned! 如何控制它。

一个常见困惑:为什么 Serde 的 #[derive(Serialize)] 能"看到"字段类型?字段的类型写在代码里了啊。答案是——宏能看到类型名,但不能解析类型。 当用户写:

#[derive(Serialize)]
struct User {
    id: u64,        // 宏只看到 token "u64",不知道它代表什么类型
    name: String,   // 同样,只看到 token "String"
}

宏处理的是 token 层面的信息。u64 是一个 Ident token,String 也是一个 Ident。宏生成的代码会写 self.id.serialize(serializer)——至于 u64 有没有 Serialize 实现,由后续类型检查决定。如果没有(比如某个字段的类型没有实现 Serialize),错误信息会是"the trait bound SomeType: serde::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 的拆法略有不同。看仓库根 Cargo.toml 的 workspace 成员:

serde/
├─ serde/              ← 主 crate, re-export 层
├─ serde_core/         ← 核心 trait 定义
├─ serde_derive/       ← proc-macro crate
├─ serde_derive_internals/ ← 把 serde_derive 内部的 AST 代码单独发布(src 是指向 serde_derive/src/internals 的符号链接)
└─ test_suite/         ← 测试

为什么要分三个甚至四个 crate? 三个技术原因:

原因 1:proc-macro crate 的限制。 标记了 proc-macro = true 的 crate 只能导出被 #[proc_macro]、#[proc_macro_derive]、#[proc_macro_attribute] 标注的那几个函数,普通类型、trait、函数都不能给其他 crate 用。这不是惯例而是编译器的硬性检查,rustc 的诊断原文是 "proc-macro crate types currently cannot export any items other than functions tagged with #[proc_macro], #[proc_macro_derive], or #[proc_macro_attribute]"。所以 Serialize trait 必须放在另一个 crate(serde_core 或 serde)。

原因 2:编译优化。 主 crate 的 Cargo.toml 里,proc-macro crate 是可选依赖(serde_derive = { version = "1", optional = true },derive feature 不在 default = ["std"] 里)。用户如果不用 derive 宏(自己手写 impl),不开 derive feature 就不会编译 serde_derive 和它的传递依赖(syn、quote、proc-macro2)——syn 2.0.117 的 src/ 目录约 5 万行 Rust 代码(find syn/src -name '*.rs' | xargs wc -l 得 50057 行,顶层文件 27758 行),再加 quote/proc-macro2,不用就不编译。

原因 3:编译并行。 serde_core(1.0.220 从 serde 里拆出,仓库提交 f3869307)只包含 trait 定义和内置类型的 impl,不带 derive 支持;serde 在它之上加 serde_derive 的可选 re-export 和给派生代码用的 __private228 模块(名字带补丁号,由 serde/build.rs:7-14 生成)。serde_core/src/lib.rs 开头的文档说得很直白:只依赖 serde_core 的 crate(比如格式库)可以和 serde_derive 并行编译,即使下游开了 serde 的 derive feature。两个 crate 都是 #![cfg_attr(not(feature = "std"), no_std)],no_std 并不是拆分的理由。

这种"核心 + derive + 共享工具"的三段式,是 Rust 生态里 derive 宏的常见范式。 本专栏源码树里就能对照到两例:sqlx 分成 sqlx-core / sqlx-macros / sqlx-macros-core——后两者正是"proc-macro 壳 + 普通 crate 内核"的极端形态:sqlx-macros 里 proc-macro = true,整个 crate 只有一个 101 行的 lib.rs 放注册函数,3585 行真实逻辑全在不带 proc-macro 标记的 sqlx-macros-core 里。tokio 则是两段式:tokio 加一个 proc-macro = true 的 tokio-macros。别的库分几个 crate、怎么命名各有取舍,但"proc-macro crate 不能导出普通 item"这条硬约束对谁都一样,所以至少要拆成两个。

看 serde 的依赖关系:

graph TB
    User[用户代码]
    Serde[serde crate]
    SC[serde_core crate]
    SD[serde_derive crate]
    Syn[syn 2.x]
    Quote[quote 1.x]
    PM2[proc-macro2 1.x]
    User --> Serde
    Serde --> SC
    Serde -.optional, derive feature.-> SD
    SD --> Syn
    SD --> Quote
    SD --> PM2
    Syn --> PM2
    Quote --> PM2
    style Serde fill:#fff0d0
    style SD fill:#ffe0d0

关键依赖:

  • 用户只引用 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 代码,读取注解和类定义,生成新代码。Dagger 就是这样工作的。区别是 Java 注解处理是"生成新文件"模型,Rust 过程宏是"在调用点直接替换 token"模型——后者更集成。

Lisp 宏:是这一切的祖先。Lisp 宏可以处理完整的 S 表达式(代码即数据),可以做任何事。Rust 声明宏的模式匹配机制直接借鉴了 Scheme 的 syntax-rules(Common Lisp 的 defmacro 则是过程式的,更像过程宏)。区别是 Lisp 宏不需要独立 crate——它运行在 Lisp 解释器里,随时展开。

Rust 宏的独特定位是:在 C 预处理器的便利性和 Lisp 宏的完备性之间,提供一个类型安全、IDE 友好(虽然不完美)、零运行时开销的中间点。声明宏是简单侧,过程宏是完备侧。

5.10 三个例子:感受三种宏的差异

最后用三个真实例子对比三种宏的"手感"。

例子 1:vec! — 声明宏的优雅

// 实际使用
let v: Vec<i32> = vec![1, 2, 3];

// 简化源码(不是源码原文:标准库长期以来的经典写法)
macro_rules! vec {
    ($($x:expr),* $(,)?) => {
        <[_]>::into_vec(Box::new([$($x),*]))
    };
}

短小精悍。声明宏做不了复杂的计算逻辑,但对 vec! 这种"填充一个集合"的场景完美够用。

顺带一提,上面这个写法是标准库用了很多年的经典形状,但它后来又改过:rustc 1.96 的 library/alloc/src/macros.rs 里,多元素分支已经变成 box_assume_init_into_vec_unsafe(write_box_via_move(Box::new_uninit(), [$($x),+])),源码注释说这是为了让未优化构建下构造大 Vec 时栈占用大幅下降;空分支 Vec::new() 和 [$elem; $n] 走 from_elem 这两支没变。教学要点不变:vec! 一次性把元素铺进一块分配好的内存,而不是像 §5.4 那个教学版那样逐个 push。

例子 2:sqlx::query! — 函数式过程宏的威力

// 实际使用
let user = sqlx::query!(
    "SELECT id, name FROM users WHERE email = $1",
    email
)
.fetch_one(&pool)
.await?;

// 假设 PostgreSQL 里 id 是 BIGINT、name 是 NOT NULL 的 TEXT:
// user.id 是 i64,user.name 是 String——类型是从数据库实际 schema 推导出来的!

这个宏编译期真的连到你配置的数据库(离线模式下改读 cargo sqlx prepare 缓存的查询元数据)、解析 SQL、查询列类型、生成一个带具体字段类型的 Rust 结构体。声明宏做不到——这需要"执行任意 Rust 代码"的能力。

例子 3:#[derive(Serialize)] — 派生宏的典范

// 用户写
#[derive(Serialize)]
struct User {
    id: u64,
    name: String,
}

// 宏展开后(cargo expand 可观察;这里做了简化——真实输出把 `Serializer::serialize_struct(__serializer, "User", false as usize + 1 + 1)`
// 和 `SerializeStruct::serialize_field(&mut __serde_state, ...)` 都写成 `_serde::` 开头的完全限定路径)
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-127 只有两个公开的派生入口:#[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-81 同时引入 proc_macro::TokenStream、proc_macro2::{Ident, Span}、quote::{ToTokens, TokenStreamExt as _}、syn::parse_macro_input 和 syn::DeriveInput,正好对应本专栏第 6-8 章的工具链顺序:

阶段 工具 在 serde_derive 入口里的位置
编译器传入 token proc_macro::TokenStream 函数参数
解析为 Rust AST syn::parse_macro_input serde_derive/src/lib.rs:115 / serde_derive/src/lib.rs:123
生成输出 token quote / proc_macro2 下游 ser.rs、de.rs 返回
返回给 rustc .into() serde_derive/src/lib.rs:118 / serde_derive/src/lib.rs:126

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

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

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

  1. 为什么 Rust 需要三套宏:没反射、没继承、复杂 API 简化、DSL。
  2. 三种宏的分界:声明宏是模板,过程宏是函数;派生宏/属性宏/函数式过程宏是过程宏的三种使用形态。
  3. 宏的宏观位置:展开发生在 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 模板变量上卡住。打好这块地基值得投入。

动手实验

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

  2. 用 cargo expand 观察实际展开:

    cargo install cargo-expand

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

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

  4. 思考题:假如 Rust 编译器未来加入了运行时反射(纯属假设),Serde 会变成什么样?哪些特性会简化,哪些会丢失?(提示:运行时反射要在运行时查询类型元数据,这和零成本抽象的目标相冲突;选择运行时反射,通常就是接受一部分运行时开销。)

延伸阅读

  • The Little Book of Rust Macros:声明宏的权威教程。配合本章作为声明宏的深度扩展。
  • Rust Reference - Macros:Rust 参考手册的宏章节,涵盖声明宏和过程宏的完整语法。
  • proc-macro-workshop:dtolnay(Serde 维护者)设计的过程宏练习题集,强烈推荐作为第 9 章前的热身。
  • cargo-expand 文档:不写过程宏的人也要装这个工具。
  • 《Rust 编译器与运行时揭秘》专栏第 14 章"宏系统:编译期的元编程引擎":从编译器视角看宏展开——TokenStream 如何进入 AST、hygiene 机制如何避免标识符冲突、recursion limit 从哪里来。本章是"宏写起来是什么样"的视角,那一章是"编译器做了什么"的视角,建议交叉阅读。
  • 《Tokio 源码深度解析》专栏第 14 章"select! 宏展开与公平调度":tokio::select! 的主体是一个大型声明宏(只借两个 tokio-macros 过程宏生成输出枚举、清洗绑定模式)——在模式匹配上模拟出了 async 多路选择。看过后你会对声明宏的边界有更深体感。