Rust 编译器宏展开机制深度解析:从 Token 流到完整 AST 的迭代工程
2026/9/12 21:16:55 网站建设 项目流程

Rust 编译器宏展开机制深度解析:从 Token 流到完整 AST 的迭代工程

【免费下载链接】rustEmpowering everyone to build reliable and efficient software.项目地址: https://gitcode.com/GitHub_Trending/ru/rust

Rust 拥有极其强大的宏系统。本文基于 rustc-dev-guide 的 "Macro expansion" 章节,系统讲解 rustc 如何在解析阶段之后,把被暂时搁置的宏调用(即占位符)不断展开、集成,直到产出不含任何未展开宏的完整抽象语法树(AST)。读者将掌握MacroExpander::fully_expand_fragment的迭代展开算法、三层 hygiene(卫生性)层级体系、MBE(Macros By Example)解析器与过程宏(proc macro)的展开路径,并结合 rustc 源码(主要位于 compiler/rustc_expand/src)印证每一步实现。

宏展开概述:从占位符到完整 AST

Rust 编译器对宏的处理分为两大阶段:解析阶段(parsing)与展开阶段(expansion)。在解析阶段,普通 Rust 解析器(parser)会先把宏的调用内容暂时搁置,用临时 占位符(placeholders) 替代,其细节参见本书的 AST 验证 与 解析器 章节。而展开阶段的核心目标,就是迭代地展开这些被搁置的宏,直到整个 crate 的 AST 中不再存在未展开的宏,或直接报出编译错误

展开过程发生在 crate 级别:给定 crate 的原始源码,编译器最终产出一个宏全部展开、模块全部内联的巨型 AST。多数展开相关的算法与数据结构都位于rustc_expand中,基础数据结构在 compiler/rustc_expand/src/base.rs。另外需要特别指出:cfgcfg_attr与其他宏的待遇不同,它们在 compiler/rustc_expand/src/config.rs 中被特殊处理。

从源码结构看,整个展开流程的入口链路为:expand_crate(expand.rs)将ast::Crate包装成AstFragment::Crate后调用fully_expand_fragment,最终再通过make_crate()还原为 crate 并断言其NodeId仍是CRATE_NODE_ID

展开与 AST 集成:核心迭代算法

MacroExpander::fully_expand_fragment(expand.rs)是展开过程的主要入口。除了少数例外情况(详见下文 "Eager Expansion"),我们通常对整个 crate调用该方法。

从高层看,fully_expand_fragment以迭代方式工作:我们维护一个未解析宏调用队列(即尚未找到定义的宏),反复尝试从队列中取出宏、解析它、展开它、并把结果集成回 AST。如果某一轮迭代没有任何进展,就代表出现了编译错误。算法伪码如下:

  1. 初始化一个queue,存放未解析的宏调用。
  2. 反复执行,直到queue为空(或无法取得进展——此时是错误):
    1. 尽可能解析(名称解析)部分构建的 crate 中的导入(imports)。
    2. 从部分构建的 crate 中尽可能收集宏Invocation(包括fn风格的调用、属性宏、derive 宏)并加入队列。
    3. 出队第一个元素并尝试解析它。
    4. 如果解析成功:
      1. 运行该宏的展开函数:它消费一个TokenStream或 AST,产出一个TokenStreamAstFragment(取决于宏的种类)。TokenStream是一组TokenTree的集合,每个TokenTree要么是一个 token(标点、标识符或字面量),要么是一个带定界符的组(()/[]/{}内部的一切)。
        • 此时我们对宏本身已了解全部信息,可以调用set_expn_data在全局数据中填充其属性,即与ExpnId相关联的 hygiene 数据。
      2. 把这块 AST 集成进当前已存在(但仍是部分构建)的 AST 中。这一步本质上是把"token 状的一团"变成带有侧表的、确定下来的正式 AST,具体过程为:
        • 如果宏产出的是 token(例如过程宏),我们需要把它解析成 AST,此过程可能产生解析错误。
        • 展开过程中我们会创建SyntaxContext(第二层层级体系,见下文 Hygiene 章节)。
        • 这两个 pass 会在每个从宏中新展开的 AST 片段上先后执行:
          • InvocationCollector(expand.rs)分配NodeId,同时收集这段新 AST 中出现的新的宏调用并加入队列。
          • DefCollector创建 "Def paths"、分配对应的DefId,并构建约简图(从解析器的视角把名字放入模块)。
      3. 展开单个宏并集成其输出后,进入fully_expand_fragment的下一次迭代。
    5. 如果解析失败:
      1. 把宏放回队列。
      2. 进入下一次迭代……

在真实源码实现中,这一流程在 expand.rs 中体现为:先用collect_invocations收集所有宏调用并用占位符替换(InvocationCollector通过MutVisitor遍历 AST,expand.rs),随后进入主循环——从队列弹出调用、调用resolver.resolve_macro_invocation解析;解析不明确的(Indeterminate)放入undetermined_invocations稍后重试;展开成功的片段按depth分层暂存于expanded_fragments,直到所有调用处理完毕,最后用PlaceholderExpander把展开结果统一回填到占位符位置。

错误恢复(Error Recovery)

如果某一轮迭代没有取得任何进展,我们就到达了编译错误(例如宏未定义)。我们会尝试从失败(即未解析的宏或导入)中恢复,目的是生成诊断信息。失败恢复通过把未解析的宏展开成ExprKind::Err来实现,从而允许编译越过第一个错误继续推进,这样rustc就能报告出比最初的失败更多的错误。与之对应的实现线索是 expand.rs 中error_recursion_limit_reached等错误处理函数,以及fragment_kind.dummy(span, guar)这类"哑片段"占位手段。

名称解析(Name Resolution)

注意名称解析也参与上述算法:我们需要解析导入和宏名称。这由rustc_resolve::macros完成,它负责解析宏路径、验证解析结果,并报告各种错误(例如 "not found"、"found, but it's unstable"、"expected x, found y")。但此时我们还不尝试解析其他名称,这要等到后面 名称解析 章节介绍的过程才进行。在实现层面,fully_expand_fragment主循环中通过self.cx.resolver.resolve_macro_invocation(&invoc, eager_expansion_root, force)(expand.rs)解析宏调用,force模式表示强推展开。

Eager Expansion(急切展开)

Eager expansion(急切展开)意味着:在展开宏调用本身之前,先展开该调用的参数。它只为少数几个期望字面量的特殊内建宏实现;先展开这些宏的参数能给用户带来更顺滑的体验。举例:

macro bar($i: ident) { $i } macro foo($i: ident) { $i } foo!(bar!(baz));

惰性(lazy)展开会先展开foo!;急切展开则会先展开bar!

Eager expansion不是 Rust 的通用可用特性。通用地实现急切展开颇具挑战,所以我们只为少数特殊的内建宏实现它,以改善用户体验。这些内建宏实现在rustc_builtin_macros(compiler/rustc_builtin_macros/src)中,同时那里还有一些早期代码生成设施,如标准库导入的注入、测试 harness(test harness)的生成等。在 compiler/rustc_expand/src/build.rs 中还有一些用于构建 AST 片段的辅助工具。急切展开通常执行的是普通(惰性)展开所做事的一个子集,实现方式是对 crate 的一部分调用fully_expand_fragment(而不是像平常那样对整个 crate)。

其他相关数据结构

展开与集成过程中还涉及一些值得注意的数据结构(均在 base.rs 中定义):

  • ResolverExpand(base.rs)——用于打破 crate 依赖的trait。它使得解析器服务可以在rustc_ast中使用,尽管rustc_resolve和其他几乎所有东西都依赖rustc_ast
  • ExtCtxt/ExpansionData(base.rs)——持有各种中间展开基础设施数据。MacroExpander结构体(expand.rs)中保存的正是&'a mut ExtCtxt<'b>monotonic标志。
  • Annotatable(base.rs)——可作为属性(attribute)目标的一块 AST,与AstFragment几乎相同,唯一区别在于:类型(types)和模式(patterns)可以由宏产生,却不能标注属性。
  • MacResult(base.rs)——一种"多态"的 AST 片段,可以根据其AstFragmentKind(即 item、表达式、模式等)转变为不同的AstFragment
  • SyntaxExtension(base.rs)——宏的降级表示,包含其展开函数以及稳定性等附加数据。

Hygiene 与三层层级体系

如果你用过 C/C++ 预处理器宏,一定领教过那些恼人且难以调试的坑!例如:

#define DEFINE_FOO struct Bar {int x;}; struct Foo {Bar bar;}; // 然后,在别处 struct Bar { ... }; DEFINE_FOO

大多数人避免这样写 C 代码——理由很充分:它编译不过。宏定义的struct Bar与代码中定义的struct Bar名字冲突。再看另一个例子:

#define DO_FOO(x) {\ int y = 0;\ foo(x, y);\ } // 然后,在别处 int y = 22; DO_FOO(y);

看出问题了吗?我们本想生成调用foo(22, 0),结果却得到foo(0, 0),因为宏定义了自己的y

这两个例子都是宏 hygiene(卫生性)问题。Hygiene 关心的是如何对待在宏内部定义的名字。具体来说,一个 hygienic(卫生的)宏系统会防止由宏内部引入名字而导致的上述错误。Rust 宏是 hygienic 的,它不允许你写出上面那类 bug。

从高层看,rustc 中的 hygiene 通过跟踪名字被引入与被使用的上下文来实现,然后基于该上下文对名字进行消歧。未来宏系统的迭代版本会给宏作者更多使用该上下文的控制权,例如:宏作者可能想把新名字引入到宏被调用的上下文中;或者宏作者可能只打算定义一个仅供宏内部使用的变量(即宏外部不可见)。

上下文被附着在 AST 节点上。所有由宏生成的 AST 节点都带有上下文。此外,可能还有其他节点也带上下文,例如某些脱糖(desugared)语法(未经过宏展开的节点被认为只带 "root" 上下文,详见下文)。在整个编译器里,我们用rustc_span::Span来指代代码位置;这个结构体同样携带 hygiene 信息,具体见 compiler/rustc_span/src/hygiene.rs。

由于宏调用和宏定义可以嵌套,节点的语法上下文必须是一个层级(hierarchy)。例如,如果我们展开一个宏,而它的输出里又有另一个宏调用或定义,那么语法上下文应当反映这种嵌套关系。

不过,事实证明我们可能出于不同目的需要追踪几种上下文。因此,构成一个 crate hygiene 信息的不止一种,而是三种展开层级体系。所有这三种层级体系都需要某种"宏 ID"来标识展开链中的单个元素,这个 ID 就是ExpnId所有宏都会获得一个整数 ID,从 0 开始,随着我们发现新的宏调用而连续分配。所有层级体系都从ExpnId::root开始,root 是它自己的父节点。

rustc_span::hygiene模块(compiler/rustc_span/src/hygiene.rs)包含了所有与 hygiene 相关的算法和结构(除了Resolver::resolve_crate_root中的一些 hack),以及保存在全局数据中的展开信息。这些实际层级存储在HygieneData中,这是一个全局数据结构,包含 hygiene 与展开信息,可在无任何上下文的情况下从任意Ident访问。Ident只是被 intern 的Symbol+Span(即 intern 后的字符串 + hygiene 数据),Symbol定义于 compiler/rustc_span/src/symbol.rs。

第一层:展开顺序层级(The Expansion Order Hierarchy)

第一层层级追踪展开的顺序,即当一个宏调用出现在另一个宏的输出中时,谁先谁后。这里的层级中,子节点是最"内层"的 token。ExpnData结构体本身包含来自宏定义与宏调用的一小部分属性,可通过全局数据获得;ExpnData::parent记录了这一层级中从子到父的链接。

举例:

macro_rules! foo { () => { println!(); } } fn main() { foo!(); }

在这段代码中,最终生成的 AST 节点会具有层级root -> id(foo) -> id(println)

第二层:宏定义层级(The Macro Definition Hierarchy)

第二层层级追踪宏定义的顺序,即当我们展开一个宏时,其输出中又揭示了另一个宏的定义。这一层比其他两层更棘手、更复杂。

SyntaxContext通过一个 ID 表示这一层级中的一整条链;SyntaxContextData包含与给定SyntaxContext关联的数据,主要是对不同方式过滤该链的结果的缓存。SyntaxContextData::parent是这里的子到父链接,SyntaxContextData::outer_expn是链中的单个元素。编译器中实现"链式操作"的运算符是SyntaxContext::apply_mark

前面提到的Span,实际上只是代码位置 +SyntaxContext的紧凑表示。

对于内建宏,我们使用上下文SyntaxContext::empty().apply_mark(expn_id),这类宏被认为定义在层级根部。对于过程宏(proc macro)我们也这么做,因为我们尚未实现跨 crate 的 hygiene。

如果一个 token 在被宏产出之前具有上下文X,那么在被宏产出之后,它拥有上下文X -> macro_id。以下是几个例子:

示例 0:

macro m() { ident } m!();

这里ident最初具有上下文SyntaxContext::root,在被m产出后,它拥有上下文ROOT -> id(m)

示例 1:

macro m() { macro n() { ident } } m!(); n!();

此例中ident初始上下文为ROOT,第一次展开后是ROOT -> id(m),之后是ROOT -> id(m) -> id(n)

示例 2:

注意这些链并非完全由它们的最后一个元素决定,换句话说,ExpnIdSyntaxContext不是同构的:

macro m($i: ident) { macro n() { ($i, bar) } } m!(foo);

经过所有展开后,foo的上下文是ROOT -> id(n),而bar的上下文是ROOT -> id(m) -> id(n)

目前,这个用于追踪宏定义的层级体系受到所谓的 "context transplantation hack"(上下文移植 hack)的约束。现代(即实验性的)宏比传统的 "Macros By Example"(MBE)系统具有更强的 hygiene,这可能导致两者之间的奇怪交互。该 hack 的目的只是让一切"暂时正常工作"。

第三层:调用点层级(The Call-site Hierarchy)

第三层也是最后一层层级,追踪宏调用的位置。在这一层级中,ExpnData::call_sitechild -> parent链接。

例子:

macro bar($i: ident) { $i } macro foo($i: ident) { $i } foo!(bar!(baz));

对于最终输出中的bazAST 节点:展开顺序层级是ROOT -> id(foo) -> id(bar) -> baz,而调用点层级是ROOT -> baz

宏回溯(Macro Backtraces)

宏回溯(即错误信息中展示宏展开调用链的机制)由rustc_span使用rustc_span::hygiene中的 hygiene 机制实现。

产生宏输出(Producing Macro Output)

前面我们看到了宏的输出如何集成进 crate 的 AST,以及 crate 的 hygiene 数据如何生成。但宏的输出究竟是怎么产生的?这取决于宏的类型。

Rust 中有两种宏:

  1. macro_rules!宏(又称 "Macros By Example",MBE);
  2. 过程宏(proc macro),包括自定义 derive(custom derives)。

在解析阶段,普通 Rust 解析器会把宏及其调用的内容搁置一旁;稍后,宏使用这些代码片段被展开。这里有几个重要的数据结构/接口(均在 base.rs):

  • SyntaxExtension(base.rs)——宏的降级表示,包含其展开函数(把TokenStream或 AST 变换为另一个TokenStream或 AST)以及一些附加数据,如稳定性、宏内部允许的不稳定特性列表。
  • SyntaxExtensionKind(base.rs)——展开函数可能有几种不同的签名(接收一个 token 流、或两个、或一块 AST 等),这是一个列出它们的enum
  • BangProcMacro/TTMacroExpander/AttrProcMacro/MultiItemModifier(base.rs)——代表各种展开函数签名的trait

expand_invoc(expand.rs)是分发到具体展开逻辑的关键函数:它依据InvocationKind::Bang/Attr/DeriveSyntaxExtensionKind的组合,调用对应的 expander,例如Bang变体调用expander.expand(cx, span, mac.args.tokens.clone()),再把返回的 token 结果解析为 AST 片段。

Macros By Example(MBE)

MBE 拥有独立于 Rust 解析器的自己的解析器。当宏被展开时,我们可能调用 MBE 解析器来解析和展开宏;而 MBE 解析器在绑定元变量(metavariable,如$my_expr)并解析宏调用内容时,又可能回调 Rust 解析器。宏展开的代码位于 compiler/rustc_expand/src/mbe/。

示例

macro_rules! printer { (print $mvar:ident) => { println!("{}", $mvar); }; (print twice $mvar:ident) => { println!("{}", $mvar); println!("{}", $mvar); }; }

这里$mvar被称为元变量(metavariable)。与普通变量不同——普通变量在运行时绑定到一个值——元变量在编译时绑定到一棵token 树(token tree)。一个token是语法的一个"单元",例如标识符(如foo)或标点(如=>)。还有一些特殊 token,如EOF,它本身表示没有更多 token 了。此外还有由成对括号类字符((...)[...]{...})形成的 token 树——它们包含开括号、闭括号以及两者之间的所有 token(Rust 要求括号类字符必须平衡)。让宏展开操作 token 流而不是源文件的原始字节,抽象掉了很多复杂性。宏展开器(以及编译器其他大部分)并不关心代码中某个语法构造的确切行列,它关心的是代码中使用了哪些构造。使用 token 让我们只关心what,而不必担心where。关于 token 的更多信息,参见本书的 解析(Parsing) 章节。

printer!(print foo); // `foo` 是一个变量

把宏调用展开成语法树println!("{}", foo),再把该语法树展开成对Display::fmt的调用,这是宏展开的一个常见示例。

MBE 解析器

MBE 展开由宏解析器完成,包含两部分:

  1. 解析宏定义
  2. 解析宏调用

我们把 MBE 解析器看作一个基于NFA(非确定有限自动机)的正则解析器,因为它使用的算法在精神上类似于 Earley 解析算法。宏解析器定义在 compiler/rustc_expand/src/mbe/macro_parser.rs。

宏解析器的接口如下(略有简化):

fn parse_tt( &mut self, parser: &mut Cow<'_, Parser<'_>>, matcher: &[MatcherLoc] ) -> ParseResult

在宏解析器中用到的要素:

  • parser变量是对普通 Rust 解析器状态的引用,包括 token 流和解析会话。token 流正是我们请求 MBE 解析器去解析的东西;我们会消费原始 token 流,输出元变量到对应 token 树的绑定。解析会话可用于报告解析错误。
  • matcher变量是一组MatcherLoc(macro_parser.rs),我们希望用它们匹配 token 流;它们是在匹配之前从宏定义中的原始 token 树转换而来的。从源码看,MatcherLoc是一个扁平的、非递归的匹配单元枚举(TokenDelimitedSequenceMetaVarDeclEof等),其注释明确指出它专为匹配时快速便捷的遍历而设计。

用正则解析器的类比来说:token 流是输入,我们用 matcher 定义的"模式"去匹配它。以我们的示例为例,token 流可能是包含示例调用print foo内部内容的 token 流,而 matcher 可能是 token(树)序列print $mvar:ident

解析器的输出是ParseResult(macro_parser.rs),它指示以下三种情况之一:

  • 成功(Success):token 流与给定 matcher 匹配,且我们已产生从元变量到相应 token 树的绑定。
  • 失败(Failure):token 流与 matcher 不匹配,产生类似 "No rule expected token ..." 的错误信息。
  • 错误(Error):解析器内部发生了致命错误。例如,当存在不止一种模式匹配时就会发生,因为这表明宏是有歧义的。

与普通正则解析器几乎完全相同,唯一例外是:为了解析不同类型的元变量(如identblockexpr等),宏解析器必须回调普通 Rust 解析器

定义的解析代码位于 compiler/rustc_expand/src/mbe/macro_rules.rs。关于宏解析器实现的更多信息,可参见 macro_parser.rs 中的注释。

用我们的示例说明:我们会尝试把调用中的 token 流print foo与从宏定义各规则中提取出的 matcherprint $mvar:identprint twice $mvar:ident匹配。当宏解析器走到当前 matcher 中需要匹配非终结符(如$mvar:ident)的位置时,它会回调普通 Rust 解析器来获取该非终结符的内容。在这个例子中,Rust 解析器会寻找一个identtoken,找到(foo)后返回给宏解析器。然后宏解析器继续解析。

注意:各规则中的 matcher 中恰好一个应该与调用匹配;如果多于一个匹配,解析就是有歧义的;如果完全没有匹配,则是语法错误。假定恰好一个规则匹配,宏展开随后会**转录(transcribe)**该规则的右侧,代入左侧匹配时捕获的任何值。转录逻辑位于 compiler/rustc_expand/src/mbe/transcribe.rs,元变量表达式处理见 compiler/rustc_expand/src/mbe/metavar_expr.rs。

过程宏(Procedural Macros)

过程宏同样在解析期间展开。但与编译器内置解析器不同,过程宏是作为自定义的第三方 crate实现的。编译器会编译过程宏 crate 以及其中被特殊标注的函数(即过程宏本身),把 token 流传给它;过程宏可以变换该 token 流并输出一个新的 token 流,再被合成为 AST。

过程宏使用的 token 流类型是**稳定(stable)**的,因此rustc内部并不使用它。编译器(不稳定)的 token 流定义在rustc_ast::tokenstream::TokenStream(compiler/rustc_ast/src/tokenstream.rs),它与稳定的proc_macro::TokenStream之间的相互转换在 compiler/rustc_expand/src/proc_macro.rs 和 compiler/rustc_expand/src/proc_macro_server.rs 中完成。由于 Rust ABI 目前不稳定,我们使用C ABI进行这种转换。

自定义 Derive(Custom Derive)

自定义 derive 是过程宏的一种特殊类型。在fully_expand_fragment的实现中,derive 调用会被特殊对待:展开一个宏后,如果它引入了 derive 宏,会通过take_derive_resolutions取出这些 derive 解析结果,构造InvocationKind::Derive调用,并优先于输出片段中新收集到的普通宏调用展开(expand.rs)。

Macros By Example 与 Macros 2.0

还存在一个遗留的、大多未记录在案的努力,旨在改进 MBE 系统:赋予它更多与 hygiene 相关的特性、更好的作用域与可见性规则等。内部上它使用与今天 MBE 相同的机制,外加一些额外的语法糖,并允许出现在命名空间中。

小结

宏展开是 rustc 前端中连接解析与后续编译阶段的关键一环:它以 crate 为单位,通过fully_expand_fragment的迭代循环(收集 → 解析 → 展开 → 集成)最终得到无未展开宏的完整 AST;同时,rustc_span::hygiene的三层层级(展开顺序、宏定义、调用点)为名字消歧提供了精确的上下文追踪;MBE 的 NFA 式解析器与过程宏的 C ABI 转换则覆盖了两种主流宏的实现路径。相关核心源码均可从 compiler/rustc_expand/src 与 compiler/rustc_span/src/hygiene.rs 展开继续阅读。

【免费下载链接】rustEmpowering everyone to build reliable and efficient software.项目地址: https://gitcode.com/GitHub_Trending/ru/rust

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询