树遍历解释器的 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 写两版。没有一行是两全的。

