Skip to content

第14章 宏系统:编译期的元编程引擎

"宏不是文本替换——它是 AST 到 AST 的变换。理解这一点,你才能理解 Rust 宏系统的全部设计决策。"

本章要点

  • Rust 有两套宏系统:声明宏macro_rules!)和过程宏proc_macro),它们在编译器内部走完全不同的路径
  • 宏展开发生在 AST 阶段——语法分析之后、名称解析和类型检查之前
  • 声明宏的模式匹配由一个基于 NFA 的解析器驱动,位于 rustc_expand/src/mbe/macro_parser.rs
  • 卫生性(Hygiene)通过 SyntaxContext 标记链实现,每个标识符携带完整的展开历史
  • 过程宏作为独立的动态库编译和加载,通过 bridge 协议与编译器通信
  • 展开算法采用不动点迭代:反复展开、解析导入、重试,直到所有宏调用都被消解

14.1 宏系统的全景:两种宏,一个目标

在深入实现细节之前,我们先建立宏系统的全景认知。Rust 的宏系统要解决一个根本问题:如何在不牺牲安全性的前提下,让程序员在编译期生成代码

C 语言的预处理器通过文本替换解决了这个问题,但代价是没有类型安全、没有作用域隔离、充满了陷阱。Lisp 的宏系统通过 S 表达式的同相性(homoiconicity)实现了优雅的代码生成,但要求语言本身的语法足够简单。Rust 选择了一条中间路线:宏操作的是 token tree(而非原始文本),生成的是 AST 片段(而非字符串),并且通过卫生性机制保证生成的代码不会意外地捕获或遮蔽外部变量。

14.1.1 声明宏与过程宏的本质区别

两种宏在编译器内部的处理路径截然不同:

维度声明宏(macro_rules!过程宏(proc_macro
定义方式模式匹配规则,写在普通 .rs 文件中独立的 Rust 函数,编译为动态库
输入Token tree,按模式匹配绑定TokenStream,可以任意解析
输出模板替换,填入匹配结果TokenStream,可以任意构造
卫生性部分卫生:局部变量卫生,路径不卫生不卫生:需手动处理 Span
执行时机编译器内部直接展开加载动态库,跨进程执行
能力边界只能做模式匹配和替换可以执行任意 Rust 代码
编译器入口rustc_expand/src/mbe/rustc_expand/src/proc_macro.rs

14.1.2 宏展开在编译流水线中的位置

宏展开发生在编译器流水线的一个非常特殊的位置——语法分析之后、名称解析完成之前。这个位置决定了宏的能力边界:

这个位置意味着:

  1. 宏可以看到 token:因为词法分析已经完成,宏操作的是结构化的 token tree
  2. 宏看不到类型:因为类型检查还没有开始,$x:expr 匹配的只是"语法上像表达式的东西"
  3. 宏看不到完整的名称解析结果:但宏展开和名称解析是交织进行的——展开一些宏可能引入新的 use 语句,而解析 use 语句又可能解锁新的宏

在编译器源码中,这个入口位于 rustc_expand/src/expand.rsMacroExpander::expand_crate 方法:

rust
// compiler/rustc_expand/src/expand.rs
impl<'a, 'b> MacroExpander<'a, 'b> {
    pub fn expand_crate(&mut self, krate: ast::Crate) -> ast::Crate {
        // ... 设置根路径和模块信息 ...
        let krate = self.fully_expand_fragment(AstFragment::Crate(krate))
            .make_crate();
        assert_eq!(krate.id, ast::CRATE_NODE_ID);
        self.cx.trace_macros_diag();
        krate
    }
}

fully_expand_fragment 是整个宏展开引擎的核心——它接收一个 AST 片段,反复展开其中的宏调用,直到没有更多宏需要展开。

14.2 声明宏深入解析:从模式到代码

声明宏(macro_rules!)是 Rust 中最常用的宏形式。它的核心思想是模式匹配:定义一组模式(称为 "matcher"),每个模式对应一个模板(称为 "transcriber")。当编译器遇到宏调用时,它将调用处的 token tree 与各个模式逐一匹配,匹配成功后用模板生成代码。

14.2.1 Token Tree:宏操作的基本单位

在理解声明宏之前,必须先理解 token tree。它不是"token 的列表",而是一个递归结构:每个 token tree 要么是一个普通 token,要么是一个被分隔符(括号、方括号、花括号)包裹的 token tree 序列。

rust
// 对于宏调用 vec![1, 2 + 3, foo()]
// token tree 结构是:
//   Delimited([], [
//     Token(1),
//     Token(,),
//     Token(2),
//     Token(+),
//     Token(3),
//     Token(,),
//     Token(foo),
//     Delimited((), [])    // foo() 中的空括号是嵌套的 token tree
//   ])

在编译器内部,声明宏使用专门的 mbe::TokenTree(位于 rustc_expand/src/mbe.rs),它比通用的 tokenstream::TokenTree 多了几种变体:

rust
// compiler/rustc_expand/src/mbe.rs
pub(crate) enum TokenTree {
    /// 普通 token
    Token(Token),
    /// 分隔的序列,如 ($e:expr) 或 { $e }
    Delimited(DelimSpan, DelimSpacing, Delimited),
    /// Kleene 重复序列,如 $($e:expr)*
    Sequence(DelimSpan, SequenceRepetition),
    /// 元变量引用,如 $var
    MetaVar(Span, Ident),
    /// 元变量声明,如 $var:expr(只出现在匹配侧/LHS)
    MetaVarDecl { span: Span, name: Ident, kind: NonterminalKind },
    /// 元变量表达式,如 ${count(var)}
    MetaVarExpr(DelimSpan, MetaVarExpr),
}

这个类型是宏匹配和转写的基础。MetaVarDecl 只出现在宏定义的左侧(匹配模式),而 MetaVar 出现在右侧(替换模板)。SequenceMetaVarExpr 在两侧都可以出现。

14.2.2 Fragment Specifier:元变量的类型系统

每个元变量声明都有一个 fragment specifier(片段说明符),它告诉宏解析器应该以什么语法规则来匹配输入。这些说明符在编译器中定义为 NonterminalKind

rust
// compiler/rustc_ast/src/token.rs
pub enum NonterminalKind {
    Item,       // 一个完整的 item(fn, struct, impl, ...)
    Block,      // 一个块表达式 { ... }
    Stmt,       // 一条语句
    Pat(..),    // 一个模式
    Expr(..),   // 一个表达式
    Ty,         // 一个类型
    Ident,      // 一个标识符
    Lifetime,   // 一个生命周期 'a
    Literal,    // 一个字面量
    Meta,       // 一个属性内容,如 derive(Debug)
    Path,       // 一个路径,如 std::collections::HashMap
    Vis,        // 可见性修饰符,如 pub(crate)
    TT,         // 一个 token tree(最宽泛的匹配)
}

这些说明符的选择深刻影响宏的行为:

说明符匹配内容可以跟随的 token贪婪程度
expr任意表达式, ; =>非常贪婪
ty任意类型=> , = | ; : > >> [ { as where贪婪
pat任意模式=> , = | if in较贪婪
ident单个标识符几乎任何 token不贪婪
tt单个 token tree任何 token最不贪婪
literal字面量几乎任何 token不贪婪
path路径受限制贪婪
block{...}几乎任何 token自限定
item完整 item几乎任何 token自限定
stmt完整语句; =>贪婪

"可以跟随的 token" 这一列非常重要。Rust 宏系统有严格的 follow-set restriction:在元变量后面,只有特定的 token 才是合法的。这是为了保证宏的匹配是确定性的——如果 $e:expr 后面可以跟任何 token,那编译器就不知道表达式在哪里结束。

14.2.3 NFA 匹配引擎:macro_parser 的实现

声明宏的模式匹配不是简单的正则匹配或递归下降——它是一个基于 NFA(非确定有限自动机)的解析器。这个设计在 macro_parser.rs 的文件头注释中有清晰的说明:

This is an NFA-based parser, which calls out to the main Rust parser
for named non-terminals (which it commits to fully when it hits one
in a grammar). There's a set of current NFA threads and a set of next
ones. Instead of NTs, we have a special case for Kleene star.

核心概念是 matcher position(匹配位置),用一个点 · 表示当前的匹配进度。解析器维护多个"线程",每个线程是一个可能的匹配状态。

rust
// compiler/rustc_expand/src/mbe/macro_parser.rs

/// 匹配位置,表示匹配状态
struct MatcherPos {
    /// 指向 MatcherLoc 数组的索引,代表"点"的位置
    idx: usize,
    /// 已匹配的元变量绑定。使用 Rc 是因为在处理序列时频繁克隆
    matches: Rc<Vec<NamedMatch>>,
}

/// 匹配器中的一个位置单元
pub(crate) enum MatcherLoc {
    Token { token: Token },              // 需要精确匹配的 token
    Delimited,                            // 分隔符开始
    Sequence {                            // 重复序列的入口
        op: KleeneOp,                     // *, +, ?
        num_metavar_decls: usize,         // 序列中的元变量数量
        idx_first_after: usize,           // 序列结束后的位置
        next_metavar: usize,
        seq_depth: usize,
    },
    SequenceKleeneOpNoSep { op: KleeneOp, idx_first: usize },
    SequenceSep { separator: Token },
    SequenceKleeneOpAfterSep { idx_first: usize },
    MetaVarDecl { span: Span, bind: Ident, kind: NonterminalKind, .. },
    Eof,
}

匹配过程是这样运作的。解析器逐个消费输入中的 token,维护三组"线程":

  • cur_mps:当前正在处理的匹配位置
  • next_mps:等待下一个 token 的匹配位置
  • bb_mps:等待一个非终结符(如 $e:expr)的匹配位置
  • eof_mps:已经到达模式末尾的匹配位置

让我们用一个具体例子追踪匹配过程:

模式: a $( a )* a b
输入: a a a a b

步骤 1: 消费第一个 a
  · a $( a )* a b  →  a · $( a )* a b
  执行 epsilon 转换(Descend/Skip):
  next: [a $( · a )* a b]  [a $( a )* · a b]

步骤 2: 消费第二个 a
  cur:  [a $( a · )* a b]  [a $( a )* a · b]
  Finish/Repeat epsilon 转换:
  next: [a $( a )* · a b]  [a $( · a )* a b]  [a $( a )* a · b]

步骤 3: 消费第三个 a(与步骤 2 完全相同的结构)
步骤 4: 消费第四个 a(与步骤 2 完全相同的结构)

步骤 5: 消费 b
  只有 [a $( a )* a · b] 能匹配 b
  eof: [a $( a )* a b ·] — 匹配成功!

这个 NFA 方式的关键优势是:它可以同时跟踪多个可能的匹配路径,而不需要回溯。对于有 Kleene 重复的模式,这避免了指数级的回溯开销。

14.2.4 重复展开(Repetition)的实现

Kleene 重复操作符 $(...)*$(...)+$(...)? 是声明宏中最强大的特性。在编译器中,重复序列由 SequenceRepetition 结构表示:

rust
// compiler/rustc_expand/src/mbe.rs
pub(crate) struct SequenceRepetition {
    /// 序列中的 token tree
    tts: Vec<TokenTree>,
    /// 可选的分隔符(如逗号)
    separator: Option<Token>,
    /// Kleene 操作符
    kleene: KleeneToken,
    /// 序列中捕获的元变量数量
    num_captures: usize,
}

pub(crate) enum KleeneOp {
    ZeroOrMore,   // *
    OneOrMore,    // +
    ZeroOrOne,    // ?
}

匹配成功后,重复中的元变量被收集为 NamedMatch::MatchedSeq——一个匹配结果的向量。在转写(transcribe)阶段,编译器会"展开"这些向量:

rust
// 考虑这个宏:
macro_rules! make_pairs {
    ( $( $key:expr => $val:expr ),* ) => {
        vec![ $( ($key, $val) ),* ]
    };
}

// 调用 make_pairs!(1 => "a", 2 => "b", 3 => "c")
// 匹配后:$key = [1, 2, 3],$val = ["a", "b", "c"]
// 转写时,遍历这两个向量,逐对生成:
//   vec![ (1, "a"), (2, "b"), (3, "c") ]

转写过程由 transcribe 函数实现(位于 mbe/transcribe.rs)。它使用一个显式的栈来处理嵌套结构:

rust
// compiler/rustc_expand/src/mbe/transcribe.rs(简化)
pub(super) fn transcribe<'a>(
    psess: &'a ParseSess,
    interp: &FxHashMap<MacroRulesNormalizedIdent, NamedMatch>,
    src: &mbe::Delimited,       // 宏定义的 RHS(模板)
    src_span: DelimSpan,
    transparency: Transparency,
    expand_id: LocalExpnId,
) -> PResult<'a, TokenStream> {
    let mut tscx = TranscrCtx {
        psess,
        interp,
        marker: Marker { expand_id, transparency, cache: Default::default() },
        repeats: Vec::new(),        // 重复展开的索引栈
        stack: smallvec![Frame::new_delimited(src, src_span, ...)],
        result: Vec::new(),         // 当前正在生成的 token
        result_stack: Vec::new(),   // 嵌套的结果栈
    };

    loop {
        let Some(tree) = tscx.stack.last_mut().unwrap().next() else {
            // 到达序列末尾——检查是否需要继续重复
            // ...
        };

        match tree {
            mbe::TokenTree::Sequence(_, seq_rep) => {
                // 进入重复序列:确定重复次数,压入 repeats 栈
                transcribe_sequence(&mut tscx, seq, seq_rep, interp)?;
            }
            &mbe::TokenTree::MetaVar(sp, ident) => {
                // 替换元变量:从 interp 中查找匹配结果
                transcribe_metavar(&mut tscx, sp, ident)?;
            }
            mbe::TokenTree::MetaVarExpr(dspan, expr) => {
                // 处理 ${count()}, ${index()}, ${len()} 等
                transcribe_metavar_expr(&mut tscx, *dspan, expr)?;
            }
            // Token 和 Delimited 直接输出
            // ...
        }
    }
}

TranscrCtx 中的 repeats 字段是一个 Vec<(usize, usize)> 栈,每个元素是 (当前索引, 总长度)。当进入一个 $(...)* 序列时,转写器计算这个序列应该重复多少次(通过检查序列中元变量的匹配数量),然后在每次迭代时递增索引。嵌套重复(如 $( $( $x ),* );*)通过栈的深度来追踪。

14.2.5 元变量表达式(MetaVarExpr)

Rust 2021 edition 引入了元变量表达式(RFC 3086),提供了在宏模板中操控重复上下文的能力:

rust
// compiler/rustc_expand/src/mbe/metavar_expr.rs
pub(crate) enum MetaVarExpr {
    /// ${concat(a, b, $var)} — 拼接标识符
    Concat(Box<[MetaVarExprConcatElem]>),
    /// ${count(var)} — 重复的总次数
    Count(Ident, usize),
    /// ${ignore(var)} — 忽略元变量但保留重复上下文
    Ignore(Ident),
    /// ${index()} — 当前重复的索引(从 0 开始)
    Index(usize),
    /// ${len()} — 当前重复层的长度
    Len(usize),
}

这些表达式使得一些以前需要递归宏技巧才能实现的模式变得简单:

rust
macro_rules! count_and_index {
    ( $( $item:expr ),* ) => {
        // 总共有 ${count(item)} 个元素
        let total = ${count(item)};
        $(
            println!("第 {} 个(共 {} 个): {}", ${index()}, ${len()}, $item);
        )*
    };
}

count_and_index!(10, 20, 30);
// 展开为:
// let total = 3;
// println!("第 0 个(共 3 个): {}", 10);
// println!("第 1 个(共 3 个): {}", 20);
// println!("第 2 个(共 3 个): {}", 30);

14.3 宏卫生性:SyntaxContext 的标记链

宏卫生性是 Rust 宏系统最精妙的部分。它解决的核心问题是:宏生成的标识符不应该意外地引用或遮蔽调用处的标识符

14.3.1 问题的本质

rust
macro_rules! make_var {
    () => { let x = 42; };
}

fn main() {
    let x = 0;
    make_var!();
    println!("{}", x);  // 应该输出 0 还是 42?
}

在 C 的预处理器中,宏展开后两个 x 会合并成同一个变量——这就是"不卫生"的宏。在 Rust 中,make_var!() 内的 x 和外部的 x不同的标识符println! 输出 0

14.3.2 SyntaxContext:标识符的身份证

每个标识符(Ident)在编译器内部都携带一个 Span,而每个 Span 都包含一个 SyntaxContextSyntaxContext 是卫生性的核心数据结构:

rust
// compiler/rustc_span/src/hygiene.rs

/// SyntaxContext 表示一条由 (ExpnId, Transparency) 对组成的链,称为"marks"
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct SyntaxContext(u32);

struct SyntaxContextData {
    /// 链中最后一次宏展开的 ID
    outer_expn: ExpnId,
    /// 该展开的透明度
    outer_transparency: Transparency,
    /// 父上下文(链中的前一个节点)
    parent: SyntaxContext,
    /// 过滤掉所有 Transparent 和 SemiOpaque 展开后的上下文
    opaque: SyntaxContext,
    /// 过滤掉所有 Transparent 展开后的上下文
    opaque_and_semiopaque: SyntaxContext,
    /// $crate 在此上下文中解析到的 crate 名称
    dollar_crate_name: Symbol,
}

每次宏展开时,编译器会为生成的标识符 "打标记"(apply mark)——在它们的 SyntaxContext 链上追加一个新的 (ExpnId, Transparency) 对。

14.3.3 三种透明度

透明度(Transparency)是卫生性的分级机制:

rust
// compiler/rustc_span/src/hygiene.rs
pub enum Transparency {
    /// Transparent:标识符总是在**调用处**解析
    /// 用于过程宏的 call-site span、macro 2.0 的卫生豁免
    Transparent,

    /// SemiOpaque:局部变量和标签在**定义处**解析,
    /// 其他名称在**调用处**解析
    /// macro_rules! 宏的默认行为
    SemiOpaque,

    /// Opaque:标识符总是在**定义处**解析
    /// macro 2.0(`macro` 关键字定义的宏)的默认行为
    Opaque,
}

macro_rules! 使用 SemiOpaque 透明度。这意味着:

  • 宏内部定义的局部变量(如 let x = 42)在定义处解析——不会与调用处的 x 冲突
  • 但宏内部引用的路径(如 Vec::new()println!)在调用处解析——这使得宏可以使用调用处的类型和函数

14.3.4 apply_mark 的实现

当宏展开产生新的 token 时,编译器通过 apply_mark 为每个 span 打上标记:

rust
// compiler/rustc_span/src/hygiene.rs(简化)
fn apply_mark(
    &mut self,
    ctxt: SyntaxContext,
    expn_id: ExpnId,
    transparency: Transparency,
) -> SyntaxContext {
    assert_ne!(expn_id, ExpnId::root());

    // 完全不透明的展开:直接分配新上下文
    if transparency == Transparency::Opaque {
        return self.alloc_ctxt(ctxt, expn_id, transparency);
    }

    // 对于 SemiOpaque 和 Transparent,需要考虑调用处的上下文
    let call_site_ctxt = self.expn_data(expn_id).call_site.ctxt();
    let mut call_site_ctxt = if transparency == Transparency::SemiOpaque {
        self.normalize_to_macros_2_0(call_site_ctxt)
    } else {
        self.normalize_to_macro_rules(call_site_ctxt)
    };

    if call_site_ctxt.is_root() {
        return self.alloc_ctxt(ctxt, expn_id, transparency);
    }

    // 处理 macro_rules! 在 macro 2.0 内部定义的情况
    for (expn_id, transparency) in self.marks(ctxt) {
        call_site_ctxt = self.alloc_ctxt(call_site_ctxt, expn_id, transparency);
    }
    self.alloc_ctxt(call_site_ctxt, expn_id, transparency)
}

在转写阶段,Marker 结构负责为每个输出 token 打标记:

rust
// compiler/rustc_expand/src/mbe/transcribe.rs
struct Marker {
    expand_id: LocalExpnId,
    transparency: Transparency,
    /// 缓存:同一个宏体中的 token 通常有相同的 SyntaxContext,
    /// 缓存命中率接近 100%
    cache: FxHashMap<SyntaxContext, SyntaxContext>,
}

impl Marker {
    fn mark_span(&mut self, span: &mut Span) {
        *span = span.map_ctxt(|ctxt| {
            *self.cache.entry(ctxt).or_insert_with(||
                ctxt.apply_mark(self.expand_id.to_expn_id(), self.transparency)
            )
        });
    }
}

14.3.5 名称解析中的卫生性检查

打标记只是第一步。真正的卫生性在名称解析阶段生效。当解析器需要查找一个标识符时,它会比较标识符的 SyntaxContext 和候选定义的 SyntaxContext

宏定义处的 x: SyntaxContext = root → (ExpnId(42), SemiOpaque)
调用处的 x:   SyntaxContext = root

这两个 x 的 SyntaxContext 不同,所以它们是不同的标识符。
宏内部的 let x = 42 不会遮蔽外部的 let x = 0。

但是,宏内部引用的 Vec::new() 中的 Vec,在 SemiOpaque 模式下,会被"normalize"到调用处的上下文——所以它能找到调用处作用域中的 Vec 类型。

14.3.6 $crate:跨 crate 卫生性

$crate 是声明宏中的特殊元变量,它总是解析到定义该宏的 crate。这对于库作者至关重要:

rust
// 在 my_lib crate 中定义
#[macro_export]
macro_rules! my_assert {
    ($cond:expr) => {
        if !$cond {
            $crate::panic_helper("assertion failed");
            //  ^^^^^^ 总是解析到 my_lib,不管调用者在哪
        }
    };
}

SyntaxContextData 中,dollar_crate_name 字段记录了 $crate 应该解析到哪个 crate。

14.4 过程宏:编译器的插件系统

如果说声明宏是"编译器内建的模式匹配引擎",那过程宏就是"编译器的插件系统"——它允许用户编写任意的 Rust 代码,在编译期接收 TokenStream 并生成 TokenStream

14.4.1 三种过程宏的签名

rust
// 1. 函数式过程宏
// 调用方式: my_macro!(...)
#[proc_macro]
pub fn my_macro(input: TokenStream) -> TokenStream { ... }

// 2. Derive 宏
// 调用方式: #[derive(MyTrait)]
#[proc_macro_derive(MyTrait, attributes(my_attr))]
pub fn my_derive(input: TokenStream) -> TokenStream { ... }

// 3. 属性宏
// 调用方式: #[my_attribute(args)]
#[proc_macro_attribute]
pub fn my_attribute(attr: TokenStream, item: TokenStream) -> TokenStream { ... }

三者的关键区别在输入和输出的语义

过程宏类型输入输出语义
函数式宏调用中的 token替换宏调用的代码替换
Derive被标注的类型定义追加的 impl 块追加(不修改原始定义)
属性宏(属性参数, 被标注的 item)替换被标注 item 的代码替换

14.4.2 编译器侧的过程宏实现

在编译器内部,三种过程宏分别由三个结构体实现,它们都位于 rustc_expand/src/proc_macro.rs

rust
// compiler/rustc_expand/src/proc_macro.rs

/// 函数式过程宏
pub struct BangProcMacro {
    pub client: pm::bridge::client::Client<pm::TokenStream, pm::TokenStream>,
}

impl base::BangProcMacro for BangProcMacro {
    fn expand(
        &self,
        ecx: &mut ExtCtxt<'_>,
        span: Span,
        input: TokenStream,
    ) -> Result<TokenStream, ErrorGuaranteed> {
        let proc_macro_backtrace = ecx.ecfg.proc_macro_backtrace;
        let strategy = exec_strategy(ecx.sess);
        let server = proc_macro_server::Rustc::new(ecx);
        // 通过 bridge 协议调用外部的过程宏函数
        self.client.run(&strategy, server, input, proc_macro_backtrace)
            .map_err(|e| {
                ecx.dcx().emit_err(errors::ProcMacroPanicked { span, message: ... })
            })
    }
}

/// 属性过程宏
pub struct AttrProcMacro {
    pub client: pm::bridge::client::Client<
        (pm::TokenStream, pm::TokenStream), pm::TokenStream
    >,
}

/// Derive 过程宏
pub struct DeriveProcMacro {
    pub client: DeriveClient,
}

注意这里的 client 字段——过程宏的执行不是简单的函数调用,而是通过 bridge 协议与一个独立编译的动态库通信。

14.4.3 Bridge 协议:编译器与过程宏之间的桥

过程宏作为独立的 crate 编译为动态库(.so / .dylib / .dll)。编译器在需要时加载这些动态库,并通过 bridge 协议传递 token。

这个设计有几个重要原因:

  1. ABI 隔离:过程宏和编译器可能使用不同版本的 Rust 编译,bridge 协议提供了稳定的 ABI
  2. 安全性:过程宏中的 panic 不会直接 crash 编译器
  3. 增量编译:过程宏的输出可以被缓存
rust
// library/proc_macro/src/lib.rs
// 这是过程宏作者看到的 TokenStream 类型
pub struct TokenStream(Option<bridge::client::TokenStream>);

// 它实际上是一个 bridge client 的包装!
// 真正的 token 数据存在编译器一侧,
// 过程宏通过 bridge 协议远程操作它们。

bridge 协议支持两种执行策略:

rust
// compiler/rustc_expand/src/proc_macro.rs
fn exec_strategy(sess: &Session) -> impl pm::bridge::server::ExecutionStrategy {
    pm::bridge::server::MaybeCrossThread {
        cross_thread: sess.opts.unstable_opts.proc_macro_execution_strategy
            == ProcMacroExecutionStrategy::CrossThread,
    }
}
  • SameThread:过程宏在编译器的同一个线程中执行(默认,性能更好)
  • CrossThread:过程宏在单独的线程中执行(更安全,可以处理 panic)

14.4.4 proc_macro_server:编译器侧的适配器

proc_macro_server.rs 实现了从编译器内部类型到 bridge 协议类型的转换。最核心的工作是 token 的双向转换:

rust
// compiler/rustc_expand/src/proc_macro_server.rs

// 编译器的 Delimiter → 过程宏的 Delimiter
impl FromInternal<token::Delimiter> for Delimiter {
    fn from_internal(delim: token::Delimiter) -> Delimiter {
        match delim {
            token::Delimiter::Parenthesis => Delimiter::Parenthesis,
            token::Delimiter::Brace => Delimiter::Brace,
            token::Delimiter::Bracket => Delimiter::Bracket,
            token::Delimiter::Invisible(_) => Delimiter::None,
        }
    }
}

// 过程宏的 Delimiter → 编译器的 Delimiter
impl ToInternal<token::Delimiter> for Delimiter {
    fn to_internal(self) -> token::Delimiter {
        match self {
            Delimiter::Parenthesis => token::Delimiter::Parenthesis,
            Delimiter::Brace => token::Delimiter::Brace,
            Delimiter::Bracket => token::Delimiter::Bracket,
            // 注意这里的 InvisibleOrigin::ProcMacro 标记
            Delimiter::None => token::Delimiter::Invisible(
                token::InvisibleOrigin::ProcMacro
            ),
        }
    }
}

这些转换看起来是简单的一一映射,但实际上有微妙之处。比如 Invisible 分隔符——编译器内部有多种来源的不可见分隔符,但过程宏 API 只暴露一个 None。转换回来时,编译器将其标记为 ProcMacro 来源,以便后续处理。

14.4.5 TokenStream API:过程宏的操作接口

过程宏通过 proc_macro crate 提供的 API 操作 token。核心类型有四个:

rust
// library/proc_macro/src/lib.rs

/// Token 流——过程宏的输入和输出
pub struct TokenStream(Option<bridge::client::TokenStream>);

/// Token 流中的单个元素
pub enum TokenTree {
    Group(Group),       // 被分隔符包裹的 token 流
    Ident(Ident),       // 标识符
    Punct(Punct),       // 标点符号
    Literal(Literal),   // 字面量
}

/// 带分隔符的 token 组
pub struct Group { /* bridge handle */ }

/// 位置信息
pub struct Span { /* bridge handle */ }

过程宏作者通常不直接操作这些低级类型,而是使用 syn(解析)和 quote(生成)两个社区库:

rust
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(MyTrait)]
pub fn my_derive(input: TokenStream) -> TokenStream {
    // syn: TokenStream → 结构化的 AST
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;

    // quote: Rust 代码模板 → TokenStream
    let expanded = quote! {
        impl MyTrait for #name {
            fn describe(&self) -> &'static str {
                stringify!(#name)
            }
        }
    };

    expanded.into()
}

synquote 不是编译器的一部分,但它们是 Rust 过程宏生态的事实标准。syn 提供了完整的 Rust 语法解析器(比编译器自己的解析器更宽松),quote 提供了类似 macro_rules! 的模板替换能力。

14.5 展开算法:不动点迭代

宏展开不是一次性完成的。一个宏可能生成包含其他宏调用的代码,而那些宏可能引入新的 use 语句,使得之前无法解析的宏变得可用。编译器使用不动点迭代来处理这种复杂性。

14.5.1 fully_expand_fragment:展开引擎的核心

rust
// compiler/rustc_expand/src/expand.rs(简化)
pub fn fully_expand_fragment(
    &mut self,
    input_fragment: AstFragment
) -> AstFragment {
    // 第 1 步:收集所有宏调用,替换为占位符
    let (mut fragment_with_placeholders, mut invocations) =
        self.collect_invocations(input_fragment, &[]);

    // 第 2 步:优化——先解析所有导入,解锁尽可能多的宏
    self.resolve_imports();

    // 第 3 步:不动点迭代
    invocations.reverse();
    let mut expanded_fragments = Vec::new();
    let mut undetermined_invocations = Vec::new();
    let (mut progress, mut force) = (false, !self.monotonic);

    loop {
        let Some((invoc, ext)) = invocations.pop() else {
            // 所有确定的宏都已处理,尝试重新解析未确定的
            self.resolve_imports();
            if undetermined_invocations.is_empty() {
                break;  // 没有更多宏了,迭代结束
            }
            invocations = mem::take(&mut undetermined_invocations);
            force = !progress;
            progress = false;
            continue;
        };

        let ext = match ext {
            Some(ext) => ext,
            None => {
                // 尝试解析宏路径
                match self.cx.resolver.resolve_macro_invocation(
                    &invoc, eager_expansion_root, force
                ) {
                    Ok(ext) => ext,
                    Err(Indeterminate) => {
                        // 暂时无法解析,放入待定队列
                        undetermined_invocations.push((invoc, None));
                        continue;
                    }
                }
            }
        };

        // 展开这个宏
        let fragment = self.expand_invoc(invoc, &ext.kind);
        // 递归处理展开结果中的宏调用
        let expanded = self.fully_expand_fragment(fragment);
        expanded_fragments.push(expanded);
        progress = true;
    }

    // 第 4 步:用展开结果替换占位符
    // ...
    fragment_with_placeholders
}

这个算法的关键特性:

  1. 占位符模式:先用占位符替换所有宏调用,保持 AST 结构完整,然后逐个展开并替换
  2. 导入解析交织:每轮迭代结束时重新解析导入,因为新展开的代码可能包含 use 语句
  3. force 模式:如果一轮迭代没有任何进展,进入强制模式——要么报错,要么做出最佳猜测
  4. 递归展开:每个宏的展开结果都递归地调用 fully_expand_fragment

14.5.2 Invocation 的三种形式

编译器识别三种宏调用形式:

rust
// compiler/rustc_expand/src/expand.rs
pub enum InvocationKind {
    /// 函数式宏调用:foo!(...)
    Bang {
        mac: Box<ast::MacCall>,
        span: Span,
    },
    /// 属性宏:#[foo(...)]
    Attr {
        attr: ast::Attribute,
        pos: usize,              // 属性在列表中的位置
        item: Annotatable,       // 被标注的 item
        derives: Vec<ast::Path>, // 需要解析的 derive helper
    },
    /// Derive 宏:#[derive(Foo)]
    Derive {
        path: ast::Path,
        is_const: bool,
        item: Annotatable,
    },
}

每种形式的展开过程不同:Bang 调用声明宏或函数式过程宏;Attr 调用属性宏;Derive 调用 derive 宏或声明式 derive 宏。

14.5.3 AST Fragment:展开结果的类型

宏可以出现在 AST 的许多位置——表达式位置、语句位置、item 位置等。编译器用 AstFragmentKind 枚举来追踪:

rust
// compiler/rustc_expand/src/expand.rs
pub enum AstFragmentKind {
    OptExpr,           // 可选的表达式
    MethodReceiverExpr,// 方法接收者表达式
    Expr,              // 表达式
    Pat,               // 模式
    Ty,                // 类型
    Stmts,             // 语句序列
    Items,             // item 序列
    TraitItems,        // trait 中的 item
    ImplItems,         // impl 中的 item
    ForeignItems,      // extern 块中的 item
    Arms,              // match 的分支
    Crate,             // 整个 crate
    // ... 更多变体
}

每个 AstFragmentKind 对应不同的解析规则。当宏展开的结果需要被解析为 AST 时,编译器根据宏出现的位置选择正确的解析函数。

14.5.4 递归限制

宏可以递归展开(一个宏的输出包含对自身的调用),这可能导致无限递归。编译器默认设置了 128 层的递归限制:

rust
// 用户可以通过 crate 属性调整
#![recursion_limit = "256"]

当递归深度超过限制时,编译器会报告错误,并显示宏展开的调用链。

14.6 实战:手写一个 Derive 宏

让我们从零开始实现一个 Builder derive 宏,完整展示过程宏的开发流程。

14.6.1 目标

rust
#[derive(Builder)]
struct Config {
    host: String,
    port: u16,
    debug: bool,
}

// 自动生成:
impl Config {
    fn builder() -> ConfigBuilder {
        ConfigBuilder::default()
    }
}

#[derive(Default)]
struct ConfigBuilder {
    host: Option<String>,
    port: Option<u16>,
    debug: Option<bool>,
}

impl ConfigBuilder {
    fn host(mut self, val: String) -> Self {
        self.host = Some(val);
        self
    }
    fn port(mut self, val: u16) -> Self {
        self.port = Some(val);
        self
    }
    fn debug(mut self, val: bool) -> Self {
        self.debug = Some(val);
        self
    }
    fn build(self) -> Result<Config, String> {
        Ok(Config {
            host: self.host.ok_or("host is required")?,
            port: self.port.ok_or("port is required")?,
            debug: self.debug.ok_or("debug is required")?,
        })
    }
}

14.6.2 实现

首先创建过程宏 crate:

toml
# Cargo.toml
[lib]
proc-macro = true

[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"

完整实现:

rust
// src/lib.rs
use proc_macro::TokenStream;
use quote::{quote, format_ident};
use syn::{parse_macro_input, DeriveInput, Data, Fields};

#[proc_macro_derive(Builder)]
pub fn derive_builder(input: TokenStream) -> TokenStream {
    // 第 1 步:解析输入
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;
    let builder_name = format_ident!("{}Builder", name);

    // 第 2 步:提取字段信息
    let fields = match &input.data {
        Data::Struct(data) => match &data.fields {
            Fields::Named(fields) => &fields.named,
            _ => panic!("Builder only supports structs with named fields"),
        },
        _ => panic!("Builder only supports structs"),
    };

    // 第 3 步:为每个字段生成代码片段
    let builder_fields = fields.iter().map(|f| {
        let name = &f.ident;
        let ty = &f.ty;
        quote! { #name: Option<#ty> }
    });

    let setter_methods = fields.iter().map(|f| {
        let name = &f.ident;
        let ty = &f.ty;
        quote! {
            fn #name(mut self, val: #ty) -> Self {
                self.#name = Some(val);
                self
            }
        }
    });

    let build_fields = fields.iter().map(|f| {
        let name = &f.ident;
        let err_msg = format!("{} is required", name.as_ref().unwrap());
        quote! {
            #name: self.#name.ok_or(#err_msg)?
        }
    });

    // 第 4 步:组装输出
    let expanded = quote! {
        impl #name {
            fn builder() -> #builder_name {
                #builder_name::default()
            }
        }

        #[derive(Default)]
        struct #builder_name {
            #(#builder_fields,)*
        }

        impl #builder_name {
            #(#setter_methods)*

            fn build(self) -> Result<#name, String> {
                Ok(#name {
                    #(#build_fields,)*
                })
            }
        }
    };

    expanded.into()
}

14.6.3 在编译器内部的展开过程

当编译器处理 #[derive(Builder)] struct Config { ... } 时,展开过程如下:

  1. 收集阶段InvocationCollector 遍历 AST,发现 #[derive(Builder)] 属性,创建一个 Invocation::Derive
  2. 解析阶段resolve_macro_invocationBuilder 解析为某个 proc-macro crate 中的 derive 宏
  3. 序列化:编译器将 struct Config { ... } 的 token 序列化为 TokenStream
  4. 跨 bridge 调用:通过 DeriveProcMacro::expand,将 TokenStream 传递给过程宏函数
  5. 过程宏执行derive_builder 函数运行,生成新的 TokenStream
  6. 反序列化:编译器将返回的 TokenStream 解析为 AST items
  7. 追加:将生成的 impl Configstruct ConfigBuilder 等 item 追加到 AST
rust
// compiler/rustc_expand/src/proc_macro.rs(DeriveProcMacro 的展开逻辑)
impl MultiItemModifier for DeriveProcMacro {
    fn expand(
        &self,
        ecx: &mut ExtCtxt<'_>,
        span: Span,
        _meta_item: &ast::MetaItem,
        item: Annotatable,
        _is_derive_const: bool,
    ) -> ExpandResult<Vec<Annotatable>, Annotatable> {
        let input = item.to_tokens();
        // 通过 bridge 调用过程宏
        let Ok(output) = expand_derive_macro(invoc_id, input, ecx, self.client)
        else {
            return ExpandResult::Ready(vec![]);
        };

        // 将输出解析为 item 列表
        let mut parser = Parser::new(&ecx.sess.psess, output, Some("proc-macro derive"));
        let mut items = vec![];
        loop {
            match parser.parse_item(ForceCollect::No, ...) {
                Ok(None) => break,
                Ok(Some(item)) => items.push(Annotatable::Item(item)),
                Err(err) => { err.emit(); break; }
            }
        }
        ExpandResult::Ready(items)
    }
}

注意:derive 宏的输出被解析为一系列独立的 item,然后追加到原始 struct 所在的作用域。原始的 struct Config 不会被修改——这是 derive 宏与属性宏的关键区别。

14.7 常见宏模式

14.7.1 枚举分发(Enum Dispatch)

一个常见模式是为枚举的每个变体生成分发代码:

rust
macro_rules! dispatch {
    (
        enum $name:ident {
            $( $variant:ident($inner:ty) ),* $(,)?
        }
        => trait $trait_name:ident {
            $( fn $method:ident(&self $(, $arg:ident : $arg_ty:ty)* ) -> $ret:ty; )*
        }
    ) => {
        enum $name {
            $( $variant($inner), )*
        }

        impl $trait_name for $name {
            $(
                fn $method(&self $(, $arg: $arg_ty)*) -> $ret {
                    match self {
                        $( $name::$variant(inner) => inner.$method($($arg),*), )*
                    }
                }
            )*
        }
    };
}

dispatch! {
    enum Shape {
        Circle(Circle),
        Rect(Rect),
    }
    => trait Drawable {
        fn area(&self) -> f64;
        fn draw(&self, canvas: &mut Canvas) -> Result<(), Error>;
    }
}

这个宏生成的代码手动写非常繁琐,尤其当变体和方法数量都很多时。宏的嵌套重复 $( $( ... )* )* 正好适合这种 M x N 的代码生成场景。

14.7.2 字段访问器生成

rust
macro_rules! accessor {
    (
        struct $name:ident {
            $( $vis:vis $field:ident : $ty:ty ),* $(,)?
        }
    ) => {
        struct $name {
            $( $vis $field: $ty, )*
        }

        impl $name {
            $(
                pub fn $field(&self) -> &$ty {
                    &self.$field
                }
            )*
        }
    };
}

accessor! {
    struct User {
        name: String,
        email: String,
        age: u32,
    }
}
// 自动生成 user.name(), user.email(), user.age() 方法

14.7.3 递归宏:编译期计算

声明宏可以递归调用自身,实现编译期的"计算":

rust
macro_rules! count_tts {
    () => { 0usize };
    ($head:tt $($tail:tt)*) => { 1usize + count_tts!($($tail)*) };
}

const N: usize = count_tts!(a b c d e); // = 5

// 更高效的版本:使用二分递归
macro_rules! count_tts_fast {
    () => { 0usize };
    ($one:tt) => { 1usize };
    ($($a:tt)* @ $($b:tt)*) => {
        count_tts_fast!($($a)*) + count_tts_fast!($($b)*)
    };
    // 需要手动拆分——声明宏无法自动二分
    ($a:tt $b:tt $($tail:tt)*) => {
        count_tts_fast!($a $($tail)* @ $b)
    };
}

但请注意:递归宏的每一层都会产生新的 AST 节点,深度受 recursion_limit 限制。对于需要大量编译期计算的场景,过程宏或 const fn 通常是更好的选择。

14.7.4 用声明宏实现 DSL

声明宏可以定义简单的领域特定语言:

rust
macro_rules! html {
    // 自闭合标签
    (<$tag:ident $($attr:ident = $val:expr),* />) => {{
        let mut s = format!("<{}", stringify!($tag));
        $( s.push_str(&format!(" {}=\"{}\"", stringify!($attr), $val)); )*
        s.push_str(" />");
        s
    }};
    // 带内容的标签
    (<$tag:ident $($attr:ident = $val:expr),*> $($content:tt)* </$end_tag:ident>) => {{
        let mut s = format!("<{}", stringify!($tag));
        $( s.push_str(&format!(" {}=\"{}\"", stringify!($attr), $val)); )*
        s.push_str(">");
        $( s.push_str(&html!($content)); )*
        s.push_str(&format!("</{}>", stringify!($end_tag)));
        s
    }};
    // 文本节点
    ($text:expr) => {
        $text.to_string()
    };
}

let page = html!(
    <div class="container">
        <h1> "Hello, World!" </h1>
        <img src="logo.png" />
    </div>
);

14.8 设计比较:Rust 宏 vs 其他语言

14.8.1 C 预处理器

C 的预处理器在编译的最早阶段工作,对原始文本进行替换:

c
#define MAX(a, b) ((a) > (b) ? (a) : (b))

int x = MAX(i++, j++);
// 展开为: int x = ((i++) > (j++) ? (i++) : (j++));
// 副作用被执行两次!

问题:

  • 文本替换:不理解语法结构,只是字符串操作
  • 无卫生性:宏定义的名称直接注入调用处的作用域
  • 无类型安全:展开后的代码可能在任何阶段出错,错误信息指向展开后的代码
  • 副作用重复:参数可能被求值多次

Rust 的声明宏避免了所有这些问题:操作 token tree 而非文本,有卫生性保证,元变量匹配保证参数只被求值一次。

14.8.2 Lisp 宏

Lisp 的宏系统是所有宏系统的鼻祖,也是最强大的之一:

lisp
(defmacro when (condition &body body)
  `(if ,condition
     (progn ,@body)))

(when (> x 0)
  (print "positive")
  (+ x 1))

Lisp 宏的优势:

  • 同相性(Homoiconicity):代码和数据用同一种结构(S 表达式)表示,操作代码就是操作数据
  • 完整的语言能力:宏可以使用完整的 Lisp 来生成代码
  • 简洁的引用/反引用语法` , ,@

但 Lisp 传统宏是不卫生的(Common Lisp 需要 gensym 手动生成唯一名称)。Scheme 的 syntax-rulessyntax-case 引入了卫生宏,这也是 Rust 宏卫生性设计的灵感来源。

Rust 的 macro_rules! 可以看作是 Scheme syntax-rules 的"Rust 化"版本——基于模式匹配而非 S 表达式操作,但保留了卫生性的核心思想。

14.8.3 Template Haskell

Haskell 的宏系统 Template Haskell 在类型检查之后工作:

haskell
-- Template Haskell 可以访问类型信息!
$(derive ''MyType)

这意味着 Template Haskell 的宏可以查询类型、检查实例——Rust 的宏做不到这一点。代价是编译器需要完成一轮完整的类型检查才能展开宏,增加了编译复杂度。

14.8.4 综合比较

特性C 预处理器Lisp 宏Rust macro_rules!Rust proc_macroTemplate Haskell
操作层面文本S 表达式Token treeTokenStream类型化 AST
卫生性可选部分(SemiOpaque)手动完全
类型感知
图灵完备否*有限(递归限制)
错误诊断一般
编译期开销极低中等中-高

*注:C 预处理器虽然理论上是图灵完备的(通过 #include 和条件编译的组合),但这在实践中没有意义。

14.9 编译器内部的宏统计

Rust 编译器内置了宏展开的统计收集功能(rustc_expand/src/stats.rs),可以通过 -Z macro-stats 标志查看每个宏的展开次数、生成的 token 数量和耗时。这对于诊断编译时间问题非常有用——如果一个过程宏展开耗时过长或生成了大量代码,这里可以一目了然。

14.10 宏系统的局限与未来方向

14.10.1 当前的局限

  1. 声明宏的表达能力有限:无法做条件判断(除了通过模式匹配的间接方式)、无法在模式之间共享状态
  2. 过程宏的编译开销大:每个 proc-macro crate 需要单独编译为动态库,增加了编译时间
  3. 错误定位:宏展开后的错误有时难以追溯到原始的宏调用
  4. 无法访问类型信息:宏在类型检查之前展开,无法根据类型做决策
  5. macro_rules! 的卫生性不完全:路径在调用处解析,可能导致意外行为

14.10.2 正在演进的方向

  1. macro 关键字(Macros 2.0):提供完全卫生的声明宏,使用 Opaque 透明度
  2. 过程宏的增量编译支持:编译器已经开始缓存 derive 宏的展开结果(见 proc_macro.rs 中的 QueryDeriveExpandCtx
  3. 元变量表达式${count()}, ${index()}, ${len()}, ${concat()} 正在逐步稳定
  4. 更好的错误诊断:编译器持续改进宏展开相关的错误消息

14.11 小结

本章深入探索了 Rust 宏系统的编译器实现。回顾关键要点:

声明宏使用 rustc_expand/src/mbe/ 下的 NFA 匹配引擎。macro_parser.rs 实现了基于"匹配位置"的多线程 NFA,transcribe.rs 实现了模板替换。匹配结果存储在 NamedMatch 中,重复序列通过 MatchedSeq 向量支持。

过程宏通过 rustc_expand/src/proc_macro.rsproc_macro_server.rs 实现。过程宏编译为独立的动态库,通过 bridge 协议与编译器通信。BangProcMacroAttrProcMacroDeriveProcMacro 三个结构体分别处理三种过程宏的展开。

宏卫生性rustc_span/src/hygiene.rs 中的 SyntaxContext 实现。每个标识符携带一条标记链,记录了它经过的所有宏展开。三种透明度(TransparentSemiOpaqueOpaque)提供了不同级别的卫生性保证。apply_mark 是核心操作,它在标记链上追加新的展开记录。

展开算法位于 rustc_expand/src/expand.rsfully_expand_fragment 方法。它使用不动点迭代,将宏展开和名称解析交织进行,通过占位符机制保持 AST 的结构完整性。

理解了宏系统,你就理解了 Rust 编译器在类型检查之前做的最复杂的工作。下一章我们跨过这道分水岭,进入编译器的后端世界——MIR 层的优化 Pass。

基于 VitePress 构建