函数与调用栈
函数是让语言从「计算器」变成「能抽象、能复用」的关键一跃。可它的实现,拆开看只有三步:调用一个函数 = 新开一个环境 + 把实参绑到形参 + 执行函数体。这一章把这三步写出来,处理一个刁钻的小问题(return 怎么从深处「跳」出来),并亲眼看着调用栈一帧帧堆起来、又一帧帧拆掉 —— 顺便搞懂递归为什么会爆栈。
函数是个值:它记住了自己的声明
在 Lox 里函数是头等值,所以求值器要有个东西来表示一个函数。它很简单,就是把函数的声明(参数是谁、函数体是什么)包起来:
class LoxFunction {
constructor(declaration) {
this.declaration = declaration; // 记住:参数列表 + 函数体
}
arity() { return this.declaration.params.length; } // 要几个参数
}
// 遇到 fun 声明,就造一个这样的值,塞进环境
case "fun":
environment.define(stmt.name.lexeme, new LoxFunction(stmt));
return;
于是 fun add(a, b) { ... } 之后,环境里的 add 这个名字,绑着一个 LoxFunction 值。函数名和普通变量名住在同一张环境表里 —— 这正是「函数是头等值」在实现上的含义。
调用:新环境 + 绑参数 + 跑函数体
现在有人写了 add(3, 4)。求值器碰到这个「调用」节点,做三件事:
function callFunction(fn, args) {
// ① 新开一个环境(函数自己的小天地)
const env = new Environment(fn.declaration.closure);
// ② 把实参一一绑到形参
for (let i = 0; i < fn.declaration.params.length; i++) {
env.define(fn.declaration.params[i].lexeme, args[i]);
}
// ③ 在这个新环境里执行函数体
executeBlock(fn.declaration.body, env);
}
盯住第 ① 步,它是整个函数机制的心脏:每次调用,都开一个全新的环境。这就是为什么两次调用 add 互不干扰 —— 它们各有各的环境,各有各的 a 和 b。也是为什么递归能成立:fact(3) 调 fact(2),两次调用的 n 在两个不同的环境里,一个是 3、一个是 2,谁也不碍着谁。(第 ① 步里那个 closure 先卖个关子,它是下一章的主角。)
刁钻的一步:return 怎么「跳」出来
有个问题不那么显然。return 可能出现在函数体很深的地方 —— 嵌在好几层 if 和 while 里面。它一执行,必须立刻把整个函数体的执行全部中断,带着返回值一路跳回调用点。可我们的 exec 是层层递归下去的,怎么从深处一步跳回来?
一个漂亮的招:用宿主语言的异常机制来「跳」。把返回值包成一个特殊的异常抛出去,在函数调用那一层接住它:
class ReturnSignal { constructor(value) { this.value = value; } }
case "return":
throw new ReturnSignal(stmt.value ? eval(stmt.value) : null); // 抛!
// 在调用函数的地方接住它
function callFunction(fn, args) {
// …… 开环境、绑参数 ……
try {
executeBlock(fn.declaration.body, env);
} catch (e) {
if (e instanceof ReturnSignal) return e.value; // 接住 return,把值交出去
throw e; // 别的异常,继续往上抛
}
return null; // 函数体走完也没 return,返回 nil
}
异常本来就是「从任意深处一步跳到某个接盘点」的机制 —— 它天生就是干这个的。用它来实现 return,不管 return 埋在多少层循环和判断里面,都能一步精准地跳回函数调用处。这是「借宿主语言的机制实现目标语言的特性」的一个经典小聪明。
当 fact(3) 调 fact(2) 调 fact(1),此刻有三次调用同时悬在半空:fact(3) 卡在 3 * fact(2) 等着,fact(2) 卡在 2 * fact(1) 等着,fact(1) 正在算。这一叠「开了头、还没算完、正等着里层结果」的调用,就是调用栈。
在我们的树遍历求值器里,这个栈不用自己维护 —— 它就是宿主语言(JS)自己的函数调用栈,因为 Lox 的一次函数调用,正是通过 callFunction 的一次 JS 递归调用实现的。Lox 的调用栈,寄生在 JS 的调用栈上。
看栈堆起来、拆掉
下面这台跑一个真的递归程序,让你一步步看调用栈的起落。选「阶乘 fact(4)」,按「下一步」:每调用一次压一帧、每 return 一次弹一帧:
看 fact(4):栈先一路加深到 fact(1)(四帧叠着),然后 fact(1) 返回 1,栈开始一帧帧弹回来,每弹一帧就把那个乘法补完(2 * 1、3 * 2、4 * 6),最后得 24。再选 fib(5),看它因为要调用两次自己而不断地深了又浅、浅了又深 —— 那个反复起伏的形状,就是「分治递归」在栈上的样子。
注意 Demo 里那个「峰值深度」。它就是「最多有多少次调用同时悬着没算完」。递归太深(比如忘了写终止条件,或者算 fib(50) 这种指数级展开),这个深度会失控地涨 —— 涨到超过栈能容纳的上限,就是那个人人都见过的报错:stack overflow(栈溢出)。爆的,正是这个栈。
回流
你调试时盯着的每一份堆栈跟踪(stack trace),画的就是这一章的调用栈本人。
异常堆栈:Kotlin / Java 抛异常时打印的那一长串 at com.foo.Bar.baz(...),从上到下就是调用栈从栈顶到栈底的快照 —— 最上面是出事的那次调用,往下是「谁调了它、谁又调了那个」。看懂它的层次,就是在读这一章那叠悬着的调用。
StackOverflowError:你写过忘了终止条件的递归、或者两个函数互相无限调用吗?那个 StackOverflowError,就是调用栈的深度爆了 —— 和 Demo 里「峰值深度」失控是同一回事。为什么循环不会爆栈、而等价的递归会?因为循环在原地打转、栈深度不变,递归却每深一层就多压一帧。这也引出了一个优化叫尾调用优化(把某些递归复用同一帧、不再加深)—— 如果你读过《求值》里讲尾调用那章,会在这里认出老朋友。
debugger 里的 Call Stack 面板、性能分析器里的火焰图(flame graph,本质是调用栈随时间的堆叠)—— 你日常依赖的这些工具,展示的都是这一章造出来的东西。
这一章的一句话
函数调用 = 新开环境 + 绑参数 + 跑函数体;return 借异常从深处一跳而出;而一叠「还没算完的调用」就是调用栈 —— 递归爆的就是它。
只剩最后一级台阶了。下一章,我们回到那个卖了两次关子的 closure —— 造出闭包,你的语言就真的活了。这是整卷的高潮。