Skip to content

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

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

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

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

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

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

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

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

6.2 TokenStream 的四个元素:TokenTree

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

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

四种的辨识规则:

类别例子特征
IdentUserfnmatchself字母/下划线开头的连续 ASCII 字母数字
Punct+,:;=>-单字符标点(多字符标点拆成多个 Punct)
Literal42"hello"'a'0b101数字、字符串、字符等字面值
Group{ ... }[ ... ]( ... )带分隔符的嵌套 TokenStream

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

rust
struct User { ... }

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

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

rust
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 表示这个字符和下一个字符连续(没有空格或其他 token 隔开),Alone 表示后面有空格或别的东西。这让 ->(Joint - + Alone >)能和 - >- 后面有空格,Alone)区分。

看 proc-macro2 对 Punct 的定义:

rust
// 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 是唯一的嵌套结构。

rust
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 有四种:Parenthesis(...))、Brace{...})、Bracket[...])、None(无可见边界,用于宏内部)。

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

根是 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 是 Rust 标准库的一部分。它由编译器内部提供,只能在 proc-macro = true 的 crate 里用。它不能在普通 crate 里用——你在非 proc-macro crate 里 use proc_macro::TokenStream 会编译失败。

这带来三个实际问题:

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

问题 2:工具库无法共享。 你想在 proc-macro crate 和普通库之间共享一些"操作 token"的工具代码。但工具代码不能用 proc_macro::TokenStream——它必须在 proc-macro crate 里才可用。

问题 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薄封装(几乎零开销)。
  • 在非 proc-macro crate 里(比如测试、单独工具库),它用纯 Rust 实现——自己的 lexer、自己的内存数据结构。
  • 它提供 From<proc_macro::TokenStream>Into<proc_macro::TokenStream> 的转换。

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

rust
fn inside_proc_macro() -> bool {
    // 通过探测 proc_macro 的某个符号是否可用,判断当前是否在 proc-macro 环境
    ...
}

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

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

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

看 serde_derive 的实际做法:

rust
// serde_derive/src/lib.rs:77-78
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 的转换
}

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

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

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

每一个 TokenTree 都带一个 Span:

rust
// proc-macro2/src/lib.rs:390
pub struct Span { inner: imp::Span }

Span 记录这个 token "从哪里来的"——源文件的哪一行、哪一列。它不在运行时有任何作用,只在编译器报错时用来指向代码位置。

Span 的三种常见来源:

  1. Span::call_site():指向宏调用位置。用户代码里 #[derive(Serialize)] 那一行。
  2. Span::mixed_site():混合 site——一种特殊的 hygiene 策略,让标识符在宏内部唯一(不与用户作用域冲突)。
  3. 继承输入 token 的 span:如果你从输入 TokenStream 里拿到一个 Ident 并放到输出中,它自带原始 span,错误会指向用户真正写的那行。

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

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

serde_derive 生成的代码会调用 self.data.serialize(serializer)。编译器做类型检查时发现 NotASerializableType 没有 Serialize 实现,报错。如果 self.data 那段生成代码的 span 继承自用户 data: NotASerializableType 那一行,错误就指向用户的真实代码——用户看到"这里的 NotASerializableType 没实现 Serialize";如果 span 错了,错误可能指向宏调用行,甚至指向 rustc 内部,用户一脸懵。

这就是为什么过程宏的 span 管理是一门手艺。 serde_derive 在生成代码时精心设置每一处 span:引用 self.data 的那部分代码用原字段的 span;模板骨架部分用 Span::call_site()。这种精细工作让 Serde 的错误信息即使在深度嵌套的泛型场景下也能准确指向用户问题。

看一个 serde_derive 里 span 管理的实际例子(简化版):

rust
// 为字段生成 serialize_field 调用
let field_ident = &field.ident;  // 继承原字段 span
let field_name = field.name();    // 编译期字符串字面量

quote_spanned! {field_ident.span()=>
    state.serialize_field(#field_name, &self.#field_ident)?;
}

quote_spanned! 宏指定生成代码的 span 为 field_ident.span()——如果这里的 serialize_field 调用失败,错误会指向用户定义的那个字段。

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

6.5 TokenStream 的构造与操作

拿到 TokenStream 后能做什么?

操作 1:遍历

rust
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:从字符串解析

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

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

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

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

能工作,但如果 name 包含空格或者非法字符,parse 会 panic。更糟的是——所有生成的 token 都没有有意义的 Span,错误信息指向"字符串字面量"那里,用户看不懂。

操作 3:用 quote! 模板生成

rust
use quote::quote;

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

quote! 是第 8 章的主角。它像"Rust 里的模板字符串"——#name 是插值点,会被替换成 name 的值。它自动管理 Span,类型安全,是实际过程宏开发的标准工具。

操作 4:手动构造

rust
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 主要是不可变数据结构,但支持增量追加:

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

extend 接受任何 IntoIterator<Item = TokenTree>TokenStream。这是 "增量生成代码"的标准做法——循环里不断追加新 token。

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

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

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

6.7 Ident:不只是字符串

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

Ident 带 Span。

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

Ident 必须是合法标识符。

rust
Ident::new("123bad", Span::call_site());  // panic!不能数字开头
Ident::new("foo bar", Span::call_site()); // panic!不能有空格
Ident::new("fn", Span::call_site());      // panic!是关键字

关键字不能直接做 Ident。如果你需要生成一个名字叫 fn 的变量(比如从用户数据动态生成),要用 raw identifier

rust
Ident::new_raw("fn", Span::call_site());  // 生成 r#fn,一个合法标识符

Ident 的拼接需要 format_ident!

rust
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_fooset_foo 这种基于基础名的派生标识符",format_ident! 是标准工具。这种能力对 builder 模式、ORM 等至关重要——第 9 章写第一个 derive 宏时会密集用到。

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

Literal 覆盖所有字面量:

rust
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:211src/wrapper.rs):

rust
// proc-macro2/src/lib.rs:211
pub struct TokenStream {
    inner: marker::ImplNotSendNorSync<imp::TokenStream>,
}

imp::TokenStreamsrc/wrapper.rs 里定义为一个 enum

rust
// 简化版,来自 wrapper.rs
pub(crate) enum TokenStream {
    Compiler(proc_macro::TokenStream),    // 编译器提供的真实 TokenStream
    Fallback(fallback::TokenStream),       // 自己实现的 fallback
}

每次对 TokenStream 操作,都会运行时判断当前在哪个环境,分派到对应实现:

rust
// 简化逻辑
impl TokenStream {
    fn new() -> Self {
        if inside_proc_macro() {
            TokenStream::Compiler(proc_macro::TokenStream::new())
        } else {
            TokenStream::Fallback(fallback::TokenStream::new())
        }
    }
}

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

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

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

rust
// proc-macro2/src/detection.rs:4
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 替代 AtomicBool0 表示"还没探测过"、1 表示"确定不在 proc-macro 环境"、2 表示"确定在"。直接用 AtomicBool 的话,第一次调用时无法区分"false 表示没探测过"和"false 表示探测过确定不在"——要再搭一个布尔记录"是否初始化过",多一次原子读。用 AtomicUsize 三态把"已缓存 / 未缓存"和"在/不在"两维信息挤进一个 8 字节变量、一次 Relaxed load 搞定——在热路径上省一次原子操作的开销

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

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

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

老 Rust 上没有 is_available,要靠"调 proc_macro 的某个 API、用 catch_unwind 看它会不会 panic"来探测。这条 #[cfg(no_is_available)] 路径的 15 行代码里藏着一个 15 行注释——注释比代码还长,讲的是这段代码怎么被 panic hook 坑

rust
// 老 Rust 路径
#[cfg(no_is_available)]
fn initialize() {
    let null_hook: Box<PanicHook> = Box::new(|_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) 恢复。

源码里那 15 行注释列了一个 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 堵死:整个 initialize 在进程里只会有一个线程执行。

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

fallback 实现:完全用纯 Rust 写,基于 Vec 存储 TokenTree。lexer 是手写的(约 1000 行代码在 src/parse.rs)。它作为"standalone Rust token 解析器",本身就是一个有价值的开源项目。

读懂 detection.rs 的这 75 行,是 Rust 生态一个有趣的切面:一个小小的"当前是不是在 proc-macro 里?"问题,引出了三态原子缓存、Once 初始化、catch_unwind 探测、全局 panic hook 管理、文档化的死锁分析——每一层都是真实工程里会遇到的坑。这也是为什么 proc-macro2 能在几乎所有 Rust proc macro 下游中被信任为"就是能工作"的基础设施——表面简洁、内里把每个边缘情况都认真写了。

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

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

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

用户端:

rust
hello_macro::hello_world!();

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

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

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

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

  • 类型安全(quote 的模板检查在编译期做)
  • 自动管理 Span
  • 可读性高(模板看起来就是 Rust 代码)
  • 和 proc-macro2 深度集成

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

6.11 和丛书其他书的连接点

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

  • 丛书卷一《Rust 编译器》第 14 章详细讲了 rustc 内部的 token 结构(rustc_ast::tokenstream),它是 proc-macro2 的"被封装者"。卷一视角是"编译器数据结构",本章视角是"过程宏 API"。对照看能理解"为什么 proc-macro2 要封装"。
  • 丛书《Tokio 源码深度解析》第 14 章 select! 宏虽然是声明宏,但它的模式匹配规则在 token 层是可见的——tokio::select! 接收任意 token,自己判断模式。如果有一天它换成过程宏实现(性能考虑),整个 TokenTree 遍历逻辑会非常像本章讲的那样。
  • syn 本身是 proc-macro2 的最大消费者——下一章我们就进入它的世界。

6.11.1 实测一行:proc-macro2 1.0.106 整 crate 6030 行的真实拆分

打开 ~/.cargo/registry/src/.../proc-macro2-1.0.106/src/——14 个 .rs 文件 + 一个 probe/ 子目录——

文件角色
lib.rs1527公共 API 类型——TokenStream / TokenTree / Group / Ident / Punct / Literal / Span / Delimiter / Spacing 全部对外类型的定义
fallback.rs1279第二大文件——非 proc-macro 上下文(test / build.rs / 普通 lib)下的纯 Rust 实现——本章 §6.9.1 提到的 inside_proc_macro() 走 false 时用的就是它
parse.rs991字符串 → TokenStream 的 lexer——支持完整 Rust 词法
wrapper.rs988proc-macro / fallback 双路径的 enum Bridge { Compiler, Fallback } 统一壳——本章 §6.3"两个 TokenStream"的真实粘合层
rustc_literal_escaper.rs701从 rustc 内部库 vendored 进来——按 RFC 3349 处理字符串字面量转义、Unicode escapes、原始字符串等几十种边缘情况
extra.rs151DelimSpan / Spans 等扩展类型
rcvec.rs146一个轻量级 Rc<Vec<T>> wrapper——TokenStream 内部存储
detection.rs75探测当前是否在 proc-macro 上下文(§6.9.1 atomic)
probe/ 子目录 4 文件109编译期探测 rustc 提供的 unstable API(Span::file() 等)
marker.rs / num.rs / location.rs17 + 17 + 29小工具

两条值得记住的物理事实——

  1. fallback.rs 1279 行 ≈ lib.rs 1527 行的 84%——proc-macro2 的"非编译器后端"和"编译器后端"几乎对等大小——印证 §6.3 黄金法则"中间所有处理用 proc-macro2"的工程根基:因为 fallback 实现完整、写非过程宏的代码(如单元测试、build.rs)也能正确处理 token——这是 syn / quote 等下游能在 test 里直接断言 TokenStream 的根本前提
  2. rustc_literal_escaper.rs 701 行是 vendored 代码——头部注释明确写"copied from rustc_literal_escaper"——这是 proc-macro2 为了精确复刻 rustc 字面量解析行为(包括 \u{...} Unicode 转义、\xNN、原始字符串 r#""# 嵌套引号计数等)而把整段代码搬过来——而不是依赖 rustc 内部库(rustc_* crates 不是 stable API)——是 Rust 生态"复制 < 依赖不稳定 API"的经典取舍

对比 §10.2(serde_derive)和本节——serde_derive 9 文件 8969 行 vs proc-macro2 14 文件 6030 行——两个工具层 crate 加起来 15000 行才让 #[derive(Serialize)] 这件事情发生——大部分 Rust 用户每天都在用、绝大多数从不会读到——这就是"基础设施"的字面意义。

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

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

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

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

这看起来只是包装,但后面的 ToTokens 实现说明它解决的是语法边界问题。serde_derive/src/fragment.rs:25-37Expr wrapper 在遇到 Block 时会主动加 { ... }serde_derive/src/fragment.rs:39-48Stmts wrapper 把 block 内容当语句序列插入;serde_derive/src/fragment.rs:50-65Match wrapper 对表达式补逗号,对 block 补花括号。宏生成器真正害怕的不是"生成不了 token",而是"token 放到某个语法位置后差一个逗号或一层花括号"。

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

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

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

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

TokenStream 层的关键能力,最终不是"能生成任意文本",而是"能把语法片段安全地搬到正确位置"。这是过程宏和字符串模板的分水岭。

一旦建立这个视角,后面看到大片宏展开代码时就能分层阅读:底层是 token,上一层是语法片段,再上一层才是 Serde 的字段、变体、属性语义。

这三层不要混读。底层问题通常表现为语法错误,中层问题表现为逗号、花括号、匹配臂位置错误,上层问题才表现为字段名、默认值、生命周期不符合预期。

6.12 本章小结

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

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

两个 TokenStream 的分工:

  • proc_macro::TokenStream:编译器内置、只能在 proc-macro crate 用、不稳定 API。
  • 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 输出 TokenStreamproc_macro2::TokenStream 实现了 Display。在你的过程宏里 eprintln!("{}", input) 看看实际收到的 token 长什么样。(注意:eprintln! 在过程宏里能打印到 cargo build 的 stderr。)

延伸阅读

  • proc-macro2 文档:最权威的 API 参考。
  • The proc-macro2 source:实测 v1.0.106 6030 行(早期 v1.0.78 也有 4882 行)——比早期文档常引的"2500 行"翻了一倍多——是因为 v1.0 后逐版加入了 rustc_literal_escaper.rs(701 行 RFC 3349 字符串字面量解析)+ probe 子模块多份 cfg-gated 版本——读一遍对"如何写一个 token 解析器"有完整认识。
  • Rust 编译器内的 TokenStream:看看 proc-macro2 封装的"原版"长什么样。
  • proc-macro-hack 的历史:这个已废弃的 crate 记录了 proc-macro2 诞生前 Rust 过程宏生态的种种奇葩 workaround。读它的 README 是一次时光机之旅。
  • 丛书卷一《Rust 编译器与运行时揭秘》第 14 章 "声明宏与过程宏的展开机制":从编译器视角讲 TokenStream 如何进入 AST、hygiene 如何在 Span 层工作。本章是"我作为宏作者看到什么",卷一是"编译器内部做什么",对照阅读效果最好。

基于 VitePress 构建