卷 II · 求值CH 05深度 05/25

环境:闭包到底捕获了什么

「闭包会捕获外部变量」—— 这句话你说过一百遍,也用它解释过一百个 bug。但它捕获的到底是,还是变量本身这个问题听起来像抠字眼,可它恰好是 JS 那个著名的 for-var 坑、Java 那条奇怪的 effectively final 规则、以及 Kotlin 字节码里那个凭空冒出来的 IntRef 类的共同答案。这一章我们把「环境」这个东西画出来 —— 然后这三件事会同时变成常识。

环境链闭包词法作用域effectively final

先把「环境」定义清楚

求值一个表达式,光有表达式是不够的。(+ x 1) 算不出结果 —— 除非你告诉我 x 是多少。

所以求值永远需要两个东西:一个表达式,和一个「上下文」。这个上下文有个名字:

◆ 环境(environment)

环境 = 一串「帧」(frame),每个帧是一张「名字 → 值」的表,每个帧有一个指向父帧的指针。

查一个变量时:从当前帧开始找,找不到就顺着父指针往上一帧找,一直找到最外层。还找不到 —— 报错「unbound identifier」。

就这么简单。整个卷 II 的一半内容,都是这三句话的推论。

三条规则,对应三个动作:

你写环境上发生什么
(define x 10)在当前帧加一条绑定:x → 10
x(求值它)顺着链往上找x 的绑定,返回它的值
(f 1 2)新建一个帧,把参数绑进去,然后在这个新帧里求值 f 的函数体

关键问题来了 —— 那个新建的帧,它的父指针指向谁?

这个问题的答案,决定了一门语言的一切。

那个新帧的父亲是谁

有两个候选答案:

候选 A:指向调用者所在的环境。(「谁调用我,我就接在谁后面」)

候选 B:指向函数被定义时所在的环境。(「我在哪儿出生,我就接在哪儿」)

看这段代码,它能把两个答案区分开:

(define x 10)

(define (f) x)          ; f 里的 x 指谁?

(define (g)
  (define x 20)         ; g 里也有一个 x
  (f))                  ; 在这里调用 f

(g)                     ; ⇒ ?

如果是候选 A(调用者的环境):fg 调用,所以 f 的父环境是 g 的帧,那里有 x = 20答案是 20

如果是候选 B(定义处的环境):f 是在顶层定义的,所以它的父环境是顶层,那里有 x = 10答案是 10

Racket 的答案是 10。你的语言的答案也是 10。所有现代语言的答案都是 10。

这个选择叫 词法作用域(lexical scoping,也叫静态作用域):一个变量指谁,只取决于代码写在哪里,跟它被谁调用没有半点关系。

✎ 掌故 · 1975 年,Lisp 换了心脏

候选 A 不是我瞎编的。早期的 Lisp(包括 1958 年 McCarthy 的原版)用的就是候选 A —— 动态作用域。而且不是有意为之,是实现 eval 时的一个 bug,只是当时没人觉得它是 bug。

动态作用域听起来很灵活(「调用者可以定制被调用者看到的变量!」),实际是一场灾难:任何调用者都能悄悄改变你函数里变量的含义。你写的 (define (area r) (* pi r r)),被人在一个把 pi 定义成 3 的上下文里调用了 —— 你的函数就错了,而且你无从防范。函数不再是一个可以独立推理的东西。

1975 年,Gerald Sussman 和 Guy Steele 造了 Scheme,第一次把词法作用域带进 Lisp。这是 Lisp 历史上最重要的一次转向 —— 而且它的直接后果是:闭包这个东西第一次成为可能。(没有词法作用域,就没有「函数记住它出生的环境」这回事。)

今天你写的每一门语言,用的都是他们的选择。

闭包:一个「代码 + 出生地」的结构体

现在我们能给闭包一个精确的定义了。

当 Racket 求值一个 lambda 时,它做的事只有一件:

◆ 闭包 = 代码 + 一根指向出生环境的指针
; 求值 (lambda (x) (+ x n)) 时,Racket 造出一个这样的东西:
(struct closure (params body env))
;                 ↑      ↑    ↑
;               参数表  函数体  「我出生时所在的那个环境」

就这三个字段。「闭包」这个听起来很玄的词,指的就是这个结构体。

而当你调用这个闭包时:新建一个帧装参数,这个新帧的父指针 = 闭包里存的那个 env不是调用者的环境)。

「捕获」这个词具有误导性 —— 它听起来像是把变量复制了一份。没有复制。它只是记了一个地址

下面这台机器把这个过程画出来了。三个例子,一步一步走。

回到那个计数器

你在 demo 里看到的第一个例子,值得再嚼一遍:

(define (make-counter)
  (define n 0)
  (lambda ()
    (set! n (+ n 1))
    n))

(define c1 (make-counter))
(define c2 (make-counter))

(c1)   ; ⇒ 1
(c1)   ; ⇒ 2
(c2)   ; ⇒ 1   ← c2 有自己的 n

三个问题,你现在应该能秒答:

Q:make-counter 已经返回了,它的帧为什么还活着?
A:因为那个 lambda 记着它的地址。只要闭包还活着,它记的环境就不能回收。那个帧已经不在栈上了 —— 它在堆上。(这就是为什么闭包会「延长变量的生命周期」,也是为什么在 Android 里 lambda 捕获 Activity 会内存泄漏 —— 同一个机制。

Q:c1c2n 为什么互不干扰?
A:因为 每次调用 make-counter 都新建一个帧。两次调用 = 两个帧 = 两个 n帧是按「调用」创建的,不是按「函数」创建的。

Q:这个 n 外面能访问到吗?
A:不能。没有任何办法从外面拿到它 —— 它只存在于那个闭包记着的帧里。

而这最后一点,意味着你刚刚用闭包做出了「私有字段」。没有 class,没有 private 关键字。这就是 JS 里 module pattern 的全部内容,也是「闭包和对象是等价的」这句话的意思 —— 它们都是「一堆行为 + 一堆只有这些行为能看到的状态」。

现在,那个 JS 坑自己解释自己

第 1 章题 1:

const fns = [];
for (var i = 0; i < 3; i++) fns.push(() => i);
fns.map(f => f());   // [3, 3, 3]

用环境链来说,一句话就讲完了:

var 声明的变量,作用域是整个函数。所以整个循环只有一个帧、一个 i 的盒子。三个闭包记的是同一个地址。循环结束时那个盒子里躺着 3。于是三个闭包一起看到 3 —— 它们看的是同一个盒子。

换成 let

for (let i = 0; i < 3; i++) fns.push(() => i);
fns.map(f => f());   // [0, 1, 2]

letfor 循环里有一条特殊规则:每一轮迭代都新建一个绑定(规范里真的这么写的,V8 会为每轮迭代创建一个新的环境记录)。三轮 = 三个帧 = 三个盒子。每个闭包记着自己那个。

◆ 一句话记住它

闭包捕获的是盒子,不是letvar 的区别不是「捕获方式」,是有几个盒子

下次再有人跟你说「let 捕获值、var 捕获引用」—— 那是错的,而且这个错误的模型会在别的地方把他坑了。

Java 的 effectively final:一条奇怪规则的来历

Java 里,lambda 捕获的局部变量必须是 final 或 effectively final(即赋值一次之后不再改):

// Java
int count = 0;
list.forEach(x -> count++);   // ✗ 编译错误:
                              //   local variables referenced from a lambda
                              //   must be final or effectively final

为什么?因为 Java 的 lambda 捕获的是值的拷贝,不是盒子。

JVM 上,局部变量在栈帧里。方法返回后栈帧就没了。而 lambda 可能活得比方法更久(存进列表里、传给别的线程)。所以 Java 只能把变量的值复制进 lambda 对象。

而一旦是拷贝,「修改」就变得没有意义了 —— 你改的是拷贝还是原件?两边会不同步。Java 的选择是:干脆禁止修改,用编译错误把这个歧义堵死。

这是一个诚实但保守的设计:它没有实现真正的闭包,只实现了「值捕获」,然后用一条规则把差异藏起来。

Kotlin 的答案:它真的给你造了一个盒子

Kotlin 允许你捕获并修改 var

var count = 0
list.forEach { count++ }    // ✓ Kotlin 里这是合法的
println(count)              // 真的变了

Kotlin 是怎么绕过 JVM 那个限制的?把它反编译(IntelliJ:Tools → Kotlin → Show Kotlin Bytecode → Decompile):

// 反编译后(示意)
Ref.IntRef count = new Ref.IntRef();   // ← 一个对象!
count.element = 0;

list.forEach(x -> { count.element++; });   // 捕获的是这个对象的引用

System.out.println(count.element);
◆ Kotlin 编译器真的给你造了一个盒子

Ref.IntRefRef.ObjectRef —— 这些类的存在,唯一目的就是把一个栈上的变量搬到堆上,变成一个可以被共享的盒子

这个「盒子」不是比喻。它在字节码里是一个真的对象,有一个真的字段 element

换句话说:Kotlin 编译器手工实现了 Racket 天生就有的东西。Racket 的环境帧本来就在堆上,所以它什么都不用做。Kotlin 要在一个不支持这件事的 VM 上模拟它,于是它偷偷装箱。

顺便,这解释了一个性能细节:在热点循环里捕获并修改 var,你付的代价是一次堆分配 + 一次间接寻址。大多数时候无所谓,但如果你在做逐帧渲染或者高频回调,这值得知道。

三件事,一个答案

现象它们各自的机制同一个原因
JS:var 的三个闭包看到同一个值整个函数一个帧,一个盒子闭包捕获的是盒子(环境帧),不是值。
问题永远归结为:有几个盒子?盒子在哪儿?
Java:必须 effectively final不给盒子,只拷贝值 —— 所以禁止修改
Kotlin:可以捕获并修改 var编译器悄悄造一个 Ref 盒子放堆上

三门语言,三种设计,一个底层问题。你现在不需要分别记三条规则了 —— 你只需要在脑子里画一张环境链图,答案会自己掉出来。

回流

⟲ 回流 · 一个能立刻用上的调试习惯

下次遇到「这个变量的值怎么不对」类的诡异 bug,别急着加断点。先在纸上(或脑子里)画环境链:

  1. 这个变量的盒子在哪个帧里?(它是在哪一行被声明的?)
  2. 有几个盒子?(这段代码被执行了几次?每次是新建了帧,还是复用了同一个?)
  3. 谁记着这个盒子?(有几个闭包持有它的地址?它们会在什么时候被调用?)

这三个问题能解释掉的 bug 类型包括但不限于:

  • 循环里创建的回调全都用了最后一个值(JS、Python、Go 1.21 之前的 for 循环变量 —— Go 1.22 专门改了这个,就是因为它坑了太多人
  • Android:Activity 泄漏(一个长生命周期的对象记着一个短生命周期的帧)
  • Compose:remember 里捕获了旧的 state(重组时闭包记的还是上一帧的盒子 —— 这就是 rememberUpdatedState 存在的全部理由
  • 协程:launch { } 里读到的变量值和你以为的不一样

它们全都是同一道题。

下一章

这一章我们一直在说「lambda 求值出一个闭包」,但一直没正面回答一个更基础的问题:

函数凭什么可以是一个「值」?

你可以把它塞进列表、传给别的函数、从函数里返回它 —— 这件事你天天在做,但你想过它凭什么成立吗?

下一章,λ。