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

树遍历解释器的 Node 架构(三):未导出方法与两种扩展——穷尽性由谁保证

系列第 3 篇。

第 1 篇立起三种读法,并在”表达式与语句”一节把接口拆成 Node / Expr / Stmt 三层;第 2 篇补上位置。现在六个方法都在了,但还有一个决定没做:这套接口开不开放。这一篇只回答这个问题:不导出那几个方法之后,两个方向的扩展各要改什么;以及五种主流设计各自怎么处理穷尽性。

未导出方法:把三种读法留在包内

接口到这里是完整的,但还有一个设计问题没定:它开不开放。如果 writeTo 和两种 typeCheck 都导出,任何包外类型都可以实现这些接口,然后混进树里:它可能返回一个类型检查不认识的值,可能把位置报成别处,也可能渲染出包内读法不认识的形状。包内没有人能验证这些承诺。

先说一个前提:当前的实现是 package main,十几个文件同在一个包里,根本没有”包外”(ast.go 的注释就写着”本包是 main,外部本来就 import 不到”)。

所以这套未导出方法在今天不是编译器强制的边界,而是抽包之前要守的纪律:代价今天已经存在,好处要等真有外部包 import 之后才兑现。要在今天验证它只有一条路——把三个接口挪进独立包,再加一条”外部类型不能实现它们”的编译期用例。

所以六个方法里留了三个不导出:Node.writeTo、Expr.typeCheck、Stmt.typeCheck。只要有一个方法不导出,包外就实现不了这套接口;留三个,是因为三种读法(渲染、预检、语句检查)只服务包内流程,各自由一个包级入口调用。

CheckProgram 负责建 TypeEnv 并按顺序跑,SExpr 负责创建 Builder 并取字符串。

就这套契约而言,导出给外部的只有 Pos、Eval、Exec 三个方法,以及检查和渲染的两个入口。六个方法摊开看:

接口 方法 导出?
Node Pos / writeTo 导出 / 不导出
Expr Eval / typeCheck 导出 / 不导出
Stmt Exec / typeCheck 导出 / 不导出

补不上不导出的那三个方法,就实现不了这套接口:包外能读、能调,但造不出能进树的新形态。

判断一个类型是否满足某个接口,看的是方法的完整身份:包名加方法名,不是拼写。包外的类型可以写出一个拼写相同的 writeTo 或 typeCheck,但那是另一个包里的另一个方法,不满足这里的同名方法。于是包外没法从零造一个节点形态,也造不出一个能进树的语句。

包外唯一的例外是嵌入:嵌入一个已导出的节点或节点接口,外面包一层。嵌入不增加新形态(writeTo / typeCheck 仍走被嵌入的实现),但可以重写导出的 Eval / Exec / Pos,所以未导出方法只挡住新形态,挡不住这三个导出方法被替换。

前提是嵌入值非 nil,嵌入一个 nil 接口值后调用会 panic。

所以未导出方法限制住的是形态集合和三种包内读法。可替换的范围还剩 Eval、Exec、Pos 和字段读取;若连求值和执行也不让包外替换,就得把它们收进包级入口。

把方法收进包内,换来两件事。一是形态集合由包内定义,三种读法与语句执行面对的就是这门语言的全部形态。二是接口也能演进:加新读法(或新的执行方式)时只需改包内有限几个类型,编译器逐一检查。

代价是扩展性少了一半。包外可读不可扩:字段可以读,Eval / Exec / Pos 可以调,检查和渲染的入口也可以调,甚至能自己写类型开关做遍历,但补不上小写方法,就加不了新读法或新形态。字段导出让读方便,改写却只落在副本上(机制见第 5 篇)。

要接 lint、格式化插件、多种后端,就得把读法从节点移到包外,用 visitor 或函数表重组。那是另一种编码,成本落点也不同。

决策六|用未导出方法让形态集合只在包内扩展

  • 拒绝:六个方法全导出(形态集合向包外开放,包内读法的假设没人验证);也拒绝连 Eval / Exec 一起收进包级入口(限制更彻底,但宿主失去逐节点求值)。
  • 代价:包外不能加读法或形态;嵌入例外挡不住重写 Eval / Exec / Pos;收益要等抽包才兑现。
  • 失效:要接格式化、lint、多后端时,正确动作是导出那几个方法、把读法移到包外做 visitor。那时未导出方法从保护变成障碍。

两种扩展:加读法与加形态

Node 是一个封闭的形态集合:一个接口加每种形态一个 struct。

实现里一共八种节点——六种表达式(NumberLiteral / StringLiteral / BoolLiteral / Identifier / Binary / Unary)和两种语句(ExprStmt / Let),清单第 1 篇已经列过。

把方法收进包内之后,两个方向上的扩展都只能由包内发起,但编译器对两者的强制程度不同:加读法完全强制;加形态只在”方法齐了才装得进树”这一半强制,解析器入口不强制。

加一种形态要动七处:struct 定义、Pos、writeTo、Eval 或 Exec、typeCheck、解析器里构造它的分支、编译期钉桩(var _ Expr = ... / var _ Stmt = ...)。

这里只数实现里的地方,测试另算(求值与类型检查各要补样本)。

其中六处编译器管得住:少写 Pos / writeTo / Eval(或 Exec)/ typeCheck 里的任何一个,新类型都装不进那三个接口;钉桩管”归属”,写错组(比如把语句塞进 Binary.Left)也立刻编不过。

只有解析器里构造它的分支编译器看不见——结构体加好了、方法也齐了,只是解析器永远造不出它,整个程序照样编译通过。

变更 编译器是否强制
新增一种读法 强制:接口变长,包内该组的形态编译失败直到补齐
新增一种形态 强制:所属接口的方法齐了才能装进树里
形态归错组 强制:var _ Expr / var _ Stmt 钉桩直接编不过
解析器能造出新形态 不强制:漏写解析入口,编译照样通过
读法在语义上正确 不强制:接口只保证”有方法”,不保证”方法对”

这里还能看出来一件事:加读法的代价现在分接口。给 Expr 加一种读法,要改六种表达式;给 Stmt 加一种执行方式,只改两种语句(将来加块、if、while 也还是语句这一组)。

同一次”加读法”,改动量取决于它落在哪个接口上——这正是新结构与”一个 Node 挂四个方法”的不同。

所以这套设计真正保证的是”没有漏掉的读法”,而不是”没有漏掉的形态”:前者由编译器强制,后者由用例兜底——解析器那一处漏了,编译器不会说话。

加运算符是第三个方向。优先级表是数据,解析器的分派代码不用改;但词法、运算符常量与名字表仍要加条目,类型检查与求值各要手写分支,测试样本也要补一份。加形态和加读法是两种不同的改动,第 5 篇会展开。

对照:五种上游方案各自怎么保证穷尽性

第 1 篇开头立了两个问题:分派放在哪,穷尽性由谁保证。这一篇回答的是第二个;第一个在节点方法上已经答过。

先把比较单位说清楚:下面比的是形态怎么表示、分派放在哪、穷尽性由谁保证,不是”解释器对编译器”。

样本故意跨类型:两个解释器/VM(jlox、clox)、一份 AST 规范加转换工具链(ESTree / Babel)、两个编译器前端(TypeScript、syn),再加本实现。它们各自在自己的流程里回答同一个问题,于是排出了下面这张表:

设计 形态表示 分派 穷尽性靠什么
Lox(jlox) 类层次 visitor 双分派 每个 visitor 实现全部方法,编译期检查
Lox(clox) 没有 AST:边解析边生成字节码 单遍编译,VM 按 opcode 分派 不适用:没有形态集合
ESTree / Babel 普通对象加 type 字符串 外部遍历器加插件 无编译期检查,靠工具校验
TypeScript kind 判别联合 中心 forEachChild 判别联合提供窄化,never 断言兜底
Rust syn 原生 enum match 编译器检查穷尽
本实现 三个接口(Node/Expr/Stmt)加每种形态一个 struct 节点方法 接口:漏方法编译失败

表里最值得对比的是两个极端。clox 干脆不留树:边解析边生成字节码,没有形态集合,穷尽性问题根本不存在。

但它也没有”树”可再读——整程序预检、多遍读都无从谈起。它是把前提证伪掉,不是同一维度上的方案。

本实现走的是另一头:把每一步都摊到节点方法上,换来编译器逐项检查。中间几行则是第三条路——让一个中心函数或一次 match 承担分派。

syn 与 Go 的差别。 Rust 的 syn 用原生 enum 加 match,穷尽性由一次 match 集中保证。Go 没有原生 sum type,只能把每个形态的读法拆成方法:加形态要给所属接口的那组方法补实现,加读法也要给每个形态补实现。两种扩展都分散在节点上,编译器逐项检查。Go 自己的 go/ast 走的是外部遍历:Node 只有 Pos / End,遍历靠 ast.Walk / ast.Inspect,也没有编译期穷尽;本实现把读法放回节点,是在同一个生态里做了另一种选择。

选型的判据。 三条:

  • 读法少而固定:节点方法加封闭接口,样板最低。
  • 预期持续加读法(诊断、格式化、多后端):visitor 或函数表更划算。
  • 语法形态会频繁增长:中心 switch 或原生 enum 更省事。

第 1 篇开头摆的前提(读法固定、语法封闭、只有本包的两个宿主)在这里仍然成立。需求换成格式化、lint、IDE 时,正确的动作是加一层 CST 或 visitor,而不是把所有读法都塞回节点。

Lox 的 visitor 版本要靠脚本生成 AST 类和 visitor 接口,这本身就说明样板成本确实存在。本实现用每个节点上的方法换掉生成器,代价是”加读法”的成本摊到每个节点。

未导出方法只决定”谁能扩展”,它没有保证读的顺序、上下文和失败处理。下一篇回到那四种操作的方法体:先序与后序怎么来的,递归住在哪一边。

参考:

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