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

树遍历解释器的 Node 架构(五):Node 契约的边界——上下文、失败与环境镜像

系列第 5 篇。

前四篇都在 Node 接口里面:三种读法怎么分派、位置怎么进契约、形态集合怎么只在包内扩展、顺序与深度怎么由方法体决定。这一篇退一步,站在契约的边界上:先看宿主与环境镜像,再回到契约本身,最后看接口之外的两件事。

先把边界划清:Node 只保证两件事——位置(Pos)与渲染(writeTo)。 有值的表达式才多出 Eval 与 typeCheck,语句才多出 Exec 与只报错的 typeCheck;形态集合则由包内定义(这三个方法都不导出)。

它不保证几种操作各自要什么上下文,不保证失败之后环境归谁。这些属于宿主,而宿主的选择会反过来给接口加约束。

一句话记住这一篇的立场:两套环境的镜像不是 Node 的设计,是”预检先于求值”这个决策的代价;树不可变是一条写进契约语义、却不由接口方法强制的约定——靠值接收者和测试维持。

上下文与失败:宿主的那一圈

四种操作各要一个上下文:渲染写 strings.Builder,类型检查写 TypeEnv,求值与执行写 Env。

失败面也不同——Pos 和 writeTo 不会失败,Eval、Exec、两种 typeCheck 都会,所以只有这几个方法带 error。失败是读法的属性,与节点无关。

上下文由谁准备、失败之后归谁,取决于宿主。

失败怎么收场。 先看文件模式。源码是两行:let x = 1,然后 x/0。运行到求值这一步时(只截取这一段):

== 3. 求值 ==
  语句[0] 返回值 = 1
求值失败:第 2 行,第 2 列: 除数为零

第一条语句已经执行,x 已经进了环境,第二条才失败。文件模式只做了一件事:把错误打出来、以非零码退出。部分求值确实发生了,只是没有观察者,进程一退,x 就消失。

REPL 里每一行都是可观察的,所以失败必须让整行从未发生。

两种宿主跑的是同一套分层(解析、类型检查、求值),差别只有两处:失败之后环境归谁,错误打往哪里。错误的内容与格式见第 2 篇,这里只管它往哪去。

宿主 失败后果 环境生命周期
文件模式 进程退出,已求值的语句作废 随进程消失
REPL 会话继续,整行回滚 临时副本,成功才换回

预检的代价。 第 1 篇的决策二是”预检是第二遍完整读树”。它的代价在这里:类型先于值存在,环境因此要备两张,还得手工保持同步。

Let 是 let 名字 = 表达式 的语句,主要字段是 Name 和 Value(外加位置 At)。它在两个环境里的写法:

func (l Let) typeCheck(env *TypeEnv) error {
	t, err := l.Value.typeCheck(env)
	if err != nil { return err }
	env.Bind(l.Name, t)
	return nil
}

func (l Let) Exec(env *Env) (Result, error) {
	v, err := l.Value.Eval(env)
	if err != nil { return Result{}, err }
	env.Set(l.Name, v)
	return Result{Value: v, HasValue: true}, nil
}

类型检查在求值之前跑完整个输入(文件模式是整个程序,REPL 是一行),所以它只能得到类型;求值才拿得到值。

两套环境因此必需,但它们必须同步:同一个 let 在两个环境里要有同样的键。

同步靠”两个方法成对写”,没有任何机制保证它。语句接口不返回类型,类型只留在 TypeEnv 里,连”顺便带出来”这条路都没有。

REPL 的办法是把一次输入做成事务:Env 和 TypeEnv 各复制一份临时副本,两遍都成功之后才一起换回主环境(env.vars = tmpEnv.vars、typeEnv.vars = tmpTypeEnv.vars),失败时整行回滚。流程如下:

line ──▶ parse ──▶ clone(Env, TypeEnv)
                       │
                       ├─ typeCheck(tmpTypeEnv) ──失败──▶ 丢弃,主环境不变
                       │
                       └─ Exec(tmpEnv) ──失败──▶ 丢弃,主环境不变
                                │
                                └─ 成功 ──▶ commit:env.vars = tmpEnv.vars
                                                   typeEnv.vars = tmpTypeEnv.vars

以 let y = 1/0 为例:

toy> let y = 1/0
求值失败:第 1 行,第 10 列: 除数为零
toy> y
类型检查失败:第 1 行,第 1 列: 未定义变量 "y"
toy> :env
无绑定

类型检查确实 Bind 过 y,求值失败后整个临时环境被丢掉,所以 y 既没有值,也不留下类型。

如果两套环境因为某个 bug 分叉,:env 会把缺失的一侧显式标成 <unknown> 或 <unbound>,不会把 map 零值当成 int 混过去。具体说:<unknown> 是值在、类型侧缺失;<unbound> 是类型在、值侧缺失。

替代方案各有问题:

  • 合并成一套环境:在类型检查时写入占位值,占位值迟早被求值结果覆盖,但”没有值”这个状态就消失了,再也分不清”声明过还没算”和”算过”。
  • 让 Value 自带类型:在检查期造一个只有类型、没有内容的值,等于把两套表示的差异藏进一个类型里。
  • 推迟到求值时就地检查:预检就没有了,第 4 篇里”1/0 + "a" 根本不会开始求值”的现象也不复存在。

结论不太好看:镜像是”类型先于值存在”的直接后果,是笔实打实的架构债。能做的只有两件事:把同步做成事务(REPL 的临时环境),让分叉可见(:env 显式打标)。

不可变:一半靠类型系统,一半靠约定

这一节是整篇里唯一真正属于 Node 契约的部分。前面讲宿主,接口在那边只约束”分派”和”形态集合”;到”节点能不能被改写”,约束才落回节点自己。

别的树做过另一种选择:Babel 允许在读法里就地改节点,适合变换流水线;Roslyn(.NET 的编译器平台)与 rowan(rust-analyzer 用的无损语法树库)把树做成不可变值,用重抄换缓存和增量解析。本实现选不可变,理由和位置只取单点一样:够用就不加机制。

节点只存数据字段和位置:字面量、运算符、子节点、名字,加上 At;没有父指针、没有缓存的类型、没有算好的值。这不是为了省几个字段,而是几种读法共享同一棵树的前提:读的结果不写回节点,树在下一次读之前保持不变。

不可变的这一半由类型系统直接给出,不靠人遵守:方法用值接收者,节点以值存在 Node 接口和它所属的那组接口里,拿到手的永远是副本。n.Left = ... 改的是副本,落不到树上,读法连”顺手改一下”的入口都没有。

这也是接收者用值、不用指针的理由:指针接收者等于把改写入口重新发出去。

真正还能改到树的只有一条路:宿主持有 []Stmt,任何一遍读法都可以替换切片元素,比如”检查时顺手常量折叠”。这条路编译器不拦,只能靠用例兜住。

有一条用例在读法跑之前取一次树的形状,之后连跑三遍 SExpr + 类型检查 + 执行,逐遍比对签名,同时比对树的快照,元素一被替换就会失败。

这条不变量只有一条用例兜着:附录的清单里,”谁会发现”一栏基本是空的,测试期还有这条形状比对的用例,运行时则悄无声息。把”节点构造完成后不再修改”写成包级入口的前置条件,这件事也还没做。一旦破了,类型检查面对的和执行面对的就不再是同一棵树。

Node 契约的不变量:两条硬约束,一条约定

把”接口必须始终成立的事”单独拎出来,只有三条,分成两类:

契约不变量 守它的机制 谁会发现
每个形态实现它那一组的方法 接口加编译器:Expr 要 Eval+typeCheck,Stmt 要 Exec+typeCheck 编译失败
每个节点都带位置 Pos() 在 Node 里,三个接口都继承 编译失败
节点构造完成后不再修改 值语义挡原地改写;切片替换只能靠用例 无人(测试期有快照用例)

前两条是硬约束:编译器就管着。第二条其实是第一条的特例(Pos 也在 Node 里),单列出来是因为第 2 篇单独论证过它。

第三条只是约定:值接收者挡住原地改写,但”替换切片元素”这条路编译器不拦,只能靠测试兜住——也就是上一节讲的不可变。

词法表、优先级表、两套环境、求值深度这些也都有人管、有人漏,但它们不归这套接口管。完整清单收进附录,给需要查的人。

Node 不管的那一笔:加运算符的缝在表上

第 3 篇算过两种扩展的代价:加读法、加形态。加运算符是第三个方向,而它连这套接口都不碰——要动的六处,没有一处在 Node / Expr / Stmt 上,外加一份测试样本(第 3 篇简略列过一遍,这里展开每一处为什么在那儿):

改动点 谁强制
词法层的 token 类型与名字表 编译期(双向负长度断言)
词法层的符号表(新符号才需要) 测试(符号表全扫用例)
按 token 索引的优先级表(binOps) 测试(元数据用例)
运算符常量与名字表(Op + opNames) 编译期 + 测试
类型检查的分支(applyType) 无(编译器不查 switch 穷尽)
求值的分支(applyBinary) 无

测试那边再补一条样本——漏了跑用例时会直接报”缺少样本”,但实现本身已经能跑。

解析器一行不用改,因为优先级表是数据。所以这一笔不是”接口又要长出什么”,而是”接口之外有人接着”。

和第 3 篇讲的加节点七处对照,两边要动的地方不一样:节点的缝在接口上,漏实现方法会直接编译失败,漏写解析分支则要靠用例发现;运算符的缝在表上,解析器被数据解放了,但类型检查与求值各自还要手写一个分支。

纸面上可以把这两个分支也收进表里:

type opSpec struct {
	prec  int
	check func(l, r Type) (Type, error)
	apply func(l, r Value) (Value, error)
}

新增运算符于是从六处降到五处(token 类型与名字、词法符号、binOps、运算符常量、规格表项),类型检查与求值不再各留一个分支。

代价是规则被编码成函数值,switch 的可读性丢了。而且”先检查、后求值”的顺序要由表驱动的代码统一保证,不能再靠两个方法各自的写法。这仍是取舍,代价只是换了地方。

这里的实现仍然按分支写,因为两侧的分支各自只有几行,读起来比多一层规格表直观。

决策七|加运算符按表 + 双分支,不先建 opSpec 表

  • 拒绝:把 check / apply 收进 opSpec(规则变函数值,switch 的可读性丢了,且”先检查、后求值”要由表驱动统一保证)。
  • 代价:类型检查与求值各一份分支,必须手工同步;漏分支编译器不报,只能靠样本与用例发现;token/Op 名字表、binOps、词法符号表要一起改。
  • 失效:运算符数量或规则复杂度上来(矩阵、字符串比较、隐式转换),或需要统一元数据(结合性、类型签名、文档)时,opSpec 表更划算。

下一层的三条缝:块、函数与错误恢复

上面是现在已经存在、但不归 Node 管的那一笔;下一层真正会改动形态集合的,是下面三条。

只有与形态集合直接相关的三条值得在这里说。其余(环境链、错误恢复的协议细节)属于宿主,等真要做时再开一篇。

  • 语句列表变块:[]Stmt 里已有 ExprStmt 和 Let;加 if / while 就是再加一种 Stmt,用 Result{} 表示”没有值”,块则是 Stmt 的切片——形态集合再长,Exec 的签名不用动。
  • 函数:函数要有参数表和函数体,调用是新的 Expr(有值),定义可能是新的 Stmt;环境要从一张 map 变成链,类型环境跟着变。
  • 错误恢复:协议要能”跳过一条语句继续”,那是 Stmt 之上的一层,现在还没有。Error 已经带类别,将来按 Kind 分流,不必匹配文本。

系列到这里结束。这棵树的形状、坐标、导出边界、调度与契约边界都已经立住;再往下走,每一条缝都可以单独成篇。

附录:不变量与已知缺口清单

下面这些和 Node 契约无关,但同属这套实现,列在这里供查;两张表的最后一栏都是”破坏时谁会发现”。

守住的不变量

不变量 守它的机制 谁会发现
名字表长度与枚举数量一致 三处同款的双向负长度断言(token / op / type) 编译失败
名字表顺序与枚举一致 tokenNames 键控字面量(编译期);opNames / typeNames 靠用例 编译失败 / 测试失败
每个运算符恰好挂在一项优先级上 两条元数据用例:唯一挂载与覆盖检查,加上每个运算符的样本端到端 测试失败
解析嵌套不超过上限 enter / leave 配对(第 4 篇) 带位置报错(正常路径)
求值深度有界 左脊迭代:左链不吃栈,右递归 ≤ maxDepth;一条 100 万项链的回归用例 测试失败

已知缺口(含部分守住)

不变量 现状 谁会发现
两套环境的键一致 REPL 事务保证正常路径成对更新;分叉时 :env 显式打标 分叉可见,无自动发现(部分守住)
整数运算不溢出 无(按 Go 语义静默回绕) 无人

负长度断言的写法值得看一眼:

var _ [int(tokenTypeCount) - len(tokenNames)]struct{}
var _ [len(tokenNames) - int(tokenTypeCount)]struct{}

名字表少一项或多一项,两个数组长度里就有一个变成负数,编译直接失败。两条一起用,缺名字和多名字都挡得住。

它守的只是数量:顺序错了长度仍然相等,顺序得另找机制。这类”用编译期挡住数量、用测试挡住内容”的分工,比只写测试更省反馈时间。

这里的 tokenNames 是与枚举一一对应的名字表,定义为 [...]string 数组,len 才是编译期常量;换成切片,两条断言直接编译不过。

同一套双向断言在 operator.go 的 opNames / opCount 和 typecheck.go 的 typeNames / TypeCount 上各有一份。区别在顺序:tokenNames 用键控字面量在编译期钉死,opNames / typeNames 是非键控字面量,顺序靠用例(S-表达式里的名字、:env 输出)兜底;数量则三张表都由双向断言钉住,少一项或多一项都编不过。

附录:七张决策卡

第 1 篇四张(分派、预检、渲染、接口拆分),第 2 篇一张(位置),第 3 篇一张(导出边界),第 5 篇一张(加运算符)。并排放着,就是整套接口的全部选择:

决策 拒绝的替代 代价 什么时候失效
(第 1 篇)分派放在节点方法上 any 加类型开关;visitor 加读法要给包里每种形态补方法 读法持续增长
(第 1 篇)预检是第二遍完整读树 边求值边检查 规则写两遍;两套环境手工镜像 类型规则复杂到要推导
(第 1 篇)渲染共享 Builder 每节点返回字符串再拼接 渲染不纯;顺序被输出格式钉死 要在渲染里做变换
(第 1 篇)按值有无拆 Expr / Stmt 单接口 + 假值;TypeUnit 假装有类型 多一个接口;typeCheck 两版;SExpr 兼服务 加函数时边界重新模糊
(第 2 篇)位置做成接口方法 字段直读;可选 Positioned 每节点多一个字段和方法;单点答不了区间 要下划线、跨行、LSP
(第 3 篇)未导出方法限制包外扩展 全导出;连 Eval 也收进包级入口 包外不能加读法或形态;嵌入例外 要接格式化、lint、后端
(第 5 篇)加运算符按表 + 双分支 全收进 opSpec 两份分支手工同步;漏分支编译器不报 规则复杂度上来

把七行并排看,代价落在两头:有的落到每个节点(分派、位置、导出边界),有的留在读法这一侧(预检要写两套规则,渲染失去纯函数);接口拆分则两边都沾一点——多一个接口,typeCheck 写两版。没有一行是两全的。

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