卷 IV · 义CH 15深度 15/20

闭包:函数记得它出生的地方

这是卷 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

谜在哪?countmakeCounter 的局部变量。按第 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 完结。你已经有了一台完整、能跑、有闭包的解释器 —— 这本书最硬的骨头,你啃完了。下一卷是加餐:树遍历虽然能跑,但慢。我们尝一口真语言的做法 —— 把树编译成字节码,喂给一台虚拟机。