闭包:函数记得它出生的地方
这是卷 IV 的高潮,也是整本书情绪上的顶点。第 2 章那个「外层函数早已结束,内层函数却还记得、还能改外层变量」的 makeCounter,一直悬着没解释。现在揭晓:让它成真,只需要一行改动 —— 让函数值记住自己出生时所在的那个环境。这一行,就是闭包。造完它,你的语言就真的活了。
先重温那个谜
第 2 章的计数器,再看一眼,这次带着实现者的眼光:
fun makeCounter() {
var count = 0;
fun tick() {
count = count + 1;
return count;
}
return tick; // 把 tick 返回出去
}
var c = makeCounter(); // makeCounter 执行完、结束了
print c(); // 1
print c(); // 2 ← count 居然被记住了、还累加了
print c(); // 3
谜在哪?count 是 makeCounter 的局部变量。按第 12 章的规矩,makeCounter 一执行完,它那层环境就该被丢弃、count 就该消失。可 tick 被返回出来后,一次次调用居然还能读到、还能改那个 count,而且数字在累加 —— 那个本该消失的环境,明明还活着。为什么?
答案:函数出生时,记住了它周围的环境
关键的一句话:一个函数被声明的那一刻,它就记住了「我是在哪个环境里出生的」。之后无论它被带到多远、被调用多少次,它执行时都以那个「出生环境」为外层 —— 而不是「被调用时」所在的环境。
这需要的改动,小到几乎让人不敢相信。第 14 章的 LoxFunction,只要在出生时把当时的环境存下来:
class LoxFunction {
constructor(declaration, closure) {
this.declaration = declaration;
this.closure = closure; // ← 就是这一行:记住出生时的环境
}
}
// 声明函数时,把「此刻的环境」交给它
case "fun":
environment.define(stmt.name.lexeme,
new LoxFunction(stmt, this.environment)); // ← 把当前环境传进去
return;
然后,调用函数时,它的新环境挂在这个 closure 下面,而不是挂在「调用点的环境」下面 —— 这正是第 14 章卖关子的那个 new Environment(fn.closure):
function callFunction(fn, args) {
const env = new Environment(fn.closure); // ← 外层是「出生环境」,不是「调用环境」
// …… 绑参数、跑函数体 ……
}
这两处加起来不到三行,就是全部。现在回头解那个谜:tick 出生在 makeCounter 的环境里,于是它记住了那个环境(里面有 count)。makeCounter 虽然返回了,但因为 tick 还攥着那个环境的引用,它就不会被丢弃(垃圾回收器看到还有人引用它,就不敢回收)。每次调 c(),tick 都回到那个还活着的环境里去读写 count —— 所以数字累加,所以谜底揭开。
把这个公式刻下来:闭包(closure),就是一个函数,加上它被定义时所在的那个环境。「闭」的意思是它把周围的变量「包」了进来、封了口。
为什么叫「词法作用域」(lexical scoping)?因为一个变量指向谁,是由它在源码里的位置(词法上写在哪个函数里面)决定的,而不是由「运行时从哪里被调用」决定的。tick 里的 count,永远指 makeCounter 里那个(它写在那儿),无论 tick 被带到天涯海角去调用。函数记住的是它出生的地方,不是它被使唤的地方 —— 这句话,就是闭包和词法作用域的全部。
你的语言活了 —— 现在就跑给你看
到这里,卷 II 到卷 IV 造的东西全部合龙了:扫描器、解析器、环境、函数、闭包 —— 一台完整、能跑的解释器。下面这个 REPL,就是它本人。第一个例子就是那个计数器,按运行,看它真的打出 1 2 3:
停下来,认真感受一下这一刻。你上面读到的每一段实现 —— 那个 eval、那条环境链、那个 closure 字段 —— 此刻正在这个文本框底下真实地运转。你可以清空它,写一段你自己的 Lox:递归、循环、闭包,随便折腾。这不是模拟,不是动画。这是一门语言,你跟着这本书,一章一章亲手造出来的,现在它在你的浏览器里活着、听你的话。这个瞬间,就是这本书想送给你的东西。
我们这版闭包,已经能跑对绝大多数程序。但 CI 里 Bob Nystrom 指出:朴素的「每次执行都顺着环境链现查变量」,在某些边缘情形(同一作用域里先用一个变量、再重新声明它)下会有微妙的错。他的解法是加一趟变量解析(resolver):在运行之前先走一遍树,把每个变量「该去链上第几层找」提前钉死。
这本书为了让你尽快尝到「跑起来」的甜头,略过了这一趟 —— 我们的实现对你写的正常程序都成立,只是没堵上那个学术性的边角。这恰恰是你该去读 CI 的一个具体理由:它会把这个坑、以及为什么要用 resolver 堵它,讲得干干净净。你带着「我已经造过一版」的手感去读那一章,会格外有共鸣。
回流
你在 JS / Kotlin 里用了无数次的闭包,底层就是这一章的 closure 字段。回调、事件处理器、map 里的 lambda、useState 的 setter —— 它们能「记住」外面的变量,全靠出生时捕获了环境。
那个经典的 JS 循环坑,现在你能从实现层面解释了:
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); —— 打印三个 3,不是 0 1 2。为什么?因为老 var 只有一个环境(函数级),三个闭包捕获的是同一个 i 的格子;等回调真正执行时,循环早跑完了,那个格子里是 3。换成 let 就对了 —— 因为 let 让每轮循环各叠一层新环境(正是第 12 章那个「进块叠一层」),三个闭包于是捕获了三个不同的格子。「捕获的是变量的盒子,不是当时的值」—— 这句让无数人恍然大悟的话,你现在是从造过它的角度懂的。
如果你读过《求值》:那里我们说闭包捕获的是「盒子」而非「值」,还说 async/await 的祖先叫续延。这本书,让你把那个「盒子」亲手造了出来 —— 它就是环境里的一个键值对,而闭包攥着的,是整张表的引用。
这一章的一句话
闭包 = 函数 + 它出生时的环境;实现只要让函数值记住「我在哪儿被声明」,那个本该消失的环境就因被引用而留存 —— 函数记得的,永远是它出生的地方。
卷 IV 完结。你已经有了一台完整、能跑、有闭包的解释器 —— 这本书最硬的骨头,你啃完了。下一卷是加餐:树遍历虽然能跑,但慢。我们尝一口真语言的做法 —— 把树编译成字节码,喂给一台虚拟机。