环境:闭包到底捕获了什么
「闭包会捕获外部变量」—— 这句话你说过一百遍,也用它解释过一百个 bug。但它捕获的到底是值,还是变量本身?这个问题听起来像抠字眼,可它恰好是 JS 那个著名的 for-var 坑、Java 那条奇怪的 effectively final 规则、以及 Kotlin 字节码里那个凭空冒出来的 IntRef 类的共同答案。这一章我们把「环境」这个东西画出来 —— 然后这三件事会同时变成常识。
先把「环境」定义清楚
求值一个表达式,光有表达式是不够的。(+ x 1) 算不出结果 —— 除非你告诉我 x 是多少。
所以求值永远需要两个东西:一个表达式,和一个「上下文」。这个上下文有个名字:
环境 = 一串「帧」(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(调用者的环境):f 被 g 调用,所以 f 的父环境是 g 的帧,那里有 x = 20 → 答案是 20。
如果是候选 B(定义处的环境):f 是在顶层定义的,所以它的父环境是顶层,那里有 x = 10 → 答案是 10。
Racket 的答案是 10。你的语言的答案也是 10。所有现代语言的答案都是 10。
这个选择叫 词法作用域(lexical scoping,也叫静态作用域):一个变量指谁,只取决于代码写在哪里,跟它被谁调用没有半点关系。
候选 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:c1 和 c2 的 n 为什么互不干扰?
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]
let 在 for 循环里有一条特殊规则:每一轮迭代都新建一个绑定(规范里真的这么写的,V8 会为每轮迭代创建一个新的环境记录)。三轮 = 三个帧 = 三个盒子。每个闭包记着自己那个。
闭包捕获的是盒子,不是值。let 和 var 的区别不是「捕获方式」,是有几个盒子。
下次再有人跟你说「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);
Ref.IntRef、Ref.ObjectRef —— 这些类的存在,唯一目的就是把一个栈上的变量搬到堆上,变成一个可以被共享的盒子。
这个「盒子」不是比喻。它在字节码里是一个真的对象,有一个真的字段 element。
换句话说:Kotlin 编译器手工实现了 Racket 天生就有的东西。Racket 的环境帧本来就在堆上,所以它什么都不用做。Kotlin 要在一个不支持这件事的 VM 上模拟它,于是它偷偷装箱。
顺便,这解释了一个性能细节:在热点循环里捕获并修改 var,你付的代价是一次堆分配 + 一次间接寻址。大多数时候无所谓,但如果你在做逐帧渲染或者高频回调,这值得知道。
三件事,一个答案
| 现象 | 它们各自的机制 | 同一个原因 |
|---|---|---|
JS:var 的三个闭包看到同一个值 | 整个函数一个帧,一个盒子 | 闭包捕获的是盒子(环境帧),不是值。 问题永远归结为:有几个盒子?盒子在哪儿? |
| Java:必须 effectively final | 不给盒子,只拷贝值 —— 所以禁止修改 | |
Kotlin:可以捕获并修改 var | 编译器悄悄造一个 Ref 盒子放堆上 |
三门语言,三种设计,一个底层问题。你现在不需要分别记三条规则了 —— 你只需要在脑子里画一张环境链图,答案会自己掉出来。
回流
下次遇到「这个变量的值怎么不对」类的诡异 bug,别急着加断点。先在纸上(或脑子里)画环境链:
- 这个变量的盒子在哪个帧里?(它是在哪一行被声明的?)
- 有几个盒子?(这段代码被执行了几次?每次是新建了帧,还是复用了同一个?)
- 谁记着这个盒子?(有几个闭包持有它的地址?它们会在什么时候被调用?)
这三个问题能解释掉的 bug 类型包括但不限于:
- 循环里创建的回调全都用了最后一个值(JS、Python、Go 1.21 之前的
for循环变量 —— Go 1.22 专门改了这个,就是因为它坑了太多人) - Android:
Activity泄漏(一个长生命周期的对象记着一个短生命周期的帧) - Compose:
remember里捕获了旧的 state(重组时闭包记的还是上一帧的盒子 —— 这就是rememberUpdatedState存在的全部理由) - 协程:
launch { }里读到的变量值和你以为的不一样
它们全都是同一道题。
下一章
这一章我们一直在说「lambda 求值出一个闭包」,但一直没正面回答一个更基础的问题:
函数凭什么可以是一个「值」?
你可以把它塞进列表、传给别的函数、从函数里返回它 —— 这件事你天天在做,但你想过它凭什么成立吗?
下一章,λ。