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

树遍历解释器的 Node 架构(四):顺序与深度——下钻位置与递归上限

系列第 4 篇。

第 1 篇立起三种读法,第 2、3 篇处理坐标与导出边界。这一篇只回答两个问题:方法体里”先写自己还是先取子结果”决定了什么,递归的无界代价压在哪一边。两件事都在节点方法里,不用退到宿主去看。

下钻写在第几行,顺序就是什么

这几种操作的骨架几乎一样,差别只在”先写自己的部分,还是先拿子结果”:

操作 进入节点时 递进方向 回程时
writeTo 写 ( 和算子名 沿左脊下行 补右子树和 )
Eval 什么也不写 先算最左操作数 自内向外逐层应用
typeCheck 什么也不写 先判最左操作数 自内向外逐层合并类型
Exec 什么也不写 一条语句走一遍 把结果装进 Result

两种顺序的代码第 1 篇已经都贴过(渲染的 writeTo 与求值的左脊循环,leftSpine 的定义也在那里);这里不重贴,只对照结果:

writeTo 下行时先写前缀、回程再补右子树和右括号;Eval 和 typeCheck 反过来,先拿最左操作数的结果,再自内向外逐层合成。于是渲染的访问顺序是”父 → 左 → 右”,另外两个是”左 → 右 → 父”。这就是先序与后序的全部来源:决定顺序的不是遍历参数,是方法体里先写什么。

这几种操作没能合并成一个,原因就在这里:渲染要父节点先出,检查与求值要子节点先出,两种顺序在同一条遍历里冲突。硬要合并,就得同时维护”已经写出的前缀”和”还没算出的子结果”,等于把调度写死。

拆成四个方法之后,每个方法只对自己的顺序负责。顺序因此是局部的:改一个方法里的递进位置,只影响这一种操作。

前缀记法把渲染钉在先序上

S-表达式是前缀记法,父节点的算子名必须出现在子节点文本之前。这条输出格式的要求直接决定了渲染只能先写自己的开头、再深入;括号是”进入时写 (、回程时写 )“的收尾约定,本身不携带信息,只是把树形结构标出来。

沿左脊迭代只是把”先写自己”做成”下行一次写完所有前缀”,输出与递归版逐字相同。

换个输出格式,顺序立刻跟着变:

  • 前缀记法里父节点先出,所以渲染只能是先序;
  • 中缀 1+(2*3) 把算子夹在两个子节点之间,顺序变成中序(左 → 父 → 右);
  • 后缀 1 2 3 * + 则是后序(左 → 右 → 父)。

所以渲染的顺序不是设计者的偏好,而是被输出格式逼出来的:顺序跟着产出走。

前缀记法还有个附带后果:S-表达式不需要知道优先级,括号已经把树形编码进去了。渲染的职责只是把树形写出来,不负责还原源码里”先乘后加”的那套约定——那套约定在解析时就消化完了。

子结果决定父结果,后序无可选

类型检查和求值都必须后序,因为父节点的结论建立在子节点之上:不知道两个操作数的类型,就无法判断 + 是否合法;没算出两个操作数,就无法做乘法。

这个顺序还会留下可观察的痕迹。两条输入:

1/0 + 2%0  →  求值失败:第 1 行,第 2 列: 除数为零
1/0 + "a"  →  类型检查失败:第 1 行,第 5 列: + 两侧类型不同:int 和 string

第一条里,右边的 2%0 本来也会除零,但左操作数先求值、先失败,右边根本没执行。第二条更极端:类型检查先于求值跑完整个程序,求值根本没开始。错误谁先出现、出现在哪一层,同样由这套调度决定,不是随机的。

还有一条更彻底的可观察性:&& / || 短路。左边能定结果时右子树根本不会被求值——false && (1/0 == 1) 直接得 false,那个 1/0 一次都没执行。顺序不只决定”谁先失败”,还决定”谁根本不发生”。

但短路只发生在求值:类型检查仍要访问右操作数(它需要右边的类型),所以 false && 1/0 会在预检就报”运算符 AND 需要 bool,得到 int”。这也是”检查与求值是两遍”的一条旁证。

由此得到一个结论:在类型检查与求值这条链路上,类型检查是第一遍完整读树,求值是第二遍。两遍都可能失败,只是时机不同,由此决定了错误处理的分层:层名归宿主、位置归节点(第 2 篇)。

顺序还会决定”哪个错误被看到”。如果把 Eval 改成先算右再算左,1/0 + 2%0 就会报右边的除零;两边都失败时,只有先算的那个会浮出水面。这种可观察性是把调度写进方法顺序的直接代价,也是它值得单独成节的原因。

深度由谁兜底:解析侧设闸,求值沿左脊迭代

顺序之外,递归住在哪是同一件事的另一面。解析器用循环处理同级运算符:优先级表 binOps 是数据,同级的左结合链在循环里往左叠。无界的递归只有三条——括号、一元负号和一元 !;二元右递归也会递归,但受优先级层数封顶,而且照样过 enter / leave。

// 同级运算符待在循环里往左叠,进不了右递归
info := binOps[p.cur.Type]
if !info.isBinary() {
	return left, nil // 不是二元运算符,本层到此结束
}
if info.prec < minPrec {
	return left, nil // 比本层门槛松,留给外层
}
p.advance()
if err := p.enter(opTok); err != nil { return nil, err } // 右递归也过闸
right, err := p.parseExpr(info.prec + 1)                 // 门槛 +1,同级抢不进右递归
p.leave()

所以 1+1+1 这种平坦长链不会在解析阶段加深栈:同级运算符进不了右递归,只能回到本层循环。

求值侧过去是另一回事:Eval 先算左再算右,左深链就是实打实的 n 层递归。现在 Eval、typeCheck、writeTo 都沿左脊迭代,左链长度只影响堆上一个切片,不影响 Go 栈。

“只剩右子树”这句话要补全:Unary 不在左脊上,仍然递归(第 1 篇已经点过这一点)。

两条递归的深度都来自解析期的结构:右递归层数 ≤ 5(binOps 共 5 层:||、&&、比较、加减、乘除取模),括号与一元 ≤ maxDepth。所以解析器那道闸把求值侧一并管住了。

那么闸要装在哪?先把两种”深”分清楚:括号让 AST 变深,同时也让解析递归变深;平坦长链只让 AST 变深,不让解析递归变深。这个区分决定了闸装在哪:

1+1+1+…+1(平坦长链)        ((((1))))(括号)
AST 深度:n                  AST 深度:n
解析递归深度:常数          解析递归深度:n

长链的 AST 一样深,但它的解析递归深度是常数——闸压在后者,不在前者。

判据是一句话:上限要压在递归上,不是压在看起来长的输入上。所以闸只装在这三条无界路径上(括号、一元负号、一元 !):

const maxDepth = 1000

func (p *Parser) enter(tok Token) error {
	if p.depth >= maxDepth {
		return errAt(tok, "表达式嵌套过深(上限 %d 层)", maxDepth)
	}
	p.depth++
	return nil
}

func (p *Parser) leave() { p.depth-- }

先判上限再自增,失败时计数不变。leave 的配对有两处写法:括号和一元运算那几条路径用 defer p.leave()。

parseExpr 里的右递归在循环里,defer 会随循环累积,所以改成显式配对——p.leave() 紧跟在 p.parseExpr(...) 返回之后,正常的错误路径同样会执行到,只有 panic 才会跳过。

边界钉在”恰好 1000 层通过、1001 层带位置报错”,三条无界路径各测一遍。

另有一道 1MB 的扫描器上限只保护 REPL 行缓冲:默认 64KB 会把长行报成 “token too long”,做法是 scanner.Buffer(make([]byte, 0, 64*1024), 1<<20)。它和深度无关,两条上限守的东西不一样:

上限 守什么 按什么定
maxDepth:1000 解析递归深度 上限压在递归上
扫描器缓冲:1MB REPL 行缓冲占用的内存 输入规模

求值侧没有同款的闸,因为它已经不需要:左脊迭代之后,无界递归只剩解析期那三条;右子树的深度既受优先级层数(5 层,见上文)限制,也照样过同一道 enter / leave——将来加右结合运算符(传 prec 而不是 prec + 1)时,链式右递归不会绕过它。

旧实现不是这样:Eval 是纯递归,10 万项链靠 Go 栈自动增长才勉强跑得动,100 万项链在约 81 万层触发 fatal stack overflow(不可 recover)。

现在有一条极端规模的回归用例直接跑 100 万项链——解析、类型检查、SExpr、执行四步都过;另一条钉住 10 万项链那档。

补一个求值深度计数不难,难的是把”深度超限”的报错位置说清楚:错误该指向树里的哪个节点?第 2 篇的单点位置可以复用,但”哪一层算超限”的锚点还要另定。左脊迭代让求值侧不再有无界递归,这份代价今天不必付。

替代方案各有代价。显式栈要把 Env 和 Value 手工压栈,”求值”会变成一台小机器;字节码多一层编译,离树更远;求值侧加深度计数最便宜,但错误位置要另设计。这份实现三者都没选:解析侧设闸,求值侧用迭代把递归消掉。

四种操作各带一个上下文,两个宿主的失败处置也不同,还有一条不变量没有任何机制守着。第 5 篇把这三件事一起收束。

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