树遍历解释器的 Node 架构(二):位置与错误——坐标怎么进入接口
系列第 2 篇。
第 1 篇里,求值、执行与类型检查都会失败,而失败时错误只有消息、没有位置(”类型不符”、”未定义变量”、”除数为零”都一样)。近两千行源码,这些话等于没说:不知道指的是哪一处。这一篇补上坐标:位置为什么不是又一种操作、错误怎么带上坐标,以及失败的层名归谁。
它不新增读法:位置既不遍历树,也不产出值,只是让每条错误能说出”第 N 行,第 M 列”。麻烦的地方在于,它照样得挂在 Node 接口上;一旦挂上去,以后每个形态都要实现这个方法。
位置:不是又一种操作
做法是给每个节点补一个位置字段,再加一个读取它的方法:
type Position struct {
Line, Column int
}
type Binary struct {
Op Op
Left, Right Expr
At Position
}
func (n Binary) Pos() Position { return n.At }
type Node interface { // 位置 + 渲染,所有节点共有
Pos() Position
writeTo(b *strings.Builder)
}
Expr 与 Stmt 不重复列出:它们只是嵌入 Node、各加自己的方法,定义见第 1 篇。
每个节点后面都挂着自己的位置:
Binary{Op: OpPlus} @1:2
├── NumberLiteral{Value: 1} @1:1
└── Binary{Op: OpStar} @1:4
├── NumberLiteral{Value: 2} @1:3
└── NumberLiteral{Value: 3} @1:5
Pos 只是把节点后面的 @行:列 读出来。把它放进接口,有两个替代方案被否决。字段直读行不通,因为读法的参数是 Node,拿不到具体类型的字段。可选接口(只让一部分节点带 Positioned)会让每个读法先做一次类型断言,还把”每个节点都有位置”从契约降成可选能力;而报错路径恰恰不能容忍”这个节点没有位置”。所以位置不是又一种操作,它是接口上的一个投影。
Pos 落在 Node 上、而不是 Expr 上,也是同一个理由:语句也要报位置——今天语句自己的错误都来自内部表达式,Let.At 是为将来的语句级报错(重复定义、块收尾)留的坐标;而语句不是表达式。三个接口共用这一个方法,位置因此成了”所有节点”的共有属性。
决策五|位置做成接口方法,单点而不是区间
- 拒绝:字段直读(读法只拿得到
Node);可选接口Positioned(报错路径不能容忍”这个节点没有位置”)。- 代价:每个节点多一个
At字段、一行Pos(),以及构造时填At的一处;接口永久多一个方法。单点答不了”标出这一段”。- 失效:要做下划线、跨行诊断或 LSP 时,单点得换成带起止的
Span,Position、errAtPos和位置表要一起改(见本节末)。
这里用的是单点位置:它只回答”第一个错误指向哪个节点”。标出整段、跨行报告、或者把诊断交给 LSP,都需要起点加终点的区间。单点位置是有意裁出来的边界,不是通用方案。
错误怎么带上坐标
错误构造换成 errAtPos(pos, kind, format, ...)(签名 func errAtPos(pos Position, kind ErrorKind, format string, args ...any) error)。
“第 N 行,第 M 列: 消息”这个格式由 Error() 统一拼装,解析、类型检查、求值三层共用;kind 决定这条错误属于 ErrSyntax / ErrType / ErrUndefinedVar / ErrZeroDivisor / ErrInternal 里的哪一类。
解析器一侧用的是它的薄包装 errAt(tok, ...):收一个 Token,固定 KindSyntax。
遇到 ILLEGAL / UNTERMINATED_STRING / BOOLEAN 时,它把”哪个字符 / 哪种没闭合 / 哪个关键字”补在消息后面,内部仍然调 errAtPos。
第 4 篇里的上限报错写作 errAt(tok, ...),用的是同一个包装,不是笔误。
该指向哪个节点,由错误的来源决定:
| 错误来源 | 用哪个位置 |
|---|---|
| 类型检查:左操作数类型不对 | 该层左操作数的位置(leftPos) |
| 类型检查:右操作数类型不对 | 该层右操作数的位置(st.right.Pos()) |
| 类型检查:运算类型不匹配 | 运算符位置 |
| 求值:运算不能做 | 运算符位置 |
| 求值 / 预检:未定义变量 | 标识符位置 |
左操作数那一栏在左结合链上要看清楚:leftPos 的取值是——第一层用最左操作数的位置,之后每合并一层就更新成上一层的运算符位置。所以它记的不是最左叶子,而是”当前这棵已合并子树”的位置。两种情况差别明显:
1 + 2 * "a"
^ 第 9 列:STAR 的右操作数类型不对(这里的 `"a"`)
("a" + "b") * 2
^ 第 6 列:左操作数是合并出来的子树,位置取它自己的运算符 +
把第二种输入的树摊开(@行:列 是各节点的 At):
Binary{Op: OpStar} @1:13
├── Binary{Op: OpPlus} @1:6 ← 合并到这一层后,leftPos 取 + 的位置
│ ├── StringLiteral{Value: "a"} @1:2
│ └── StringLiteral{Value: "b"} @1:8
└── NumberLiteral{Value: 2} @1:15
第二种才是 leftPos 的用武之地:报错指向那一层的 +,而不是最左边的 "a"。这是有意的——问题出在哪一层合并,就指向哪一层。
如果绕过预检直接求值,1 + 2 * "a" 的报错会落在 *(第 7 列):那是求值的兜底分支,用的一律是运算符位置。
再看三条完整的错误,位置不同,指向也不同:
"a" * 2 → 第 1 行,第 1 列: 运算符 STAR 需要整数,得到 string
1 + "a" → 第 1 行,第 3 列: + 两侧类型不同:int 和 string
1/0 → 第 1 行,第 2 列: 除数为零
第一条里,类型检查问的是”这个操作数类型对不对”,所以指向 "a";第二条两个操作数各自都合法,错在”相加”这个动作,所以指向运算符;求值问的是”这个运算能不能做”,所以第三条也指向运算符。
规则只有一句:问题归谁,就指向谁。这条规则能落地,靠的是位置可以从任意节点上取到;位置之所以做成接口方法、而不是一张外部索引表,原因也在这里:读法的参数只有 Node,它需要的坐标必须在契约里。
错误本身是 *Error{Pos, Kind, Msg}:位置和类别一起交给上层,errors.As 取出 Pos 重排,errors.Is 按类别分流,都不必匹配文本。
位置的边界:单点什么时候不够
单点够解释器用,因为解释器只需要回答”这条错误指向哪”。
需求一换,位置的形状就跟着换:格式化、lint、IDE 要保留注释、做增量编辑,就会长出带区间的语法树、父指针和 trivia(注释、空白这类不参与语义的记号)。
树本身可变不可变也跟着用途走,但那是另一条主题(第 5 篇讲),这里只谈位置:一旦要标出整段,位置就从单点变成区间。
几种主流实现的位置表示摆在一起,差别一目了然:
| 设计 | 位置表示 |
|---|---|
| Lox(jlox) | Token 引用 |
| Lox(clox) | 无(不留树) |
| ESTree / Babel | loc / start / end |
| TypeScript | pos / end |
Rust syn |
Span |
Go go/ast |
Pos() / End()(token.Pos + FileSet) |
| 本实现 | At 单点 |
分界不在”精确到什么程度”,而在”错误要标出多大一段”。本实现要的最多就是”哪一处”,所以一个点够用。
一旦要标出整段、要跨行、要让编辑器在原文上画波浪线,就得把 Position 换成 Span,errAtPos 和上面那张位置表跟着改。
Go 的 go/ast 走的是这条区间路线:Node 只有 Pos() / End(),位置是 token.Pos 偏移,行列由 token.FileSet 现算。本实现用 At 单点直接存行列,省掉 FileSet 与 End() 的计算,但标不出整段;要接 LSP,换成 Span 只是第一步,token.Pos 的字节偏移与 LSP 的 UTF-16 列之间还要换算。
这条边界还会从别处反推回来。第 4 篇给解析器装了深度闸,求值侧却没有同款的闸——不是”位置说不清”,而是求值侧已经不需要。
左脊迭代之后(第 4 篇),求值侧已经没有无界递归,只剩解析器里那三条(括号、一元负号、一元 !),在解析时就挡住了。位置协议因此只需回答”哪一处”。
层名是宿主加的
层名(解析失败、类型检查失败、求值失败)不是错误本身的一部分。文件模式在包装错误时加上层名,REPL 在打印时加上,两处都是宿主在边界上的动作;错误自己只带位置、类别和消息。
另一种做法是让错误自己带层字段,或者干脆用错误码。本实现选了个折中:错误带类别(Kind)但不带层。
五个哨兵 ErrSyntax / ErrType / ErrUndefinedVar / ErrZeroDivisor / ErrInternal 负责分类,errors.Is 按类别匹配,errors.As 取出 *Error 读位置。
分层仍由宿主加:run 套上”解析失败:/ 类型检查失败:/ 求值失败:”,REPL 打印时自己加前缀。
好处是程序能按类别分流,测试与将来的错误恢复都不必匹配文本。
代价是类别表要维护,而且类别与层并不一一对应:一个 KindType 可能来自预检,也可能来自求值兜底。所以这一篇把三件事分开:格式给读者(Error() 统一渲染)、类别给程序(哨兵 + errors.Is)、层留宿主。
两个宿主的差别不止错误打往哪里:失败之后环境归谁、整行要不要回滚,是第 5 篇的主题。在那之前还有一个决定没做:这三个接口里导出几个方法(第 3 篇)。

