17.4 把错误消息视为编译器用户体验(UX)
对于绝大多数程序员而言,诊断信息(diagnostics)是他们平日里最频繁接触到的唯一一个编译器交互界面。一段哪怕技术上百分之百绝对正确的底层报错消息,在程序员实际开发中依然可能极其难用:比如它可能会莫名其妙指向完全错误的词法记号(token)、无情暴露编译器晦涩内部的实现细节、或者因为一个最底层的单一根源问题而排山倒海般地爆发出二十个后续的无关连锁报错。因此,在编译器工程中,我们应当始终将诊断错误记录视为高度结构化的“产品级数据(structured product data)”,而不是随心所欲在各个编译器处理遍(passes)中随意拼接并发射出的匿名终端字符串。
一条生产级的结构化诊断记录应当明确、自若地包含:稳定的错误码(error code)、严重级别(severity)、主错误代码区间(primary source span)、次要标注辅助信息(secondary label)、简洁概要(headline)、解释性备注(explanatory note)以及若干条自动代码修复建议(fix-it)。概要设计应当一针见血地挑明核心矛盾:例如输出“期望获得 bool 类型,但实际获得了 String(expected bool, found String)”往往比简单粗暴地报一句“类型不匹配(type mismatch)”要具有可操作性得多。主区间标签辅助程序员在第一时间精确锁定目光汇聚点,而次要的 labels 则负责将所有的辅助性物理证据(例如该变量先前究竟是在哪里被声明的、对应的引用借用 borrow 又是从哪一行开始生效的)给无缝串联在一起。备注主要用来耐心阐释该项语言设计规则为什么在此处适用,而帮助说明则负责诚恳地建议程序员接下来应当如何修改,但是在建议时也绝不能假装该项修复必定是正确和无损的。
源码物理坐标的精确定位在实践中其实极易出现 bug。语法解析器(Parser)在解析时通常喜欢直接记录底层的字节相对偏移量(byte offset),而编辑器内部则倾向于统一使用 UTF-16 行列编码坐标,但人类在界面屏幕上看到的最终显示列数(display columns),往往还会受到制表符(tab)宽度设置、各种组合符号标记及中日韩(CJK)等不同宽字符宽度的剧烈拉扯干扰。在编译器内部,我们应当始终坚持统一保留最原始的基准字节范围(canonical byte range),仅在需要向外输出或与编辑器交互时,再基于完全一致的源码物理快照版本进行零故障转换。Fix-it 修复机制也必须针对特定的代码快照版本进行匹配,绝不容许让两个自动生成的编辑替换区间发生冲突重叠,并且生成的补丁字符必须能够确保绝对可以用 parser 重新解析。如果面对同一个问题存在多种均合理的代码修复建议,则应当大方展示让程序员抉择,而不是在暗地里盲目猜测一个。
举例来说,假设我们在编译时遭遇了 total += name 的失败逻辑,其真实根因在于 total 被推导为整数而 name 被判定为了字符串。一段真正具有优秀 UX 体验的诊断消息,应当在终端精确地将 name 标记出来作为高亮,同时把 total 的推导类型呈报在旁边,并主动指向代码中将 name 声明为字符串的初始不变量代码位置;通常也应当仅在这类语言中存在安全显式类型转换等少数确定契约前提下,才在 fix-it 中主动提供转换。为了省事直接给一整条长达数行的 statement 底部画横线,虽然看起来足够醒目,但往往会令排错精确度大打折扣。任何将类似底层合一算法的局部类型中间变量 ID(unification-variable ID)直接原样打印给用户的行为,都只是在极度偷懒地强迫用户去理解编译器内部的隐秘实现,而不是在以人类易读的思路沟通高级语言本身的编程模型。
错误恢复机制决定后续诊断的二次质量
在语法解析阶段一旦物理发现错误后,编译器既可以直接强制中止并退出编译,也可以选择执行尝试性恢复(recovery)并继续向后解析。错误恢复(Recovery)对于代码编辑器中极高频发生的动态交互式即时编辑至关重要,却极其容易在实现不佳时引发严重的连锁报错(cascade/瀑布效应):一个哪怕只是由于程序员不小心打字缺失的定界配对符(如括号、分号等 delimiter),在未能成功恢复时极易强行扭曲整棵语法分析树的结构,进而诱发出后续一连串名称未定义名(name)和类型不匹配(type mismatch)错误。恐慌恢复模式(Panic-mode recovery)会直接选择丢弃后面的所有 token,直到在代码流中重新遭遇如分号 ;、右花括号 } 或全新类型声明开头等可靠的代码同步检查点(synchronization point)。它简单且高度可靠,但在遭遇严重阻碍时会损失掉故障点附近大量十分关键且原本没问题的 AST 语法树结构。局部自主修复机制(Local repair)则尝试通过插入、删除或替换极少数特定的词法记号来模拟对常见语病的本地修正;它能留存更多的抽象语法树树体,却也绝不能凭空乱造和发明出不合不变量要求的非法内容。更为高超的岛屿分析法(Island parsing)可以提前识别出声明等稳健的“可靠岛屿(island)”,并将所有不确定和混乱的代码区间一并标记并视为不透明且直接跳过解析的未知水域(opaque water)。
同时,失败的计算节点和报错标记必须在后续的语法树与整个类型系统中显式表达和流动。例如,一个占位的非法表达式对象(Error expression)在生成时携带原始的出错 span 以及特定的“该问题已经报告(already reported)”之不变量标记。类似地,有毒的占位报错类型(Poison/error type)应当顺畅地流过类型系统所有下游的依赖级计算与合并操作,在类型推导时自动吞噬掉随后的类型检验并维持不报错状态,以此来避免对同一处失误进行反反复复且令人恼火的连续报错。后续所有的优化遍(passes)还必须能够自若、安全地容忍并绕过这些 poison/error 类型节点;一旦发现关键的不变量在物理上已不成立,就应当优雅地跳过对应编译层级。因此,错误恢复工程是覆盖整个编译器流水线顶层设计的全局契约(contract),而静态分析绝不仅仅只是在语法解析器(parser)层面上玩弄的一些局部把戏。
验证测试诊断消息的可用性,而不单单比对固定文本
编译器诊断黄金规格基准测试(Diagnostic golden tests)应当全面覆盖:报错码(code)的一致性、严重级别(severity)、各种 Labels 的位置、终端下的源码高亮渲染、以及 fix-its 替换逻辑的稳健性。编写具有强验证断言的测试用例比简单化地进行终端彩色输出的完全相等比对要稳定和可靠得多。测试框架应当自动将每一条机器可自动套用的代码补丁(machine-applicable fix)实际应用到后台的临时工作区(workspace)中,执行全新重新解析,以此来验证推荐的代码修改本身是否确实不会引起任何低级的新错误。测试集还应当全面涵盖各种极端测试:例如未完成文件测试、多个同时错误、macro 编译或者是自动生成代码中的报错定位、宽字符 Unicode 以及陈旧的 editor 缓存映射。
拥有高度稳定的诊断报错码(diagnostic codes)可以允许在线排账文档、集成开发环境(IDE)、编译选项中的报错抑制机制(suppression)以及全流程的数据遥测监控(telemetry)安全且高频引用同一类经典错误,即便是编译器本身的字里行间措辞和本地化内容在日常迭代中进行着更迭改进。机器可读输出要给 schema 版本,并将可本地化文字与稳定字段分开。Localization 会改变语法与宽度,因此不要用细碎翻译片段拼句子;应保留带 typed placeholder 的完整消息。
指标必须结合含义解释。错误数量减少,可能是 cascade suppression 做得好,也可能是 parser 过早放弃。更有用的指标包括真实 fault 到 primary span 的距离、有效 fix 比例、重复 code 比例、首条相关消息耗时,以及用户能否不依赖外部资料完成修复。用真实损坏程序进行短小 usability study,可以发现 snapshot 看不到的问题:错误强调、陌生 jargon,或技术上可行但上下文中错误的建议。优秀诊断既解释被违反的模型,也帮助程序员完成下一次安全编辑。