
Rust 编译器宏展开机制深度解析从 Token 流到完整 AST 的迭代工程【免费下载链接】rustEmpowering everyone to build reliable and efficient software.项目地址: https://gitcode.com/GitHub_Trending/ru/rustRust 拥有极其强大的宏系统。本文基于 rustc-dev-guide 的 Macro expansion 章节系统讲解 rustc 如何在解析阶段之后把被暂时搁置的宏调用即占位符不断展开、集成直到产出不含任何未展开宏的完整抽象语法树AST。读者将掌握MacroExpander::fully_expand_fragment的迭代展开算法、三层 hygiene卫生性层级体系、MBEMacros By Example解析器与过程宏proc macro的展开路径并结合 rustc 源码主要位于 compiler/rustc_expand/src印证每一步实现。宏展开概述从占位符到完整 ASTRust 编译器对宏的处理分为两大阶段解析阶段parsing与展开阶段expansion。在解析阶段普通 Rust 解析器parser会先把宏的调用内容暂时搁置用临时 占位符placeholders 替代其细节参见本书的 AST 验证 与 解析器 章节。而展开阶段的核心目标就是迭代地展开这些被搁置的宏直到整个 crate 的 AST 中不再存在未展开的宏或直接报出编译错误。展开过程发生在 crate 级别给定 crate 的原始源码编译器最终产出一个宏全部展开、模块全部内联的巨型 AST。多数展开相关的算法与数据结构都位于rustc_expand中基础数据结构在 compiler/rustc_expand/src/base.rs。另外需要特别指出cfg和cfg_attr与其他宏的待遇不同它们在 compiler/rustc_expand/src/config.rs 中被特殊处理。从源码结构看整个展开流程的入口链路为expand_crateexpand.rs将ast::Crate包装成AstFragment::Crate后调用fully_expand_fragment最终再通过make_crate()还原为 crate 并断言其NodeId仍是CRATE_NODE_ID。展开与 AST 集成核心迭代算法MacroExpander::fully_expand_fragmentexpand.rs是展开过程的主要入口。除了少数例外情况详见下文 Eager Expansion我们通常对整个 crate调用该方法。从高层看fully_expand_fragment以迭代方式工作我们维护一个未解析宏调用队列即尚未找到定义的宏反复尝试从队列中取出宏、解析它、展开它、并把结果集成回 AST。如果某一轮迭代没有任何进展就代表出现了编译错误。算法伪码如下初始化一个queue存放未解析的宏调用。反复执行直到queue为空或无法取得进展——此时是错误尽可能解析名称解析部分构建的 crate 中的导入imports。从部分构建的 crate 中尽可能收集宏Invocation包括fn风格的调用、属性宏、derive 宏并加入队列。出队第一个元素并尝试解析它。如果解析成功运行该宏的展开函数它消费一个TokenStream或 AST产出一个TokenStream或AstFragment取决于宏的种类。TokenStream是一组TokenTree的集合每个TokenTree要么是一个 token标点、标识符或字面量要么是一个带定界符的组()/[]/{}内部的一切。此时我们对宏本身已了解全部信息可以调用set_expn_data在全局数据中填充其属性即与ExpnId相关联的 hygiene 数据。把这块 AST 集成进当前已存在但仍是部分构建的 AST 中。这一步本质上是把token 状的一团变成带有侧表的、确定下来的正式 AST具体过程为如果宏产出的是 token例如过程宏我们需要把它解析成 AST此过程可能产生解析错误。展开过程中我们会创建SyntaxContext第二层层级体系见下文 Hygiene 章节。这两个 pass 会在每个从宏中新展开的 AST 片段上先后执行由InvocationCollectorexpand.rs分配NodeId同时收集这段新 AST 中出现的新的宏调用并加入队列。由DefCollector创建 Def paths、分配对应的DefId并构建约简图从解析器的视角把名字放入模块。展开单个宏并集成其输出后进入fully_expand_fragment的下一次迭代。如果解析失败把宏放回队列。进入下一次迭代……在真实源码实现中这一流程在 expand.rs 中体现为先用collect_invocations收集所有宏调用并用占位符替换InvocationCollector通过MutVisitor遍历 ASTexpand.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 its 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_macroscompiler/rustc_builtin_macros/src中同时那里还有一些早期代码生成设施如标准库导入的注入、测试 harnesstest harness的生成等。在 compiler/rustc_expand/src/build.rs 中还有一些用于构建 AST 片段的辅助工具。急切展开通常执行的是普通惰性展开所做事的一个子集实现方式是对 crate 的一部分调用fully_expand_fragment而不是像平常那样对整个 crate。其他相关数据结构展开与集成过程中还涉及一些值得注意的数据结构均在 base.rs 中定义ResolverExpandbase.rs——用于打破 crate 依赖的trait。它使得解析器服务可以在rustc_ast中使用尽管rustc_resolve和其他几乎所有东西都依赖rustc_ast。ExtCtxt/ExpansionDatabase.rs——持有各种中间展开基础设施数据。MacroExpander结构体expand.rs中保存的正是a mut ExtCtxtb与monotonic标志。Annotatablebase.rs——可作为属性attribute目标的一块 AST与AstFragment几乎相同唯一区别在于类型types和模式patterns可以由宏产生却不能标注属性。MacResultbase.rs——一种多态的 AST 片段可以根据其AstFragmentKind即 item、表达式、模式等转变为不同的AstFragment。SyntaxExtensionbase.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 的SymbolSpan即 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。以下是几个例子示例 0macro m() { ident } m!();这里ident最初具有上下文SyntaxContext::root在被m产出后它拥有上下文ROOT - id(m)。示例 1macro m() { macro n() { ident } } m!(); n!();此例中ident初始上下文为ROOT第一次展开后是ROOT - id(m)之后是ROOT - id(m) - id(n)。示例 2注意这些链并非完全由它们的最后一个元素决定换句话说ExpnId与SyntaxContext不是同构的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 ExampleMBE系统具有更强的 hygiene这可能导致两者之间的奇怪交互。该 hack 的目的只是让一切暂时正常工作。第三层调用点层级The Call-site Hierarchy第三层也是最后一层层级追踪宏调用的位置。在这一层级中ExpnData::call_site是child - 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 中有两种宏macro_rules!宏又称 Macros By ExampleMBE过程宏proc macro包括自定义 derivecustom derives。在解析阶段普通 Rust 解析器会把宏及其调用的内容搁置一旁稍后宏使用这些代码片段被展开。这里有几个重要的数据结构/接口均在 base.rsSyntaxExtensionbase.rs——宏的降级表示包含其展开函数把TokenStream或 AST 变换为另一个TokenStream或 AST以及一些附加数据如稳定性、宏内部允许的不稳定特性列表。SyntaxExtensionKindbase.rs——展开函数可能有几种不同的签名接收一个 token 流、或两个、或一块 AST 等这是一个列出它们的enum。BangProcMacro/TTMacroExpander/AttrProcMacro/MultiItemModifierbase.rs——代表各种展开函数签名的trait。expand_invocexpand.rs是分发到具体展开逻辑的关键函数它依据InvocationKind::Bang/Attr/Derive与SyntaxExtensionKind的组合调用对应的 expander例如Bang变体调用expander.expand(cx, span, mac.args.tokens.clone())再把返回的 token 结果解析为 AST 片段。Macros By ExampleMBEMBE 拥有独立于 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 树——它们包含开括号、闭括号以及两者之间的所有 tokenRust 要求括号类字符必须平衡。让宏展开操作 token 流而不是源文件的原始字节抽象掉了很多复杂性。宏展开器以及编译器其他大部分并不关心代码中某个语法构造的确切行列它关心的是代码中使用了哪些构造。使用 token 让我们只关心what而不必担心where。关于 token 的更多信息参见本书的 解析Parsing 章节。printer!(print foo); // foo 是一个变量把宏调用展开成语法树println!({}, foo)再把该语法树展开成对Display::fmt的调用这是宏展开的一个常见示例。MBE 解析器MBE 展开由宏解析器完成包含两部分解析宏定义解析宏调用。我们把 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变量是一组MatcherLocmacro_parser.rs我们希望用它们匹配 token 流它们是在匹配之前从宏定义中的原始 token 树转换而来的。从源码看MatcherLoc是一个扁平的、非递归的匹配单元枚举Token、Delimited、Sequence、MetaVarDecl、Eof等其注释明确指出它专为匹配时快速便捷的遍历而设计。用正则解析器的类比来说token 流是输入我们用 matcher 定义的模式去匹配它。以我们的示例为例token 流可能是包含示例调用print foo内部内容的 token 流而 matcher 可能是 token树序列print $mvar:ident。解析器的输出是ParseResultmacro_parser.rs它指示以下三种情况之一成功Successtoken 流与给定 matcher 匹配且我们已产生从元变量到相应 token 树的绑定。失败Failuretoken 流与 matcher 不匹配产生类似 No rule expected token ... 的错误信息。错误Error解析器内部发生了致命错误。例如当存在不止一种模式匹配时就会发生因为这表明宏是有歧义的。与普通正则解析器几乎完全相同唯一例外是为了解析不同类型的元变量如ident、block、expr等宏解析器必须回调普通 Rust 解析器。宏定义的解析代码位于 compiler/rustc_expand/src/mbe/macro_rules.rs。关于宏解析器实现的更多信息可参见 macro_parser.rs 中的注释。用我们的示例说明我们会尝试把调用中的 token 流print foo与从宏定义各规则中提取出的 matcherprint $mvar:ident和print 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::TokenStreamcompiler/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进行这种转换。自定义 DeriveCustom 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),仅供参考