从零编写现代编译器 23 - 不完整代码与增量分析
前 22 篇的编译器每次运行都从头分析整个程序。命令行工具可以承受这个代价——几千行的 Sprout 程序,全量分析也就几十毫秒。但编辑器不行。用户敲下一个字符,期望诊断在百毫秒内刷新。如果每次击键都触发完整的词法、语法、名称解析和类型检查,延迟会随着项目规模线性增长。
本篇引入两项改造:让解析器在遇到语法错误时继续工作而不中断,以及让语义分析只重新计算受改动影响的部分。
容错解析:语法树必须覆盖全部源码
之前的解析器遇到无法归约的 token 时报错并停止。这对批量编译够用,但在编辑器场景里,用户正在输入的那一行几乎永远不合法。如果解析器在第一个错误处放弃,后面所有函数都拿不到语法树,诊断和跳转全部失效。
改造策略:遇到错误时,把当前无法解析的 token 序列包进一个 Error 节点,挂到 AST 的对应位置,然后跳到下一个同步点继续解析。同步点的选取沿用第 04 篇的方案——分号、右花括号、fn 关键字。
1 | |
解析后的树中,foo 的 let 语句包含一个 Error 子节点,但 foo 的函数节点、bar 的完整定义都存在。类型检查遇到 Error 节点时跳过该子树,对 bar 仍正常执行。
关键不变量:容错树的 token 范围之和等于源文件的全部 token。没有 token 被丢弃,没有 token 被重复。这保证了从任意源码偏移都能定位到树中的某个节点,编辑器的悬停和跳转才有根基。
文件版本:只重新解析改了的文件
编辑器通过 LSP 的 textDocument/didChange 通知告诉编译器哪些文件发生了变化。每个文件维护一个单调递增的版本号。编译器收到通知时递增版本号,标记该文件的解析结果过期。下次需要该文件的语法树时,用新版本的源码重新解析。其他文件的语法树保持不变。
1 | |
对于十个文件的项目,修改其中一个文件后,只有那一个文件重新走词法和语法分析。这一步已经把解析开销降到了十分之一。
函数级依赖跟踪
文件级粒度仍然不够细。一个文件可能有二十个函数,改了其中一个的函数体,不应该重新类型检查另外十九个。要做到这一点,需要把依赖关系从文件级细化到函数级。
每个函数有两个可独立追踪的部分:签名(名字、参数类型、返回类型)和函数体(内部语句和表达式)。签名是函数的公开接口,函数体是私有实现。
依赖规则:
- 函数 A 调用函数 B → A 依赖 B 的签名。
- 函数 A 使用了结构体 S 的字段 → A 依赖 S 的定义。
- 函数 A 不依赖 B 的函数体。B 内部怎么实现,不影响 A 的类型检查结果。
这意味着:如果 B 的函数体改了但签名没变,只需要重新检查 B 自身。A 的类型检查结果还是有效的。
1 | |
只有当 add 的签名发生变化——比如返回类型从 i64 改成 bool——才需要失效所有调用了 add 的函数。
依赖图与失效传播
把函数间的依赖关系画成有向图。节点是函数(和类型定义),边表示"A 依赖 B 的签名"。当某个节点的签名变了,沿着反向边找到所有依赖它的节点,标记为失效。失效沿依赖链传播:如果 A 依赖 B,B 依赖 C,C 的签名变了,那么 B 失效,接着 A 也失效。
1 | |
修改 parse 的签名后,render、format(依赖 parse)和 compile(依赖 parse)都要失效。check 不直接依赖 parse,但 compile 失效后如果其签名也变了,check 也会跟着失效。emit 只在 compile 签名实际改变时才受波及。
只改 parse 的函数体而不改签名,则只有 parse 自身需要重新检查。六个函数中五个保持缓存有效。
查询架构
线性流水线——先全部解析,再全部名称解析,再全部类型检查——天然是全量的。要支持增量,需要换一种组织方式。
把每一步分析改造成按需查询。type_of(fn_name) 返回某个函数的类型检查结果。resolve(fn_name) 返回名称解析结果。parse(file) 返回文件的语法树。查询之间可以互相调用:type_of("main") 内部调用 resolve("main"),后者又调用 parse("main.spr")。
每个查询缓存自己的结果。再次调用时,先检查输入是否变化。如果输入(源码版本、依赖函数的签名)没变,直接返回缓存结果。如果变了,重新计算并更新缓存。
1 | |
这种架构的好处是:失效自动发生在查询被请求时,不需要预先遍历整个依赖图。没有被查询到的节点即使过期了也不会消耗计算。编辑器通常只关心当前打开文件的诊断和光标所在函数的类型,因此大量未打开文件的查询根本不会被触发。
rust-analyzer 是这种查询架构在工业级编译器中的成功实践。它用 salsa 框架管理查询依赖和缓存失效。本系列的教学语言规模小得多,手动维护依赖表即可,但组织思路一致。
缓存键不能只包含源文件的内容哈希。完整的缓存键还应包括:编译选项(优化级别、feature flags)、工具链版本(编译器自身的版本号)、目标三元组(如 x86_64-unknown-linux-gnu)。任何一项变化都可能导致不同的编译结果,使用旧缓存会产生错误。
模块级失效
第 19 篇引入了多文件模块。模块级别也需要处理增量失效。
删除一个文件时,该模块导出的所有符号消失。任何 import 了该模块的函数都要失效——它们依赖的签名不再存在,类型检查会产生"未定义模块"的错误。
新增一个文件时,如果已有的文件没有 import 它,不会产生任何影响。只有当某个文件添加了 import new_module; 语句并使用了其中的函数,对应的查询才会重新计算。
修改一个文件的公开签名时,所有导入该模块并使用了变更签名的函数都会失效,与函数级规则一致。
增量分析的效果
用第 22 篇的计时框架测量。十个文件、一百个函数的项目,全量分析耗时约 45 毫秒。修改一个函数的函数体后:
| 操作 | 耗时 |
|---|---|
| 全量分析 | 45 ms |
| 增量:重新解析 1 个文件 | 2 ms |
| 增量:重新类型检查 1 个函数 | 0.3 ms |
| 增量总计 | ~3 ms |
加速约 15 倍。项目越大,增量分析的优势越明显。一千个函数的项目中修改一个函数体,增量耗时仍在个位数毫秒级,而全量分析已经接近半秒。
缓存带来内存开销。每个函数的类型检查结果、每个文件的语法树都存在内存中。对教学语言来说,十万行项目的缓存在百兆级别。工业编译器会做更精细的淘汰策略;本系列保持全量缓存,简化实现。
练习
- 修改一个函数的函数体,不改变签名。用
--emit=query-log观察哪些查询被重新计算,验证调用该函数的其他函数没有出现在日志中。 - 修改同一个函数的返回类型。再次观察查询日志,确认所有调用方都被重新检查。比较两次日志的差异。
- 删除一个被其他文件
import的模块文件,观察产生的诊断。恢复文件后,验证诊断消失且缓存恢复。
上一篇:22 - 测量编译与运行性能。下一篇:24 - 接入语言服务器协议。
