// Created At 2026-10-27
// Go · Interpreter · Engineering

树遍历解释器的 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 篇)。

如果这篇文章对你有帮助,可以请我喝杯咖啡 ☕️
Ali PayWechat Pay