Appearance
第 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(",")
}每一个 Ident、Punct、Group 是一个 TokenTree。它们串起来是 TokenStream。过程宏函数的输入和输出都是这东西:
rust
#[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:
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)
}四种的辨识规则:
| 类别 | 例子 | 特征 |
|---|---|---|
Ident | User、fn、match、self | 字母/下划线开头的连续 ASCII 字母数字 |
Punct | +、,、:、;、=>、- | 单字符标点(多字符标点拆成多个 Punct) |
Literal | 42、"hello"、'a'、0b101 | 数字、字符串、字符等字面值 |
Group | { ... }、[ ... ]、( ... ) | 带分隔符的嵌套 TokenStream |
关键洞察 1:关键字和标识符同属 Ident。
rust
struct User { ... }在 token 层,struct 和 User 都是 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_macrocrate)proc_macro2::TokenStream(来自第三方proc-macro2crate)
它们结构几乎完全一样——都有 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 的转换
}入口 TokenStream 是 proc_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 的三种常见来源:
Span::call_site():指向宏调用位置。用户代码里#[derive(Serialize)]那一行。Span::mixed_site():混合 site——一种特殊的 hygiene 策略,让标识符在宏内部唯一(不与用户作用域冲突)。- 继承输入 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_foo、set_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.14f64suffixed 版本带类型后缀(42i32),unsuffixed 不带(42)。在生成代码时根据上下文选择——如果后续类型推导明确,用 unsuffixed;如果需要强制类型,用 suffixed。
Literal 是过程宏里相对少用的类型——大部分情况下字面量从输入 TokenStream 原样传递到输出。
6.9 从源码视角理解 TokenStream 的内部实现
proc-macro2 怎么做到"proc-macro 环境用真 TokenStream、其他环境用 fallback"?看它的关键架构(来自 proc-macro2/src/lib.rs:211 和 src/wrapper.rs):
rust
// proc-macro2/src/lib.rs:211
pub struct TokenStream {
inner: marker::ImplNotSendNorSync<imp::TokenStream>,
}imp::TokenStream 在 src/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 替代 AtomicBool:0 表示"还没探测过"、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.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 | 988 | proc-macro / fallback 双路径的 enum Bridge { Compiler, Fallback } 统一壳——本章 §6.3"两个 TokenStream"的真实粘合层 |
rustc_literal_escaper.rs | 701 | 从 rustc 内部库 vendored 进来——按 RFC 3349 处理字符串字面量转义、Unicode escapes、原始字符串等几十种边缘情况 |
extra.rs | 151 | DelimSpan / Spans 等扩展类型 |
rcvec.rs | 146 | 一个轻量级 Rc<Vec<T>> wrapper——TokenStream 内部存储 |
detection.rs | 75 | 探测当前是否在 proc-macro 上下文(§6.9.1 atomic) |
probe/ 子目录 4 文件 | 109 | 编译期探测 rustc 提供的 unstable API(Span::file() 等) |
marker.rs / num.rs / location.rs | 17 + 17 + 29 | 小工具 |
两条值得记住的物理事实——
fallback.rs1279 行 ≈lib.rs1527 行的 84%——proc-macro2 的"非编译器后端"和"编译器后端"几乎对等大小——印证 §6.3 黄金法则"中间所有处理用 proc-macro2"的工程根基:因为 fallback 实现完整、写非过程宏的代码(如单元测试、build.rs)也能正确处理 token——这是 syn / quote 等下游能在 test 里直接断言 TokenStream 的根本前提rustc_literal_escaper.rs701 行是 vendored 代码——头部注释明确写"copied fromrustc_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) | 可以当表达式使用的 token | match arm 右侧、函数返回值 |
Fragment::Block(TokenStream) | 可以当 block 语句使用的 token | 函数体、if 分支、visitor 方法体 |
这看起来只是包装,但后面的 ToTokens 实现说明它解决的是语法边界问题。serde_derive/src/fragment.rs:25-37 的 Expr wrapper 在遇到 Block 时会主动加 { ... };serde_derive/src/fragment.rs:39-48 的 Stmts wrapper 把 block 内容当语句序列插入;serde_derive/src/fragment.rs:50-65 的 Match 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!。真正决定生成代码形状的,经常是 Fragment、Parameters、FieldWithAliases 这种中间小类型。quote! 只是最后打印 token 的地方;中间类型才记录了"这段 token 是表达式还是语句"、"这个字段是否有 alias"、"这个 impl 是否需要 'de"。从第 10 章开始读 serde_derive,如果只看大模板,会觉得到处都是符号;先找这些小类型,控制流会清楚很多。
TokenStream 层的关键能力,最终不是"能生成任意文本",而是"能把语法片段安全地搬到正确位置"。这是过程宏和字符串模板的分水岭。
一旦建立这个视角,后面看到大片宏展开代码时就能分层阅读:底层是 token,上一层是语法片段,再上一层才是 Serde 的字段、变体、属性语义。
这三层不要混读。底层问题通常表现为语法错误,中层问题表现为逗号、花括号、匹配臂位置错误,上层问题才表现为字段名、默认值、生命周期不符合预期。
6.12 本章小结
TokenStream 是过程宏的"字节流"。它的结构可以用三句话概括:
- 扁平线性序列 + 嵌套 Group = 所有 Rust 源码都能表达。
- 四种 TokenTree(Group/Ident/Punct/Literal)覆盖所有 token。
- 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 每一次调用的第一步。
动手实验
统计 token 数量。写一个函数,接收 TokenStream,递归统计所有 TokenTree 的总数(Group 内部的要算)。测试它对
struct User { id: u64, name: String }的结果——验证你对"多字符标点会被拆分"的理解。观察 Span 效果。写一个过程宏,接收一个表达式,生成
compile_error!("oops")。分两个版本:一个 span 用Span::call_site(),另一个从输入继承。对比两个版本的错误指向位置。手动构造 hello_world。不用 quote,完全用
TokenStream::new()+extend+ 手动构造TokenTree::Ident/Group/...实现第 6.10 节的hello_world!()宏。这会让你深刻体会到 quote 有多宝贵。Debug 输出 TokenStream。
proc_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 层工作。本章是"我作为宏作者看到什么",卷一是"编译器内部做什么",对照阅读效果最好。