前 22 篇的编译器每次运行都从头分析整个程序。命令行工具可以承受这个代价——几千行的 Sprout 程序,全量分析也就几十毫秒。但编辑器不行。用户敲下一个字符,期望诊断在百毫秒内刷新。如果每次击键都触发完整的词法、语法、名称解析和类型检查,延迟会随着项目规模线性增长。

本篇引入两项改造:让解析器在遇到语法错误时继续工作而不中断,以及让语义分析只重新计算受改动影响的部分。

容错解析:语法树必须覆盖全部源码

之前的解析器遇到无法归约的 token 时报错并停止。这对批量编译够用,但在编辑器场景里,用户正在输入的那一行几乎永远不合法。如果解析器在第一个错误处放弃,后面所有函数都拿不到语法树,诊断和跳转全部失效。

改造策略:遇到错误时,把当前无法解析的 token 序列包进一个 Error 节点,挂到 AST 的对应位置,然后跳到下一个同步点继续解析。同步点的选取沿用第 04 篇的方案——分号、右花括号、fn 关键字。

1
2
3
4
5
fn foo() -> i64 {
let x: i64 = 1 +; // 缺右操作数
return x;
}
fn bar() -> i64 { return 2; }

解析后的树中,foolet 语句包含一个 Error 子节点,但 foo 的函数节点、bar 的完整定义都存在。类型检查遇到 Error 节点时跳过该子树,对 bar 仍正常执行。

关键不变量:容错树的 token 范围之和等于源文件的全部 token。没有 token 被丢弃,没有 token 被重复。这保证了从任意源码偏移都能定位到树中的某个节点,编辑器的悬停和跳转才有根基。

文件版本:只重新解析改了的文件

编辑器通过 LSP 的 textDocument/didChange 通知告诉编译器哪些文件发生了变化。每个文件维护一个单调递增的版本号。编译器收到通知时递增版本号,标记该文件的解析结果过期。下次需要该文件的语法树时,用新版本的源码重新解析。其他文件的语法树保持不变。

1
2
3
4
文件          版本  状态
main.spr 3 ✓ 缓存有效
math.spr 5 ✗ 版本已更新,需重新解析
util.spr 2 ✓ 缓存有效

对于十个文件的项目,修改其中一个文件后,只有那一个文件重新走词法和语法分析。这一步已经把解析开销降到了十分之一。

函数级依赖跟踪

文件级粒度仍然不够细。一个文件可能有二十个函数,改了其中一个的函数体,不应该重新类型检查另外十九个。要做到这一点,需要把依赖关系从文件级细化到函数级。

每个函数有两个可独立追踪的部分:签名(名字、参数类型、返回类型)和函数体(内部语句和表达式)。签名是函数的公开接口,函数体是私有实现。

依赖规则:

  • 函数 A 调用函数 B → A 依赖 B 的签名。
  • 函数 A 使用了结构体 S 的字段 → A 依赖 S 的定义。
  • 函数 A 不依赖 B 的函数体。B 内部怎么实现,不影响 A 的类型检查结果。

这意味着:如果 B 的函数体改了但签名没变,只需要重新检查 B 自身。A 的类型检查结果还是有效的。

1
2
3
4
5
6
7
fn add(a: i64, b: i64) -> i64 {    // 签名未变
return a + b + 1; // 函数体改了(原来是 a + b)
}

fn main() -> i64 {
return add(3, 4); // 不需要重新检查
}

只有当 add 的签名发生变化——比如返回类型从 i64 改成 bool——才需要失效所有调用了 add 的函数。

依赖图与失效传播

把函数间的依赖关系画成有向图。节点是函数(和类型定义),边表示"A 依赖 B 的签名"。当某个节点的签名变了,沿着反向边找到所有依赖它的节点,标记为失效。失效沿依赖链传播:如果 A 依赖 B,B 依赖 C,C 的签名变了,那么 B 失效,接着 A 也失效。

1
2
3
4
5
6
7
render ──→ parse ←── format


compile ──→ check


emit

修改 parse 的签名后,renderformat(依赖 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
2
3
4
5
6
query type_of(name):
sig = query signature(name)
body = query body(name)
for dep in body.called_functions:
dep_sig = query signature(dep) // 如果 dep 签名未变,命中缓存
return check(sig, body, dep_sigs)

这种架构的好处是:失效自动发生在查询被请求时,不需要预先遍历整个依赖图。没有被查询到的节点即使过期了也不会消耗计算。编辑器通常只关心当前打开文件的诊断和光标所在函数的类型,因此大量未打开文件的查询根本不会被触发。

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 倍。项目越大,增量分析的优势越明显。一千个函数的项目中修改一个函数体,增量耗时仍在个位数毫秒级,而全量分析已经接近半秒。

缓存带来内存开销。每个函数的类型检查结果、每个文件的语法树都存在内存中。对教学语言来说,十万行项目的缓存在百兆级别。工业编译器会做更精细的淘汰策略;本系列保持全量缓存,简化实现。

练习

  1. 修改一个函数的函数体,不改变签名。用 --emit=query-log 观察哪些查询被重新计算,验证调用该函数的其他函数没有出现在日志中。
  2. 修改同一个函数的返回类型。再次观察查询日志,确认所有调用方都被重新检查。比较两次日志的差异。
  3. 删除一个被其他文件 import 的模块文件,观察产生的诊断。恢复文件后,验证诊断消失且缓存恢复。

上一篇:22 - 测量编译与运行性能。下一篇:24 - 接入语言服务器协议