7.5 编译器前端架构
编译器前端把源文本转换为已经检查、正确连接的程序表示。各阶段的职责不同,保持清晰边界才能让功能扩展和诊断(diagnostic)都可控:
source -> tokens -> CST/AST -> desugared AST
-> declarations/scopes -> resolved AST
-> types and semantic checks -> typed AST
-> IR lowering每一个箭头都是一份契约。语法分析器(parser)绝不能凭空创建符号绑定;名字解析器(resolver)不应决定机器指令。类型检查器(type checker)接收的 AST 应已完成名字解析,这样它才能报告“第 2 个实参的类型是 string”,而不是反复追问某个名称的含义。
每个趋都应说明输入、输出与错误
为每个趋写清三件事:输入表示、输出表示和错误不变量。词法分析器(lexer)输出有序词法单元(token)和词法诊断;语法分析器输出一棵树和语法错误;名字解析器输出绑定和名称错误;类型检查器输出类型和语义错误。一个趋可以在可恢复错误之后继续,但必须产生显式错误节点/类型,避免下一阶段崩溃或形成大量重复消息。
把这些 contract 摆成一张表会更清楚:
| 趋 | 输入 | 输出 | 错误类型 |
|---|---|---|---|
| 词法分析器 | 源文本 | 有序词法单元 | 词法错误 |
| 语法分析器 | 词法单元 | CST/AST | 语法错误 |
| 名字解析器 | AST | 已解析 AST + 绑定 | 名称错误 |
| 类型检查器 | 已解析 AST | 带类型 AST | 语义错误 |
| 降低 | 带类型 AST | IR | (不应再产生前端错误) |
每一行的“输出”都是下一行的“输入”;只要任一行开始做下一行的工作(例如语法分析器凭空创建绑定),边界就坏了。
不要只用一个巨大的可变“编译器上下文”作为接口。那会让人无法判断每项事实在哪个时刻有效。应优先使用小而不可变的编译单元、诊断汇、所有权明确的驻留器/竞技场,以及带类型的趋结果。只有能解释失效时才缓存:当表达式、其绑定或它依赖的类型声明变化时,缓存的类型结果就应失效。
诊断与测试贯穿整个前端
高质量诊断需要多个趋的事实:语法分析器的范围、名字解析器的声明位置、类型检查器的期望与实际类型,以及导入的来源。应存储结构化诊断,而不是立刻打印字符串;这样 CLI、编辑器、JSON 和测试快照都能一致地呈现同一事实。
先单独测试每一层,再测试交接处。词法分析器测试应断言词法单元跨度;语法分析器测试应快照 AST 形状;名字解析器测试应断言符号 ID 和遮蔽;类型测试应断言类型及错误范围。最后,使用在每一层故意放入一个错误的端到端程序。各阶段能就合法和非法程序达成一致时,前端才值得信赖。
架构提示
当一个特性显得很难时,画出它必须向每个趋添加哪些数据。若加入 import 需要语法分析器改动、名字解析器边、符号来源、诊断和类型可见性,却不需要后端改动,这张图能保护你不去修改无关模块。
跟随一个程序穿过各个 pass
对于 import math; let r = math.pi; print(r);,词法分析创建词法单元种类和范围;语法分析构建导入声明、let 声明、成员访问和调用。模块解析绑定 math,名字解析绑定 r,类型检查推断 pi 的类型并验证调用。每个阶段只增加一种事实,而不改写由其他阶段拥有的事实。
当 math.pi 失败时,前端应区分若干情况:模块缺失、模块已加载却未导出 pi、pi 存在但不公开,或 math 被局部值遮蔽。这些情况需要不同的名字解析器事实和修复建议。单一的基于字符串的查找无法产生这种质量的诊断。
错误容忍是一项架构选择
IDE 希望对 let x = ; print(x); 也得到有用的树,而批处理编译器可能在达到错误阈值后停止。两者都需要显式的恢复产物:缺失表达式节点、错误符号和错误类型。后续趋应尽可能安静地传播这些信息。如果名字解析器已知道 x 无效,类型检查器应避免再为它产生五条无关消息。
要审查的趋边界
- 每个趋是否接受文档所规定的恢复节点?
- 诊断是否始终是结构化数据,直至最终渲染层?
- 失败的趋能否返回局部但内部一致的结果?
- 缓存失效是否绑定到结果实际依赖的语义事实?