第 25 章:闭包(Closures)
原文:Robert Nystrom, Crafting Interpreters, Chapter 25。原书以 CC BY-NC-SA 4.0 协议发布;本译文用于学习与研究。
正如有人说过:每个复杂问题都有一个简单的解法,而且它是错的。
Umberto Eco,《傅科摆》
上一章让 VM 拥有了函数,却没有闭包。除全局变量外,函数不能引用自己函数体之外声明的变量:
var x = "global";
fun outer() {
var x = "outer";
fun inner() { print x; }
inner();
}
outer();
现有 clox 会输出 global,正确结果应为 outer。变量解析必须纳入所有外层函数的词法作用域。
这在 clox 中比 jlox 困难,因为局部变量位于栈上。普通局部变量确实呈栈语义,按创建相反顺序销毁;闭包使其“多数时候”才成立:
fun makeClosure() {
var local = "local";
fun closure() { print local; }
return closure;
}
var closure = makeClosure();
closure();
closure 逃离了 makeClosure(),但仍持有 local,所以该变量必须比创建它的调用活得更久。

可以把所有局部变量都放到堆上,像 jlox 的 Environment;但栈很快,绝大多数局部变量不被捕获,若为少数捕获变量拖慢所有变量并不划算。本章采用 Lua VM 的方法:未捕获局部继续留在栈上;变量被闭包捕获时才提升到堆。这种方案内存节制、适合单遍编译器,不过机制较精细。
25.1 闭包对象(Closure Objects)
ObjFunction 是前端在编译期创建的函数声明“裸”表示,保存固定的字节码和常量。闭包却需要捕获声明运行时周围变量的状态:
fun makeClosure(value) {
fun closure() { print value; }
return closure;
}
var doughnut = makeClosure("doughnut");
var bagel = makeClosure("bagel");
doughnut();
bagel();
两次调用执行同一 closure 声明,却返回捕获不同值的两个闭包。因此运行时将裸 ObjFunction 包入新的 ObjClosure:前者由同一声明共享,后者持有每次执行特有的捕获状态。Lua 把裸函数称作 prototype,不过该词也常指原型继承。
即使函数没有捕获变量,仍统一包装为 ObjClosure,略有浪费但让 VM 总是假设可调用对象是闭包:
typedef struct {
Obj obj;
ObjFunction* function;
} ObjClosure;
ObjClosure* newClosure(ObjFunction* function) {
ObjClosure* closure = ALLOCATE_OBJ(ObjClosure, OBJ_CLOSURE);
closure->function = function;
return closure;
}
#define IS_CLOSURE(value) isObjType(value, OBJ_CLOSURE)
#define AS_CLOSURE(value) ((ObjClosure*)AS_OBJ(value))
增加 OBJ_CLOSURE,释放时只释放 wrapper 本身而不释放 ObjFunction,因为多个闭包以及外层函数常量表都可能引用同一裸函数,生命周期应由即将实现的 GC 统一处理。打印闭包复用 printFunction(AS_CLOSURE(value)->function),用户不需要感知两种对象的实现差异。

25.1.1 编译为闭包对象(Compiling to closure objects)
函数声明结束时,之前发出 OP_CONSTANT 直接加载函数常量;改为 OP_CLOSURE,告诉运行时创建 wrapper:
ObjFunction* function = endCompiler();
emitBytes(OP_CLOSURE, makeConstant(OBJ_VAL(function)));
该新 opcode 当前有一个函数常量表索引操作数;本章稍后会使它成为可变长度指令。调试器先以专门分支显示:
case OP_CLOSURE: {
offset++;
uint8_t constant = chunk->code[offset++];
printf("%-16s %4d ", "OP_CLOSURE", constant);
printValue(chunk->constants.values[constant]);
printf("\n");
return offset;
}
25.1.2 解释函数声明(Interpreting function declarations)
运行 OP_CLOSURE 时读取裸函数、创建闭包并压栈:
case OP_CLOSURE: {
ObjFunction* function = AS_FUNCTION(READ_CONSTANT());
ObjClosure* closure = newClosure(function);
push(OBJ_VAL(closure));
break;
}
此后 VM 只调用闭包,而不调用裸函数:
case OBJ_CLOSURE:
return call(AS_CLOSURE(callee), argCount);
call() 接收 ObjClosure*,从 closure->function 读取 arity 与字节码,并将闭包而非函数保存进帧:
typedef struct {
ObjClosure* closure;
uint8_t* ip;
Value* slots;
} CallFrame;
static bool call(ObjClosure* closure, int argCount) {
if (argCount != closure->function->arity) {
runtimeError("Expected %d arguments but got %d.",
closure->function->arity, argCount);
return false;
}
CallFrame* frame = &vm.frames[vm.frameCount++];
frame->closure = closure;
frame->ip = closure->function->chunk.code;
frame->slots = vm.stackTop - argCount - 1;
return true;
}
READ_CONSTANT、单步反汇编、运行时栈踪迹等以前取 frame->function 的地方都改为 frame->closure->function。脚本编译器仍返回裸函数,所以 interpret() 先压栈保活它、创建闭包、弹出裸函数再压入闭包,最终 call(closure, 0);看似多余的栈操作同样是为将来 GC 分配期间保活对象。
至此每次执行函数声明都会创建一个闭包,尽管它还没有真正捕获任何东西。
25.2 上值(Upvalues)
局部变量指令只可访问当前函数的栈窗口。最简单的“向当前窗口前方读取槽位”方案假设被捕获变量一直在栈上,不成立;另一种方案是所有可能被捕获的变量在声明时都放堆上,但单遍编译器直到后面遇到内层函数才知道先前变量会被捕获,难以回补已生成的代码:
fun outer() {
var x = 1; // (1)
x = 2; // (2)
fun inner() { print x; } // (3)
}
Lua 的上值(upvalue)提供间接层:上值引用外层函数的局部变量;每个闭包持有一个上值数组,数组每项对应它使用的一个外层局部。闭包创建时将这些上值接到栈中的变量位置;变量离开栈后,上值仍能指向其堆上位置。
{
var a = 3;
fun f() { print a; }
}
的对象关系如下:

25.2.1 编译上值(Compiling upvalues)
词法作用域让编译器知道内层函数访问了哪些外层变量、声明位置和栈槽。namedVariable() 在本地解析失败后,额外尝试上值;成功则选择新的读写 opcode:
if (arg != -1) {
getOp = OP_GET_LOCAL;
setOp = OP_SET_LOCAL;
} else if ((arg = resolveUpvalue(current, &name)) != -1) {
getOp = OP_GET_UPVALUE;
setOp = OP_SET_UPVALUE;
} else {
getOp = OP_GET_GLOBAL;
setOp = OP_SET_GLOBAL;
}
初版 resolveUpvalue() 先在直接外层编译器中查局部变量;若没有外层函数或未找到,返回 -1,让变量按“希望是全局”的规则处理:
static int resolveUpvalue(Compiler* compiler, Token* name) {
if (compiler->enclosing == NULL) return -1;
int local = resolveLocal(compiler->enclosing, name);
if (local != -1) {
return addUpvalue(compiler, (uint8_t)local, true);
}
return -1;
}
每个编译器有 Upvalue 数组;其位置与运行时闭包的上值数组位置对应。index 表示直接外层中被捕获的局部槽或上值索引,isLocal 说明来源:
typedef struct {
uint8_t index;
bool isLocal;
} Upvalue;
typedef struct Compiler {
/* enclosing, function, locals ... */
Upvalue upvalues[UINT8_COUNT];
} Compiler;
typedef struct {
Obj obj;
int arity;
int upvalueCount;
Chunk chunk;
ObjString* name;
} ObjFunction;
newFunction() 将 upvalueCount 初始化为零。addUpvalue() 避免同一函数同一来源变量被重复捕获,并检查一字节索引的 256 上限:
static int addUpvalue(Compiler* compiler, uint8_t index, bool isLocal) {
int count = compiler->function->upvalueCount;
for (int i = 0; i < count; i++) {
Upvalue* upvalue = &compiler->upvalues[i];
if (upvalue->index == index && upvalue->isLocal == isLocal) return i;
}
if (count == UINT8_COUNT) {
error("Too many closure variables in function.");
return 0;
}
compiler->upvalues[count].isLocal = isLocal;
compiler->upvalues[count].index = index;
return compiler->function->upvalueCount++;
}
常量、arity 与 upvalue 数一样,都是连接编译器和运行时的小块元数据。
25.2.2 扁平化上值(Flattening upvalues)
闭包也能访问非直接外层的变量:
fun outer() {
var x = 1;
fun middle() {
fun inner() { print x; }
}
}
不能直接假定 x 仍在更低的栈槽。更棘手的例子中,outer() 在 inner 声明执行前已返回:
fun outer() {
var x = "value";
fun middle() {
fun inner() { print x; }
print "create inner closure";
return inner;
}
print "return from outer";
return middle;
}
var mid = outer();
var in = mid();
in();
输出依次为 return from outer、create inner closure、value;x 先离开栈,再被内层闭包捕获与访问。

解决方案是一个闭包只从直接外层函数捕获局部或现有上值。若最内层函数引用隔多层的变量,则每个中间函数先捕获上值、再交给下一层;即使中间函数自己并未使用该变量:

递归 resolveUpvalue() 在向外寻找时捕获局部,在返回内层的途中捕获外层上值:
static int resolveUpvalue(Compiler* compiler, Token* name) {
if (compiler->enclosing == NULL) return -1;
int local = resolveLocal(compiler->enclosing, name);
if (local != -1) {
return addUpvalue(compiler, (uint8_t)local, true);
}
int upvalue = resolveUpvalue(compiler->enclosing, name);
if (upvalue != -1) {
return addUpvalue(compiler, (uint8_t)upvalue, false);
}
return -1;
}
这类递归同时在递归调用前后做工作:到达实际声明变量的一层是基例;回溯时为每个中间函数补入一项上值。isLocal = false 表示捕获直接外层闭包的上值而非局部。

所有变量最终解析为局部、上值或全局。OP_CLOSURE 因此采用可变长度编码:函数常量后,按其 upvalueCount 追加成对字节;第一字节为 1 表示捕获直接外层局部,0 表示捕获外层上值,第二字节是槽位或上值索引:
emitBytes(OP_CLOSURE, makeConstant(OBJ_VAL(function)));
for (int i = 0; i < function->upvalueCount; i++) {
emitByte(compiler.upvalues[i].isLocal ? 1 : 0);
emitByte(compiler.upvalues[i].index);
}
反汇编器据函数的上值数继续读取这些 operand;例如 inner 捕获 a + c + b + d 时可显示 upvalue 0、local 1、upvalue 1、local 2。同时为 OP_GET_UPVALUE 和 OP_SET_UPVALUE 添加普通一字节反汇编支持。
25.3 上值对象(Upvalue Objects)
字节码说明闭包需要哪些上值,运行时则需要对象来管理它们。上值不持有值副本,而持有变量位置的指针;这样内层赋值会更新外层变量本身:
fun outer() {
var x = "before";
fun inner() { x = "assigned"; }
inner();
print x; // assigned
}
outer();
typedef struct ObjUpvalue {
Obj obj;
Value* location;
} ObjUpvalue;
ObjUpvalue* newUpvalue(Value* slot) {
ObjUpvalue* upvalue = ALLOCATE_OBJ(ObjUpvalue, OBJ_UPVALUE);
upvalue->location = slot;
return upvalue;
}
上值属于对象系统,使下一章 GC 能管理它。增加 OBJ_UPVALUE,释放时只释放对象本身;多个闭包可共享同一个上值,故它不拥有所引用变量。上值不是用户可直接访问的一等 Lox 值,printObject() 虽会打印 upvalue,实际不会由用户触发。
25.3.1 闭包中的上值(Upvalues in closures)
不同闭包上值数不同,所以闭包持有动态的“指向上值的指针数组”,并单独保存数量。数量与 ObjFunction 冗余,是为了 GC 在函数已释放后仍知道数组大小:
typedef struct {
Obj obj;
ObjFunction* function;
ObjUpvalue** upvalues;
int upvalueCount;
} ObjClosure;
ObjClosure* newClosure(ObjFunction* function) {
ObjUpvalue** upvalues = ALLOCATE(ObjUpvalue*, function->upvalueCount);
for (int i = 0; i < function->upvalueCount; i++) upvalues[i] = NULL;
ObjClosure* closure = ALLOCATE_OBJ(ObjClosure, OBJ_CLOSURE);
closure->function = function;
closure->upvalues = upvalues;
closure->upvalueCount = function->upvalueCount;
return closure;
}
先分配并置空数组,再创建闭包对象,是为 GC 不看到未初始化内存。释放 ObjClosure 时释放数组但不释放数组项上的 ObjUpvalue,它们可能被共享。
VM 执行 OP_CLOSURE 后逐个读取描述字节:捕获局部时调用 captureUpvalue(frame->slots + index);捕获外层上值时复用 frame->closure->upvalues[index]。这就是闭包真正活起来的时刻:
for (int i = 0; i < closure->upvalueCount; i++) {
uint8_t isLocal = READ_BYTE();
uint8_t index = READ_BYTE();
if (isLocal) {
closure->upvalues[i] = captureUpvalue(frame->slots + index);
} else {
closure->upvalues[i] = frame->closure->upvalues[index];
}
}
新增的读取与赋值操作始终解引用 location,无论它当前指向栈或未来的堆字段:
case OP_GET_UPVALUE: {
uint8_t slot = READ_BYTE();
push(*frame->closure->upvalues[slot]->location);
break;
}
case OP_SET_UPVALUE: {
uint8_t slot = READ_BYTE();
*frame->closure->upvalues[slot]->location = peek(0);
break;
}
块结束时,若局部变量已捕获,不能直接 OP_POP。为 Local 添加 isCaptured,resolveUpvalue() 找到直接外层局部时设为真;endScope() 相应选择 OP_CLOSE_UPVALUE:
while (current->localCount > 0 &&
current->locals[current->localCount - 1].depth > current->scopeDepth) {
if (current->locals[current->localCount - 1].isCaptured) {
emitByte(OP_CLOSE_UPVALUE);
} else {
emitByte(OP_POP);
}
current->localCount--;
}
25.4 已关闭上值(Closed Upvalues)
捕获后的变量仍在栈上时,location 直接指栈槽,称开放上值。函数返回或块结束会复用/丢弃栈槽,变量必须转移到堆。重点是上值捕获的是变量而不是捕获时的值:
fun outer() {
var x = "before";
fun inner() { print x; }
x = "after";
inner(); // after
}
因此不能在创建闭包时复制 x;必须直到变量真正离开栈时才关闭它。
25.4.1 值与变量(Values and variables)
为让同一份读写指令同时处理开放和关闭状态,上值保存堆上的 closed 字段,并始终通过 location 间接访问:关闭时复制值,再把 location 改指向自己的 closed 字段,无需为 OP_GET_UPVALUE 与 OP_SET_UPVALUE 添加条件分支:
typedef struct ObjUpvalue {
Obj obj;
Value* location;
Value closed;
struct ObjUpvalue* next;
} ObjUpvalue;
ObjUpvalue* newUpvalue(Value* slot) {
ObjUpvalue* upvalue = ALLOCATE_OBJ(ObjUpvalue, OBJ_UPVALUE);
upvalue->closed = NIL_VAL;
upvalue->location = slot;
upvalue->next = NULL;
return upvalue;
}
25.4.2 关闭上值(Closing upvalues)
关闭的时机不是创建闭包时,而是变量即将离开栈时;在此之前,上值必须继续指向同一个可变栈槽,才能让外层函数和闭包观察到彼此的后续赋值。
25.4.3 跟踪开放上值(Tracking open upvalues)
两个闭包捕获同一局部变量时必须共享同一 ObjUpvalue,否则一个闭包修改变量,另一个看到的可能是旧副本。因此 VM 跟踪所有开放上值,并让每个栈槽最多有一个对象。开放上值数量一般很少且多捕获栈顶附近变量;按栈地址降序排列,查找越过目标槽就可及早结束。为便于中间插入,使用侵入式单链表:
typedef struct {
/* frames, stack, tables */
ObjUpvalue* openUpvalues;
Obj* objects;
} VM;
static void resetStack() {
vm.stackTop = vm.stack;
vm.frameCount = 0;
vm.openUpvalues = NULL;
}

captureUpvalue() 在链表中搜索同一槽位,找到便复用;否则按位置插入:
static ObjUpvalue* captureUpvalue(Value* local) {
ObjUpvalue* prevUpvalue = NULL;
ObjUpvalue* upvalue = vm.openUpvalues;
while (upvalue != NULL && upvalue->location > local) {
prevUpvalue = upvalue;
upvalue = upvalue->next;
}
if (upvalue != NULL && upvalue->location == local) return upvalue;
ObjUpvalue* createdUpvalue = newUpvalue(local);
createdUpvalue->next = upvalue;
if (prevUpvalue == NULL) {
vm.openUpvalues = createdUpvalue;
} else {
prevUpvalue->next = createdUpvalue;
}
return createdUpvalue;
}
遍历停止有三种情况:找到同一槽位;链表耗尽;遇到比目标更低的槽,因排序可知不可能存在目标。后一二情况均创建并插入节点。用“指向指针的指针”可把更新头节点和前驱 next 合并,但基础 if 更易读。
25.4.4 运行时关闭上值(Closing upvalues at runtime)
编译器在块结束为已捕获局部发出 OP_CLOSE_UPVALUE,此时变量正位于栈顶。运行时先关闭对应上值,后弹出槽位:
case OP_CLOSE_UPVALUE:
closeUpvalues(vm.stackTop - 1);
pop();
break;
closeUpvalues(last) 关闭 last 及其上方所有开放上值;由于链表从栈顶向下排序,遇到范围外节点即可停止:
static void closeUpvalues(Value* last) {
while (vm.openUpvalues != NULL &&
vm.openUpvalues->location >= last) {
ObjUpvalue* upvalue = vm.openUpvalues;
upvalue->closed = *upvalue->location;
upvalue->location = &upvalue->closed;
vm.openUpvalues = upvalue->next;
}
}

函数最外层作用域的变量也应关闭,但编译器不必逐个发指令:函数返回时本来就丢弃其整段窗口。只需在 OP_RETURN 弹帧前,以该帧第一个槽位作为下界调用同一助手:
Value result = pop();
closeUpvalues(frame->slots);
vm.frameCount--;
至此闭包完整工作,捕获变量会按需要活到最后一个使用它的闭包消失。jlox 的环境表示在实现复杂度上近似“免费”得到闭包,却让所有局部变量都承担堆分配成本;clox 用更多代码区分两条路径:绝大多数栈语义变量走快速栈路径,少数逃逸变量才走按需提升的慢路径。用户看到的局部变量语义仍统一,复杂性完全藏在实现内部。这类识别特殊使用模式并给出优化路径,正是成熟实现中“优化”的大部分含义。
现在词法作用域已在 clox 完整实现。函数、复杂生命周期与相互引用对象也让堆变得拥挤,下一章将解决内存回收。
挑战(Challenges)
- 总将
ObjFunction包进ObjClosure产生不必要间接层。改为仅有上值的函数创建闭包,比较代码复杂度与使用/不使用闭包的基准表现,并说明如何权衡冲突结果。 - 阅读下文设计笔记后,决定 Lox 的循环捕获应有何语义;修改实现,使每次循环迭代创建新变量。
- “对象是穷人的闭包,闭包也是穷人的对象”。仅用闭包写二维向量“对象”:构造函数接收
x/y,提供访问坐标的方法,并实现相加后返回第三个向量的方法。
设计笔记:捕获循环变量(Closing Over the Loop Variable)
闭包捕获的是变量。两个闭包捕获同一个变量时,共享底层存储,因此赋值可见;捕获不同变量则不会共享。两个独立块中的同名 a 显然不同:
var globalOne;
var globalTwo;
fun main() {
{ var a = "one"; fun one() { print a; } globalOne = one; }
{ var a = "two"; fun two() { print a; } globalTwo = two; }
}
main();
globalOne(); // one
globalTwo(); // two
但循环中的变量不那么明确。main() 的两次循环都创建闭包,一个保存到 globalOne,一个保存到 globalTwo:它们是两个闭包,却究竟捕获一个持续更新的 a,还是每轮一个新 a?
for (var a = 1; a <= 2; a = a + 1) {
fun closure() { print a; }
if (globalOne == nil) globalOne = closure;
else globalTwo = closure;
}
JavaScript 的 var 只有一个循环变量:
var closures = [];
for (var i = 1; i <= 2; i++) {
closures.push(function() { console.log(i); });
}
closures[0]();
closures[1]();
它输出两次 3。第二轮后 i++ 将 i 变为 3,i <= 2 失败并退出;var 又会提升到外层作用域,相当于在循环前先声明单个 i。换成 JavaScript 的 let 则输出 1、2:每轮拥有自己的 i。这看似奇怪,因为 i++ 的外观像修改既有变量,实际却为下轮创建绑定。
Python 没有真正块作用域,示例中 lambda 共享函数内的 i,range(1, 3) 后输出两次 2。Ruby 的传统 for i in 1..2 也输出两次 2;更惯用的 (1..2).each do |i| ... end 中,i 是每次调用闭包的参数,故输出 1、2。
对 C# foreach、Java enhanced for、JavaScript for-of、Dart for-in 等更高层迭代语法,读者自然期待每轮新变量:循环头看起来是变量声明,也没有明显在修改它的增量式。C# 起初没有这样做,用户困惑如此常见,以至于 C# 5 作出了破坏性变更,每轮改为新变量。
旧 C 风格 for 更难判断:增量子句确实像一个变量被持续修改,但让闭包共享导致循环退出的最终值通常没用。务实的答案很可能是仿 JavaScript 的 let:表面像修改,实则每轮创建新变量,因为这才是用户大多期待的行为;从概念上看依然有些奇特。