Serde 元编程

第 6 章 TokenStream 与 proc_macro2:编译期的"字节流"

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

6.1 源代码在过程宏眼中是什么

翻开你写过的任何一份 Rust 代码:

struct User {
    id: u64,
    name: String,
}

你看到的是一份完整的类型定义。你的眼睛认识 struct 是关键字、User 是名字、花括号里是字段。在你的脑子里这是一棵"语法树"——StructDef { name: "User", fields: [Field("id", Type("u64")), Field("name", Type("String"))] }。

但过程宏最初看到的不是这个。当编译器把这段代码传给 #[derive(Serialize)] 时,它是一串更底层的数据——token 序列:

Ident("struct") Ident("User")
Group<Brace>{
    Ident("id") Punct(':') Ident("u64") Punct(',')
    Ident("name") Punct(':') Ident("String") Punct(',')
}

每一个 Ident、Punct、Group 是一个 TokenTree。它们串起来是 TokenStream。过程宏函数的输入和输出都是这东西:

#[proc_macro_derive(Serialize)]
pub fn derive_serialize(input: TokenStream) -> TokenStream { ... }

TokenStream 是过程宏世界的"字节流"——所有输入都长这样、所有输出也必须是这样。本章要把 TokenStream 彻底拆开——它由什么组成、如何构造、如何修改、为什么有 proc_macro::TokenStream 和 proc_macro2::TokenStream 两份几乎一样但不相等的东西。

理解 TokenStream 是过程宏一切的起点。往上接 syn(把 TokenStream 解析成结构化 AST),往下接 quote(用模板生成 TokenStream),中间是你的业务逻辑。本章只讲最底层——裸的 token 流是什么。

6.2 TokenStream 的四个元素:TokenTree

Rust 的 token 系统有个漂亮的简化设计——无论多复杂的 Rust 源码,都可以归结为四种 TokenTree:

// proc-macro2/src/lib.rs:578
pub enum TokenTree {
    Group(Group),        // 用括号/方括号/花括号包裹的嵌套 token 流
    Ident(Ident),        // 标识符(变量名、类型名、关键字)
    Punct(Punct),        // 单个标点字符(+、,、:、$ 等;->、:: 这类多字符运算符拆成多个 Punct)
    Literal(Literal),    // 字面量(42、"hello"、'a'、0xff、3.14)
}

四种的辨识规则:

类别 例子 特征
Ident User、fn、match、self 字母/下划线开头的标识符(Rust 1.53 起允许非 ASCII 标识符——rustc 源码 compiler/rustc_feature/src/accepted.rs 第 334 行把 non_ascii_idents 记为 1.53.0 accepted;r#fn 这类 raw identifier 也是 Ident)
Punct +、,、:、;、#、- 单字符标点(多字符标点拆成多个 Punct)
Literal 42、"hello"、'a'、0b101 数字、字符串、字符等字面值
Group { ... }、[ ... ]、( ... ) 带分隔符的嵌套 TokenStream

关键洞察 1:关键字和标识符同属 Ident。

struct User { ... }

在 token 层,struct 和 User 都是 Ident——它们没有区别。Rust 的关键字体系是由后续语法分析器(parser)识别的,而不是 lexer。这意味着过程宏可以在 token 层"看不见"关键字和普通标识符的差异;syn 在解析时才把 Ident("struct") 识别为 Token![struct]。

关键洞察 2:多字符标点被拆成多个 Punct。

x -> y      // 四个 token:Ident("x") Punct('-', joint) Punct('>', alone) Ident("y")
a::b         // 四个:Ident("a") Punct(':', joint) Punct(':', alone) Ident("b")
x += 1       // Punct('+', joint) Punct('=', alone)

每个 Punct 都只包含一个字符,但带一个 spacing 标志——Joint 表示这个字符后面紧跟着另一个标点字符(中间没有空格),Alone 表示后面是空格、标识符、括号等其他东西(+ident 里的 + 也是 Alone)。这让 ->(Joint - + Alone >)能和 - >(- 后面有空格,Alone)区分。

看 proc-macro2 对 Punct 的定义:

// proc-macro2/src/lib.rs:819
pub struct Punct {
    ch: char,
    spacing: Spacing,
    span: Span,
}

pub enum Spacing {
    Alone,    // 后面有空格或别的非 Punct
    Joint,    // 后面紧跟另一个 Punct
}

这种"一个字符一个 Punct"的设计看起来繁琐,但它给了过程宏最大灵活度——你可以接受任意奇怪的标点组合作为自定义语法。syn 在上层把 ->、::、== 等聚合回多字符 token。

关键洞察 3:Group 是唯一的嵌套结构。

if x > 0 { println!("positive"); }

把它拆成 token:

Ident("if") Ident("x") Punct('>') Literal("0")
Group<Brace>{
    Ident("println") Punct('!')
    Group<Paren>{
        Literal("positive")
    }
    Punct(';')
}

Group 内部装着一个 TokenStream(嵌套结构)。这让 token 树自然递归——整个 Rust 源代码是一棵由 Group 嵌套的树。Group 的 Delimiter 有四种(proc-macro2/src/lib.rs:685-711):Parenthesis((...))、Brace({...})、Bracket([...])、None。None 是不可见分隔符——proc-macro2/src/lib.rs:694 的注释原文是「An invisible delimiter, that may, for example, appear around tokens」,下一行接着说它来自 macro_rules! 的「macro variable」$var;存在的意义是保住 $var * 3($var 是 1 + 2)这类表达式的运算符优先级;同一段注释还提醒:它过不了字符串往返,而且过程宏自己造出来的 None 分组当前会被 rustc 忽略(rust-lang/rust#67062),所以在过程宏输出里应当用另外三种。

整个 TokenStream 结构可以看作一棵树:

graph TD
    TS[TokenStream]
    TS --> I1[Ident &quot;struct&quot;]
    TS --> I2[Ident &quot;User&quot;]
    TS --> G1[Group Brace]
    G1 --> IS1[TokenStream]
    IS1 --> I3[Ident &quot;id&quot;]
    IS1 --> P1[Punct : ]
    IS1 --> I4[Ident &quot;u64&quot;]
    IS1 --> P2[Punct ,]
    IS1 --> I5[Ident &quot;name&quot;]
    IS1 --> P3[Punct : ]
    IS1 --> I6[Ident &quot;String&quot;]
    IS1 --> P4[Punct ,]
    style G1 fill:#fff0d0
    style TS fill:#e0f0ff
    style IS1 fill:#e0f0ff

根是 TokenStream(线性序列),每个 Group 又内嵌一个 TokenStream。这种"扁平线性序列 + Group 嵌套"的组合是 token 层的全部结构。 过程宏拿到的就是这棵树。

6.3 两个 TokenStream:proc_macro vs proc_macro2

这是 Rust 过程宏生态里最令人困惑的一个设计。你会在源码里同时看到:

  • proc_macro::TokenStream(来自标准库 proc_macro crate)
  • proc_macro2::TokenStream(来自第三方 proc-macro2 crate)

它们结构几乎完全一样——都有 TokenTree、Ident、Punct、Group、Literal。但它们是两个不同的类型,不能互相赋值,需要用 .into() 或 .to_string().parse() 转换。

为什么有两个?

proc_macro 是随编译器发布的 sysroot crate。它的类型只是编译器内部 token 数据的句柄:任何 crate 都能 extern crate proc_macro 把它链接进来,但它的 API 只有在过程宏被 rustc 调用的那一刻才能工作——在普通程序、单元测试、build.rs 里调用 proc_macro::Span::call_site() 这类函数会直接 panic(proc_macro::is_available() 就是用来查这件事的)。

这带来三个实际问题:

问题 1:测试困难。 你写了一个过程宏函数,想写单元测试验证逻辑。但过程宏函数签名是 fn(TokenStream) -> TokenStream——这个 TokenStream 来自编译器,普通测试代码构造它只会 panic。

问题 2:工具库无法共享。 你想在 proc-macro crate 和普通库之间共享一些"操作 token"的工具代码。但工具代码不能用 proc_macro::TokenStream——它只在编译器正在展开宏的时候可用。

问题 3:稳定性。 proc_macro::TokenStream 的 API 随 Rust 版本演化,某些方法是 unstable 的(需要 nightly)。第三方库如果直接用它,可能在某些 Rust 版本下不可用。

proc-macro2 是 dtolnay 写的封装层,解决上面所有问题:

proc_macro (编译器内置)
    ↕  proc-macro2 通过运行时探测
proc-macro2 (第三方,可独立使用)
    ↓
syn / quote / 任何宏工具库

proc-macro2 的工作方式:

  • 在 proc-macro crate 里,它是 proc_macro::TokenStream 的薄封装(几乎零开销)。
  • 在过程宏之外运行时(比如测试、build.rs、单独工具库),它用纯 Rust 实现——自己的 lexer、自己的内存数据结构。
  • 它提供 From<proc_macro::TokenStream> 和 Into<proc_macro::TokenStream> 的转换。

看 proc-macro2 的运行时判断(来自 proc-macro2/src/detection.rs,简化):

fn inside_proc_macro() -> bool {
    // 调用 proc_macro::is_available()(老编译器上改用 catch_unwind 探测),结果缓存起来
    ...
}

这让一个 crate 既能在 proc-macro 环境用真实 TokenStream,又能在普通环境用 fallback 实现。同一套 API 双环境无缝切换。

结果:Rust 过程宏生态有一条黄金法则:

proc-macro crate 的入口和出口用 proc_macro::TokenStream,中间所有处理用 proc_macro2::TokenStream。

看 serde_derive 的实际做法:

// serde_derive/src/lib.rs:77-78(use)+ 113-119(derive_serialize)
use proc_macro::TokenStream;            // 编译器 TokenStream
use proc_macro2::{Ident, Span};          // proc-macro2 的工具

#[proc_macro_derive(Serialize, attributes(serde))]
pub fn derive_serialize(input: TokenStream) -> TokenStream {
    //                        ↑ 编译器版
    let mut input = parse_macro_input!(input as DeriveInput);  // syn 内部转 proc-macro2
    ser::expand_derive_serialize(&mut input)
        .unwrap_or_else(syn::Error::into_compile_error)
        .into()  // proc-macro2 → proc_macro 的转换
}

入口 TokenStream 是 proc_macro::TokenStream,但 parse_macro_input! 内部立刻转成 proc-macro2 的类型。出口用 .into() 转回。你写过程宏时只需要记住这条规则,不需要纠结两者区别——除了最外层一层,其余都是 proc-macro2。

6.4 Span:错误信息的精准定位

除了 TokenStream 结构本身,还有一个关键组件:Span——"位置信息"。

每一个 TokenTree 都带一个 Span:

// proc-macro2/src/lib.rs:390
pub struct Span {
    inner: imp::Span,
    _marker: ProcMacroAutoTraits,   // 和 TokenStream 一样,用 PhantomData<Rc<()>> 去掉 Send/Sync(见 6.9)
}

Span 记录这个 token "从哪里来的"——源文件的哪一行、哪一列。它不在运行时有任何作用;在编译期,它决定报错指向哪里,也决定标识符按哪套卫生性(hygiene)规则解析(见下文 mixed_site)。

Span 的三种常见来源:

  1. Span::call_site():指向宏调用位置。用户代码里 #[derive(Serialize)] 那一行。
  2. Span::mixed_site()(proc-macro2/src/lib.rs:419-424):混合 site——源码注释写的是「local variables, labels, and $crate resolved at the definition site of the macro. This is the same hygiene behavior as macro_rules」:局部变量、标签、$crate 按宏定义处解析(不会和用户作用域里的同名变量撞上),其余名字按调用处解析。
  3. 继承输入 token 的 span:如果你从输入 TokenStream 里拿到一个 Ident 并放到输出中,它自带原始 span,错误会指向用户真正写的那行。

Span 的实际意义:想象用户写了错的 serde derive:

#[derive(Serialize)]
struct BadType {
    data: NotASerializableType,  // 这个类型没实现 Serialize
}

serde_derive 生成的代码会调用 _serde::ser::SerializeStruct::serialize_field(&mut __serde_state, "data", &self.data)。编译器做类型检查时发现 NotASerializableType 没有 Serialize 实现,报错。如果这段生成代码里有 token 的 span 继承自用户 data: NotASerializableType 那一行,编译器就能在报错里把这个字段标出来,用户一眼知道是哪个字段出了问题;如果 span 全是调用处的,报错只能停在宏调用行,用户得自己去猜是哪个字段。

这就是为什么过程宏的 span 管理是一门手艺。 serde_derive 在生成代码时精心设置每一处 span:serialize_field 这条函数路径用原字段的 span;模板骨架部分用 Span::call_site()。这种精细工作让 Serde 的错误信息能把用户写错的字段标出来。

看 serde_derive 里 span 管理的实际做法(serde_derive/src/ser.rs:1136-1147):

        let span = field.original.span();
        let ser = if field.attrs.flatten() {
            let func = quote_spanned!(span=> _serde::Serialize::serialize);
            quote! {
                #func(&#field_expr, _serde::#private::ser::FlatMapSerializer(&mut __serde_state))?;
            }
        } else {
            let func = struct_trait.serialize_field(span);
            quote! {
                #func(&mut __serde_state, #key_expr, #field_expr)?;
            }
        };

field.original.span() 取的是用户写这个字段的位置;struct_trait.serialize_field(span) 内部用 quote_spanned!(span=> ...)(serde_derive/src/ser.rs:1322-1334)把这个 span 焊到生成的 serialize_field 路径上。于是类型错误的主标注仍在 #[derive(Serialize)] 那一行(E0277),用户的字段上则多出一条次标注。下面是 rustc 1.96.0 的实测输出(节选,第 12 章 §12.5 有同样的结论):

error[E0277]: the trait bound `NotASerializableType: serde::Serialize` is not satisfied
    --> src/main.rs:3:10
     |
   3 | #[derive(Serialize)]
     |          ^^^^^^^^^ unsatisfied trait bound
   4 | struct BadType {
   5 |     data: NotASerializableType,
     |     ---- required by a bound introduced by this call

注意 quote_spanned! 只包住函数路径那几个 token,其余骨架仍用 quote!(默认 Span::call_site())。因为 trait bound 不满足的错误是报在"调用了哪个函数"上的——把 serialize_field 这条路径的 span 设成字段的 span,NotASerializableType: Serialize 不满足时,编译器就能在那个字段上标出次标注,帮读者定位到出问题的字段。

第 8 章讲 quote 时会深入 quote! vs quote_spanned! 的差异。这里只需知道:Span 不是装饰,它决定了过程宏的错误信息是否人性化。

6.5 TokenStream 的构造与操作

拿到 TokenStream 后能做什么?

操作 1:遍历。

use proc_macro2::{TokenStream, TokenTree};

fn count_identifiers(ts: TokenStream) -> usize {
    let mut count = 0;
    for tree in ts {
        match tree {
            TokenTree::Ident(_) => count += 1,
            TokenTree::Group(g) => count += count_identifiers(g.stream()),
            _ => {}
        }
    }
    count
}

TokenStream 实现了 IntoIterator<Item = TokenTree>——你可以用 for 循环遍历。Group 里面嵌套的 TokenStream 要递归处理。

操作 2:从字符串解析。

let ts: TokenStream = "fn generated() {}".parse().unwrap();

TokenStream 实现了 FromStr——可以把 Rust 源码字符串解析成 TokenStream。这是最简单(但最粗糙)的代码生成方式——把你要生成的代码拼成字符串然后 parse。

为什么这种方式粗糙? 因为字符串拼接失去了结构信息。看这个例子:

let name = "User";
let code = format!("struct {} {{}}", name);
let ts: TokenStream = code.parse().unwrap();

能工作,但如果 name 包含空格,parse 照样成功,只是悄悄生成了语法错误的代码,要到编译器解析时才报错;如果带了不配对的括号或引号这类词法错误,parse 返回 Err,unwrap 直接 panic。更糟的是——所有生成的 token 都没有有意义的 Span:在过程宏里从字符串解析出的 token 一律带 Span::call_site()(compiler/rustc_expand/src/proc_macro_server.rs:570-577),错误只能指向宏调用处,用户看不出是哪段生成代码出了问题。

操作 3:用 quote! 模板生成。

use quote::{format_ident, quote};

let name = format_ident!("User");
let ts = quote! {
    struct #name {}
};

quote! 是第 8 章的主角。它像"Rust 里的模板字符串"——#name 是插值点,会被替换成 name 的值。模板里写死的 token 默认带 Span::call_site(),插进来的 token 保留自己的 Span,是实际过程宏开发的标准工具。

操作 4:手动构造。

use proc_macro2::{TokenStream, TokenTree, Ident, Punct, Group, Spacing, Delimiter, Span};

let mut ts = TokenStream::new();
ts.extend([
    TokenTree::Ident(Ident::new("struct", Span::call_site())),
    TokenTree::Ident(Ident::new("User", Span::call_site())),
    TokenTree::Group(Group::new(Delimiter::Brace, TokenStream::new())),
]);

手动构造极少用——太繁琐。但知道可以这样做,有助于理解 quote 实际做什么。

6.6 TokenStream 的可变性与 extend

TokenStream 主要是不可变数据结构,但支持增量追加:

let mut ts = TokenStream::new();
ts.extend(quote! { fn foo() {} });
ts.extend(quote! { fn bar() {} });
// 现在 ts 里有两个函数定义

extend 接受任何产出 TokenTree 或 TokenStream 的迭代器(TokenStream 本身就是 IntoIterator<Item = TokenTree>,所以能直接传进去)。这是 "增量生成代码"的标准做法——循环里不断追加新 token。

看 serde_derive 里的 TokenStreamExt 用法(来自 serde_derive/src/lib.rs:79):

use quote::{ToTokens, TokenStreamExt as _};

TokenStreamExt 来自 quote crate(quote/src/ext.rs),给 TokenStream 加了一组便利方法:append(x)、append_all(xs)、append_separated(xs, sep)、append_terminated(xs, term)。这些方法让代码生成读起来更自然。

6.7 Ident:不只是字符串

Ident 看起来就是一个标识符(字符串),但它有一些不直观的细节:

Ident 带 Span。

let id = Ident::new("User", Span::call_site());
let id2 = Ident::new("User", some_field.span());
// 这两个 Ident 内容相同但 span 不同——在错误信息里指向不同位置

Ident 必须是合法标识符。

Ident::new("123bad", Span::call_site());  // panic!不能数字开头
Ident::new("foo bar", Span::call_site()); // panic!不能有空格
Ident::new("fn", Span::call_site());      // 合法——关键字也是 Ident(`proc-macro2/src/lib.rs:1012-1013` 的文档原话:"Panics if the input string is neither a keyword nor a legal variable name")

关键字可以做 Ident(它在 token 层和普通标识符没区别,见 6.2)。但如果你需要一个名字叫 fn 的变量或字段(比如从用户数据动态生成),就必须用 raw identifier,否则 parser 会把它当关键字:

Ident::new_raw("fn", Span::call_site());  // 生成 r#fn,一个合法的变量名

Ident 的拼接需要 format_ident!

use quote::format_ident;

let base = format_ident!("user");
let getter = format_ident!("get_{}", base);  // get_user
let setter = format_ident!("set_{}", base);  // set_user

想做 "生成 get_foo、set_foo 这种基于基础名的派生标识符",format_ident! 是标准工具。这种能力对 builder 模式、ORM 等至关重要——第 9 章写第一个 derive 宏时会密集用到。

6.8 Literal:数字和字符串的 token 表示

Literal 覆盖所有字面量:

use proc_macro2::Literal;

Literal::i32_suffixed(42);          // 42i32
Literal::u64_unsuffixed(100);       // 100
Literal::string("hello");           // "hello"
Literal::character('a');            // 'a'
Literal::byte_string(b"bytes");     // b"bytes"
Literal::f64_suffixed(3.14);        // 3.14f64

suffixed 版本带类型后缀(42i32),unsuffixed 不带(42)。在生成代码时根据上下文选择——如果后续类型推导明确,用 unsuffixed;如果需要强制类型,用 suffixed。

Literal 是过程宏里相对少用的类型——大部分情况下字面量从输入 TokenStream 原样传递到输出。

6.9 从源码视角理解 TokenStream 的内部实现

proc-macro2 怎么做到"proc-macro 环境用真 TokenStream、其他环境用 fallback"?看它的关键架构(proc-macro2/src/lib.rs:211-214):

// proc-macro2/src/lib.rs:211-214(v1.0.107)
pub struct TokenStream {
    inner: imp::TokenStream,
    _marker: ProcMacroAutoTraits,
}

第二个字段是关键。ProcMacroAutoTraits 定义在 proc-macro2/src/marker.rs:12,只有一行:

pub(crate) struct ProcMacroAutoTraits(PhantomData<Rc<()>>);

Rc<()> 既不是 Send 也不是 Sync,把它包进 PhantomData 塞进结构体, 就让 TokenStream 也失去这两个自动 trait——这正是"过程宏的类型不能跨线程" 在类型系统层面的落实,而且不花任何运行时代价。

imp::TokenStream 在 proc-macro2/src/wrapper.rs 里定义为一个 enum:

// proc-macro2/src/wrapper.rs:25-28(右侧中文注释是本专栏加的)
pub(crate) enum TokenStream {
    Compiler(DeferredTokenStream),    // 编译器提供的真实 TokenStream
    Fallback(fallback::TokenStream),  // 自己实现的 fallback
}

Compiler 装的不是裸的 proc_macro::TokenStream,而是 DeferredTokenStream(proc-macro2/src/wrapper.rs:34-38)——它把 proc_macro::TokenStream 和一个 Vec<proc_macro::TokenTree> 放在一起,为的是把 quote 高频调用的 Extend 攒成批再一次性交给编译器;源码里那条注释指向 rust-lang/rust#65080。

构造时运行时判断当前在哪个环境,之后每个操作都按这个 enum 的变体分派到对应实现:

// proc-macro2/src/wrapper.rs:89-96
impl TokenStream {
    pub(crate) fn new() -> Self {
        if inside_proc_macro() {
            TokenStream::Compiler(DeferredTokenStream::new(proc_macro::TokenStream::new()))
        } else {
            TokenStream::Fallback(fallback::TokenStream::new())
        }
    }
    // ...(同一个 impl 块里还有几十个同样按变体分派的方法)

性能影响:这条分支会出现在构造路径上,但它的主要价值不是极限性能,而是让同一套 API 同时覆盖 proc-macro 上下文和普通测试/构建脚本上下文。是否构成瓶颈要以具体宏的基准为准;对 derive 宏来说,通常更大的成本在 syn 解析、属性处理和 quote 生成。

6.9.1 inside_proc_macro() 的真实实现:一个 atomic + 两条路径

上面那个 if inside_proc_macro() 看似简单,真实代码(proc-macro2/src/detection.rs,全文 75 行)是一小段教科书级的"跨环境探测 + 线程安全缓存"。核心部分:

// proc-macro2/src/detection.rs:4-16(右侧中文注释是本专栏加的)
static WORKS: AtomicUsize = AtomicUsize::new(0);
static INIT: Once = Once::new();

pub(crate) fn inside_proc_macro() -> bool {
    match WORKS.load(Ordering::Relaxed) {
        1 => return false,
        2 => return true,
        _ => {}
    }

    INIT.call_once(initialize);
    inside_proc_macro()      // 递归一次、走缓存
}

三态 AtomicUsize 替代 AtomicBool:0 表示"还没探测过"、1 表示"确定不在 proc-macro 环境"、2 表示"确定在"。直接用 AtomicBool 的话,第一次调用时无法区分"false 表示没探测过"和"false 表示探测过确定不在"——要再搭一个布尔记录"是否初始化过",多一次原子读。用 AtomicUsize 三态把"已缓存 / 未缓存"和"在/不在"两维信息挤进一个字长的变量、一次 Relaxed load 搞定——热路径上只有一次原子读。

INIT: Once 是线程安全的"只初始化一次"原语——多线程同时撞进 inside_proc_macro() 时,只有第一个能跑 initialize、其余等它完成。Relaxed ordering 够用,因为缓存值的读者不依赖 initialize 里其他内存操作的 happens-before。

两条初始化路径。新 Rust(proc_macro::is_available 1.57+)上初始化很平凡:

#[cfg(not(no_is_available))]
fn initialize() {
    let available = proc_macro::is_available();
    WORKS.store(available as usize + 1, Ordering::Relaxed);
}

老 Rust 上没有 is_available(build.rs 在 rustc 低于 1.57 时才打开 no_is_available;1.0.107 声明的 rust-version 已是 1.71,这条路径如今只剩历史意义),要靠"调 proc_macro 的某个 API、用 catch_unwind 看它会不会 panic"来探测。这条 #[cfg(no_is_available)] 路径不到 20 行代码,前面却顶着一段 20 多行的注释——注释比代码还长,讲的是这段代码怎么被 panic hook 坑:

// proc-macro2/src/detection.rs:56
// 老 Rust 路径
#[cfg(no_is_available)]
fn initialize() {
    use std::panic::{self, PanicInfo};
    type PanicHook = dyn Fn(&PanicInfo) + Sync + Send + 'static;   // :60

    let null_hook: Box<PanicHook> = Box::new(|_panic_info| { /* ignore */ });
    let sanity_check = &*null_hook as *const PanicHook;
    let original_hook = panic::take_hook();
    panic::set_hook(null_hook);              // 屏蔽 "thread panicked" 输出

    let works = panic::catch_unwind(proc_macro::Span::call_site).is_ok();
    WORKS.store(works as usize + 1, Ordering::Relaxed);

    let hopefully_null_hook = panic::take_hook();
    panic::set_hook(original_hook);          // 恢复用户 hook
    if sanity_check != &*hopefully_null_hook {
        panic!("observed race condition in proc_macro2::inside_proc_macro");
    }
}

关键心思:proc_macro::Span::call_site() 在 proc-macro 外面调会 panic,所以 catch_unwind 抓到 Err 就是"不在 proc-macro 环境",Ok 就是"在"。但没有 panic hook 管理的话,panic 还是会把 "thread panicked at ..." 往 stderr 打——对用户是噪声。所以临时 set_hook(null_hook) 屏蔽输出、探测完 take_hook + set_hook(original_hook) 恢复。

源码里那段注释列了一个 pathological race(精简版):线程 1 take_hook 拿到用户原始 hook → 线程 1 set_hook(null_hook) → 线程 2 take_hook 拿到 null_hook 以为是"原始"hook → 线程 2 set_hook(null_hook) → 线程 1 set_hook(original) → 线程 2 set_hook(null_hook)(以为在还原)——最后用户的 hook 永久丢失。Once::call_once 的作用正是把这个 race 堵死:经 inside_proc_macro() 进来的 initialize 在进程里只会有一个线程执行一次。

末尾 if sanity_check != &*hopefully_null_hook { panic!("observed race condition...") } 是防御性校验——如果 Once 的语义真有 bug、或别人干扰了 panic hook,这个指针比较能当场发现并 fail-fast、而不是让用户的 panic hook 被隐形篡改。

fallback 实现:完全用纯 Rust 写,token 序列存在 RcVec<TokenTree>(即 Rc<Vec<TokenTree>> 的轻量封装)里——proc-macro2/src/fallback.rs:55-57 的 pub(crate) struct TokenStream { inner: RcVec<TokenTree> }。lexer 是手写的(proc-macro2/src/parse.rs,991 行)。它作为"standalone Rust token 解析器",本身就是一个有价值的开源项目。

读懂 detection.rs 的这 75 行,是 Rust 生态一个有趣的切面:一个小小的"当前是不是在 proc-macro 里?"问题,引出了三态原子缓存、Once 初始化、catch_unwind 探测、全局 panic hook 管理、文档化的竞态分析——每一层都是真实工程里会遇到的坑。

6.10 一个完整的例子:手写 HelloWorld 宏

把前面的知识串起来,手写一个过程宏。它接收任何输入、输出一个 fn hello_world() 函数:

// hello-macro/src/lib.rs
use proc_macro::TokenStream;

#[proc_macro]
pub fn hello_world(_input: TokenStream) -> TokenStream {
    let output = "fn hello_world() { println!(\"Hello, World!\"); }";
    output.parse().unwrap()
}

用户端:

hello_macro::hello_world!();

fn main() {
    hello_world();  // 调用宏生成的函数
}

(hello-macro 的 Cargo.toml 要写上 [lib] proc-macro = true,下面 quote 版还要依赖 proc-macro2 和 quote。)

这个例子用最粗糙的"字符串 parse"风格。实际开发会用 quote:

use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;

#[proc_macro]
pub fn hello_world(_input: TokenStream) -> TokenStream {
    let output: TokenStream2 = quote! {
        fn hello_world() {
            println!("Hello, World!");
        }
    };
    output.into()
}

这两个版本功能相同,但第二个:

  • 模板在编译期就要通过词法检查(括号配对、字面量合法),写错了直接编译不过
  • 自动管理 Span
  • 可读性高(模板看起来就是 Rust 代码)
  • 和 proc-macro2 深度集成

这就是为什么 serde_derive 里大量使用 quote!,几乎不用字符串 parse。 第 8 章我们把 quote 拆开看。

6.11 和本系列其他专栏的连接点

TokenStream 和 Span 的概念不只在 Serde 出现。理解它们能帮你读懂 Rust 生态很多其他库:

  • 《Rust 编译器与运行时揭秘》专栏第 14 章详细讲了 rustc 内部的 token 结构(rustc_ast::tokenstream)以及它如何经 proc_macro 的 bridge(library/proc_macro/src/bridge/)交给过程宏——proc-macro2 再在 proc_macro 之上包一层。那边的视角是"编译器数据结构",本章视角是"过程宏 API"。对照看能理解"为什么 proc-macro2 要封装"。
  • 《Tokio 源码深度解析》专栏第 14 章 select! 宏:tokio::select! 的主体是声明宏,但按分支数量声明输出枚举、清洗绑定模式这两件事交给了 tokio-macros 里的过程宏(select_priv_declare_output_enum!、select_priv_clean_pattern!)——它们接收和吐出的就是本章讲的 TokenStream。
  • syn 本身是 proc-macro2 的最大消费者——下一章我们就进入它的世界。

6.12 实测一行:proc-macro2 1.0.107 整 crate 6029 行的真实拆分

打开 proc-macro2 仓库 ed8a549(即本专栏声明的 1.0.107)的 src/——顶层 12 个 .rs 文件 + 一个 probe/ 子目录——

文件 行 角色
lib.rs 1527 公共 API 类型——TokenStream / TokenTree / Group / Ident / Punct / Literal / Span / Delimiter / Spacing 全部对外类型的定义
fallback.rs 1279 第二大文件——非 proc-macro 上下文(test / build.rs / 普通 lib)下的纯 Rust 实现——本章 §6.9.1 提到的 inside_proc_macro() 走 false 时用的就是它
parse.rs 991 字符串 → TokenStream 的 lexer——支持完整 Rust 词法
wrapper.rs 987 proc-macro / fallback 双路径的统一壳——TokenStream、Span、Group、Ident、Literal 各有一个 enum { Compiler(..), Fallback(..) }——本章 §6.3"两个 TokenStream"的真实粘合层
rustc_literal_escaper.rs 701 从 rust-lang/literal-escaper v0.0.5 vendored 进来(文件头注释)——按 Rust Reference 的字面量规则处理字符串转义、Unicode escapes、原始字符串等几十种边缘情况
extra.rs 151 DelimSpan 扩展类型和 invalidate_current_thread_spans
rcvec.rs 146 一个轻量级 Rc<Vec<T>> wrapper——fallback 侧 TokenStream 的内部存储(proc-macro2/src/fallback.rs:55-57);走编译器后端时 token 存在 proc_macro::TokenStream 里,不经过它
detection.rs 75 探测当前是否在 proc-macro 上下文(§6.9.1 atomic)
probe.rs + probe/ 子目录 3 文件 10 + 99 编译期探测当前工具链的 proc_macro::Span 能提供哪些 API(1.88 起稳定的 Span::file()、start() 等,以及仍 unstable 的部分)
marker.rs / num.rs / location.rs 17 + 17 + 29 小工具

两条值得记住的事实:

  1. fallback.rs 1279 行 ≈ 公共 API 层 lib.rs 1527 行的 84%——非编译器后端不是一个简化桩,而是一套完整的实现——印证 §6.3 黄金法则"中间所有处理用 proc-macro2"的工程根基:因为 fallback 实现完整、写非过程宏的代码(如单元测试、build.rs)也能正确处理 token——这是 syn / quote 等下游能在 test 里直接断言 TokenStream 的根本前提
  2. rustc_literal_escaper.rs 701 行是 vendored 代码——头部注释明确写"Vendored from rustc-literal-escaper v0.0.5"(rust-lang/literal-escaper,rustc 自己也用它)——这是 proc-macro2 为了精确复刻 rustc 字面量解析行为(包括 \u{...} Unicode 转义、\xNN、C 字符串字面量等)而把整段代码搬过来——而不是加一个依赖——是 Rust 生态"复制一份 < 多一个依赖"的取舍

对比 §10.13(serde_derive)和本节——serde_derive/src/ 全部 28 文件 8969 行 vs proc-macro2/src/ 全部 15 文件 6029 行——光这两个 crate 加起来就有 14998 行(还没算 syn、quote),#[derive(Serialize)] 才得以发生——大部分 Rust 用户每天都在用、绝大多数从不会读到——这就是"基础设施"的字面意义。

6.13 serde_derive 里的 Fragment:TokenStream 不是只有一种插入位置

学 TokenStream 时很容易把它想成"一段 Rust 代码字符串"。serde_derive/src/fragment.rs 反过来提醒我们:同一段 token 要插入到不同语法位置时,规则不同。

serde_derive/src/fragment.rs:5-11 定义了一个很小的 enum:

Variant 含义 典型插入位置
Fragment::Expr(TokenStream) 可以当表达式使用的 token match arm 右侧、函数返回值
Fragment::Block(TokenStream) 可以当 block 语句使用的 token 函数体、if 分支、visitor 方法体

这看起来只是包装,但后面的 ToTokens 实现说明它解决的是语法边界问题。serde_derive/src/fragment.rs:27-37 的 Expr wrapper 在遇到 Block 时会主动加 { ... };serde_derive/src/fragment.rs:40-48 的 Stmts wrapper 把 block 内容当语句序列插入;serde_derive/src/fragment.rs:52-65 的 Match wrapper 对表达式补逗号,对 block 补花括号。同文件 13-23 行的 quote_expr!/quote_block! 两个小宏则是生成侧的入口——ser.rs/de.rs 里到处是它们。宏生成器真正害怕的不是"生成不了 token",而是"token 放到某个语法位置后差一个逗号或一层花括号"。

flowchart TD
  A[生成逻辑返回 Fragment] --> B{插入位置}
  B -->|表达式位置| C[Expr wrapper]
  B -->|语句块位置| D[Stmts wrapper]
  B -->|match arm 右侧| E[Match wrapper]
  C --> F[必要时补花括号]
  D --> G[保留语句序列]
  E --> H[表达式补逗号或 block 补花括号]

这和第 8 章的 quote 主题直接相连。quote! 负责把模板变成 TokenStream,但它不知道这段 TokenStream 将来要被放进表达式、语句块还是 match arm。Fragment 把"生成了什么"和"放在哪里"分开,使 de.rs / ser.rs 可以返回语义片段,而不必在每个调用点手写标点修补。

一个具体例子是第 13 章要看的 expr_is_missing(serde_derive/src/de.rs:766)。缺字段时,有的分支返回 _serde::__private228::de::missing_field(name)? 这种表达式,有的分支(字段带 deserialize_with 时)返回 return _serde::__private228::Err(...) 这种控制流表达式(__private228 是源码里 #private 展开后的名字,见第 10 章)。它们都先用 quote_expr! 包装成 Fragment::Expr,最后由插入位置决定如何吐 token。这个小文件只有 74 行,却承担了宏工程中很关键的职责:防止模板局部正确、拼起来语法错误。

写自己的过程宏时,遇到"某些分支生成表达式,某些分支生成 block"不要急着拼字符串,也不要在每个 quote 调用点复制花括号逻辑。像 Fragment 这样先定义一个小型中间表示,再为不同插入位置实现 ToTokens,往往能把复杂度压下去。

这也是读宏源码时的一个方法论:不要只搜 quote!。真正决定生成代码形状的,经常是 Fragment、Parameters、FieldWithAliases 这种中间小类型。quote! 只是最后打印 token 的地方;中间类型才记录了"这段 token 是表达式还是语句"、"这个字段是否有 alias"、"这个 impl 是否需要 'de"。从第 10 章开始读 serde_derive,如果只看大模板,会觉得到处都是符号;先找这些小类型,控制流会清楚很多。

6.14 本章小结

TokenStream 是过程宏的"字节流"。它的结构可以用三句话概括:

  1. 扁平线性序列 + 嵌套 Group = 所有 Rust 源码都能表达。
  2. 四种 TokenTree(Group/Ident/Punct/Literal)覆盖所有 token。
  3. Span 附着每个 token,决定错误信息的准确性。

两个 TokenStream 的分工:

  • proc_macro::TokenStream:编译器内置、只在过程宏执行期间可用、部分 API 仍需 nightly。
  • proc_macro2::TokenStream:第三方封装、任何 crate 可用、稳定 API、自动适配环境。

黄金法则:proc-macro crate 入口出口用 proc_macro,中间所有处理用 proc-macro2。

TokenStream 是一切的起点,但它也是最底层的表达。对过程宏实际开发来说,你几乎不会手动构造 TokenTree——因为太繁琐、太容易出错。真实工程里:

  • 输入用 syn 解析成结构化 AST(第 7 章)
  • 输出用 quote 从模板生成(第 8 章)
  • 你的业务代码只在 AST 层工作,处理"字段列表"、"类型引用"等高层对象

本章是工具层的地基。下一章我们走到工具链的第一层——syn。你会看到如何把"一堆 token"变成"一棵 AST",如何从 DeriveInput 里提取字段、类型、泛型、属性。这是 serde_derive 每一次调用的第一步。

动手实验

  1. 统计 token 数量。写一个函数,接收 TokenStream,递归统计所有 TokenTree 的总数(Group 内部的要算)。测试它对 struct User { id: u64, name: String } 的结果——验证你对"多字符标点会被拆分"的理解。

  2. 观察 Span 效果。写一个过程宏,接收一个表达式,生成 compile_error!("oops")。分两个版本:一个 span 用 Span::call_site(),另一个从输入继承。对比两个版本的错误指向位置。

  3. 手动构造 hello_world。不用 quote,完全用 TokenStream::new() + extend + 手动构造 TokenTree::Ident/Group/... 实现第 6.10 节的 hello_world!() 宏。这会让你深刻体会到 quote 有多宝贵。

  4. Debug 输出 TokenStream。proc_macro2::TokenStream 实现了 Display。在你的过程宏里 eprintln!("{}", input) 看看实际收到的 token 长什么样。(注意:eprintln! 在过程宏里能打印到 cargo build 的 stderr。)

延伸阅读

  • proc-macro2 文档:最权威的 API 参考。
  • The proc-macro2 source:实测 v1.0.107 6029 行(v1.0.78 是 4882 行)——增量主要来自后来 vendored 进来的 rustc_literal_escaper.rs(701 行字面量转义解析)和 probe 子模块多份 cfg-gated 版本——读一遍对"如何写一个 token 解析器"有完整认识。
  • Rust 编译器内的 TokenStream:看看 proc_macro 背后编译器内部的那一版长什么样。
  • proc-macro-hack 的历史:这个已废弃的 crate 记录了 Rust 1.45 之前函数式过程宏还不能用在表达式位置时的 workaround(README 原话:As of Rust 1.45 this crate is superseded)。读它的 README 是一次时光机之旅。
  • 《Rust 编译器与运行时揭秘》专栏第 14 章 "宏系统:编译期的元编程引擎":从编译器视角讲 TokenStream 如何进入 AST、hygiene 如何在 Span 层工作。本章是"我作为宏作者看到什么",那一章是"编译器内部做什么",对照阅读效果最好。