8.5 高质量语义诊断
到了第 8 章,编译器前端已经掌握了名字、作用域、声明与类型这些事实。此时语义诊断就不再是“附赠功能”,而是整个前端体验最关键的部分之一。一个只会拒绝错误程序、却只给出含糊消息的类型检查器,在技术上能工作,在教学和工程上都不够好。
高质量的语义诊断,至少应该让用户迅速看明白四件事:
1. 到底哪里错了?
2. 最窄、最相关的范围在哪里?
3. 这条消息背后依赖了哪些事实?
4. 我下一步应该先改哪里?
好消息本质上是结构化事实
像 type error 这种消息几乎没什么修复价值。更好的消息可能是:
argument 2 of takesInt expects int, found string这句话背后其实依赖了好几类事实:
| 需要的事实 | 常见拥有者 |
|---|---|
| 实参的源码范围 | 语法分析器(parser) |
| 被调用函数的声明位置 | 解析器(resolver) |
| 期望的参数类型 | 解析器 + 类型检查器(type checker) |
| 实际的实参类型 | 类型检查器 |
这就是为什么语义诊断在最终渲染之前最好一直保持结构化。CLI、编辑器提示、JSON 输出、黄金(golden)测试快照,最终都需要同一套底层事实,而不是一串提前拼好的纯文本。
范围越准,修复越快
诊断应尽量指向“与当前错误最直接相关的最小范围”。
例如:
- 字段不存在时,通常应高亮字段名本身;
- 参数类型错误时,通常应高亮那个实参表达式;
- 返回类型不匹配时,通常应高亮返回表达式,并在需要时附上函数签名位置。
如果每条消息都高亮整行、甚至整文件,那编译器虽然技术上有位置信息,但并没有真正帮助用户更快定位修改点。
抑制连锁错误,不等于掩盖问题
语义分析器经常会遇到一个现实选择:当第一个错误已经出现后,后面是否还要把所有连带后果全都再报一遍?
例如,如果 x 未声明,那么类型检查器是否还应继续报告:x + 1 不合法、print(x + 1) 的参数类型错误、外层函数无法推断返回类型……
大多数情况下,不应该。前端应传播 ErrorSymbol 或 ErrorType,让后续阶段保持稳定,但不要把一个根因扩散成五条低价值噪声消息。
注记(note)、次级范围与修复引导
很多语义错误并不只需要一个位置。
| 错误类型 | 常见有用的补充注记 |
|---|---|
| 参数类型不对 | 该参数在函数声明中的位置 |
| 重复声明 | 第一次声明的位置 |
| 返回类型不匹配 | 函数返回类型签名的位置 |
| import/export 不可用 | 符号定义处或被隐藏的来源位置 |
这并不意味着每条诊断都要写成长篇说明,而是意味着:当一条消息本身不够时,编译器应补上一条精准的次级注记,而不是丢给用户一段模糊的大作文。
一个重写对比
比较这两条消息:
bad:
type error
better:
cannot return bool from function returning int
note: function declared here: fn area(...) -> int第二条更有价值,因为它明确指出了操作类型、期望类型、实际类型,以及这份契约来自哪一处声明。它仍然很短,但每一个成分都在帮用户更快修复问题。