7.2 AST 节点设计与源码范围
AST 是语法分析器(parser)、名字解析器(resolver)、类型检查器(type checker)、优化器、格式化器(formatter)、调试器和诊断(diagnostic)之间的接口。节点设计应让非法形状难以构造,让常见分析易于编写。
Expr = IntExpr(value, range)
| NameExpr(token, range)
| BinaryExpr(left, operator, right, range)
| CallExpr(callee, arguments, range)a + b 的整体范围从 a 的开头延伸到 b 的结尾。还应保留更窄的范围:报告“未知名称”时使用名称词法单元(token);报告“未定义该运算符”时使用运算符;报告“期望 )”时使用分隔符。单一的整表达式范围无法生成所有有用的诊断(diagnostic)。
下表把常见诊断与应用的范围对应起来:
| 诊断 | 使用的范围 |
|---|---|
| 未知名称 | 名称词法单元 |
| 该类型未定义此运算符 | 运算符词法单元 |
期望 ) | 插入位置或分隔符 |
| 整个表达式类型不匹配 | 整表达式范围 |
选错范围会让用户看到“错误在那里,但光标指在这里”。节点同时保留宽范围与几个关键子范围,才能让每条诊断都有精准落点。
范围需要明确的约定
使用半开区间 [start, end):包含起点,不包含终点,终点是节点结束后的第一个字符位置。这样相邻范围可以自然拼接,空范围有明确定义,提取源码也很直接。文件对象只需在展示消息时把偏移量映射为行和列,避免每个节点缓存不一致的位置。
合成节点(synthetic node)的范围也必须诚实。解糖产生的节点继承原始结构的范围;恢复节点指向意外词法单元或插入位置。不要为了让错误“有地方可指”就把合成节点的范围扩展到无关源码上。
遍历与语义旁表
许多阶段都会遍历同一棵树。先从简单的递归遍历器开始;当许多趋(pass)都需要穷尽式分派时,再引入访问器。不要把所有事实都直接放到可变节点上。语法分析器负责语法,名字解析器可以在旁表中记录 NameExpr -> SymbolId,类型检查器可以记录 Expr -> Type,或产生一棵后续的带标注树。这样的分离有利于测试和增量分析。
源码范围不是身份(identity)。插入一个字符就会移动许多范围。只有工具确实需要稳定身份时才引入独立的 NodeId;并且要把 NodeId、范围和语义事实视为不同概念。
调试提示
如果每个错误都高亮整个文件,就去找第一个把父节点范围复制给子节点的构造器。若新增语言特性被悄悄忽略,应要求对节点种类进行穷尽式分支,而不是保留一个默认分支。
围绕不变量设计节点
让每个构造器保证一条小而明确的事实。CallExpr 拥有一个调用目标和一个有序的实参列表;它不应允许空调用目标,也不应把逗号词法单元伪装成实参。BinaryExpr 应恰好拥有两个表达式,且运算符必须属于语言的二元运算符集合。在构造 AST 时拒绝畸形结构,远比多轮之后在类型检查器里排查缺失子节点容易。
列表也应同样谨慎。空代码块可表示为 BlockStmt(statements=[]);缺失的 else 通常应表示为 elseBranch=null,而不是空代码块,因为两者的源码含义不同。可选语法、恢复时缺失的值和空列表是三种不同状态,应在数据模型中以不同名字表示。
值得编写的范围测试
f(a, b)的范围包含最后的),但其第一个实参的范围不包含。
- 嵌套表达式的范围被其父节点范围包含。
- 每个语法分析器创建的节点都拥有当前源文件内的范围。
- 合成恢复节点具有经过刻意选择的零宽范围或词法单元宽度范围。