1679 字
8 分钟
单遍解释器:闭包

在这一节中,我们将阐述解释器中闭包的实现原理。

前提#

在我们实现的单遍解释器中,我们采用的是线性中间表达,即字节码。

语言运行的整体流程为:源代码 -> 解析器 -> 字节码 -> 虚拟机执行。

开始#

想象这样一段代码:

fun outer() {
let x = "data";
fun inner() { print(x); }
return inner;
}
let closure = outer();
closure();

outer 函数调用并返回给 closure 变量后,我们又对 closure 变量进行了函数调用,试图访问已经死去的局部变量 x,这时如果有闭包机制,我们就能够正常访问到 x 的值。

我们先从几个亟待解决的问题开始入手,逐个击破:

Q1:闭包捕获的变量怎么处理?#

在我们的虚拟机中,局部变量随函数而生,随函数而死(栈帧弹出)。但闭包需要变量「长生不老」,这就产生了一个矛盾:如何让一个本该在栈上销毁的变量,在堆中继续存在?

如果我们一开始的设计就是将所有局部变量都存储在堆上,那么闭包的实现就会非常简单,因为闭包可以直接通过引用来访问这些变量。并且在函数返回时,可以选择性地让被捕获变量不被销毁。但这种设计方式会导致性能问题,因为大多数局部变量并不需要被闭包捕获,存储在堆上会增加内存的分配。

另一个想法就是将被捕获的变量存储在堆上,这样函数调用结束后,局部变量仍然存在于堆上,闭包可以通过引用来访问这些变量。

明晰了这个思路后,我们可以将闭包捕获的变量分为两个阶段:

  1. Open 阶段:当闭包捕获一个局部变量但还未返回时,这个变量处于 Open 状态,此时它仍然存储在栈帧中,我们可以直接在当前栈帧外访问和修改该变量;
  2. Closed 阶段:当闭包捕获一个局部变量并且函数调用结束后,这个变量处于 Closed 状态,此时它已经被移动到堆上,我们需要通过引用来访问和修改该变量。
enum UpvalueState {
Open(usize),
Closed(ObjIndex),
}

可以看到,在 Open 状态下,我们直接存储了被捕获变量在栈帧中的索引;而在 Closed 状态下,我们存储了被捕获变量在堆中的索引。

而这两个状态从 Open 转换到 Closed 的时机就是在函数调用结束时,当函数调用结束时,我们需要检查当前函数是否有被闭包捕获的变量,如果有,我们就将这些变量从栈帧中移动到堆上,并将它们的状态从 Open 转换为 Closed

Q2:如何实现闭包?#

为了简化实现,我们可以在函数声明时额外增加一个操作码 OpCode::Closure,用于在运行时将函数包装为闭包。同时,该操作码还需要将被捕获变量存放到该闭包结构体当中,以便在函数调用结束后,我们能够通过该结构体直接访问这些变量:

struct ObjClosure {
/// The object index of function object.
pub func: ObjIndex,
/// List of upvalues.
pub upvalues: [Option<Rc<RefCell<UpvalueState>>>; MAX_UPVALUE_SIZE],
/// The amount of upvalues.
pub upvalue_count: usize,
}

这里我们通过 upvalues 字段来存储被捕获的变量,后续函数要访问被捕获变量时,可以直接访问该数组。

Q3:为什么要在运行时将函数包装为闭包?#

闭包结构体用于访问被捕获变量,所以需要确定被捕获变量的位置(栈索引or堆索引)。

而像有些特殊情况(例如被捕获变量是用户传入的参数)只有在运行时我们才能知道被捕获变量的具体位置:

fun outer(x) {
fun inner() {
print(x);
}
return inner;
}
let a = outer("Hello, ");
let b = outer("Lox");
a();
b();

上述代码中,inner 函数捕获了 outer 函数的参数 x

  • 对于 inner 函数来说,它捕获的是 outer 函数中栈帧的第一个元素;
  • 但是对 outer 函数来说,它在运行时才能获取到 x 的实参字符串在堆中的索引。

简单来说,编译器只知道 x 是第几个参数,但具体 x 此时指向的是字符串 Hello, 还是 Lox,只有运行时运行到那里才知道。

Q4:如何访问闭包捕获的变量呢?#

在编译期,当我们识别到当前变量是被闭包捕获的变量时,我们需要在调用函数中标记这个变量,即存入当前调用函数的 upvalues 列表中。

后续被调用函数访问这个被捕获变量时,我们就可以通过访问调用函数的 upvalues 列表来获取被捕获变量的索引,从而访问到被捕获变量的值。

具体而言,就是在访问被捕获变量时先通过操作码 OpCode::GetUpvalue OpCode::SetUpvalue 解析并输出同步维护的栈索引来获取被捕获变量的索引,从而获取值,然后使用 Rc<RefCell<UpvalueState>> 包装,存入闭包结构体中。

TIP

这里我们使用 Rc<RefCell<UpvalueState>> 来包装被捕获变量的状态,是因为被捕获变量可能会被多个闭包捕获,而这些闭包可能会在不同的作用域中访问和修改这个变量,所以我们需要使用 Rc 来实现共享所有权,使用 RefCell 来实现内部可变性。

Q5:在运行时,被捕获变量如何存储到闭包结构体中?#

在运行时,OpCode::Closure 指令后面会跟着一串「捕获指南」。每条指南告诉 VM:这个值是来自父函数的栈,还是来自父函数已经捕获过的 Upvalue。

  1. 如果这个值来自父函数的栈,那么我们就直接将这个值的栈索引存储到闭包结构体中;
  2. 如果这个值来自父函数已经捕获过的 Upvalue,那么我们就将这个 Upvalue 存储到闭包结构体中。

这些「捕获指南」来自解析器维护的 upvalues 列表,用于存储当前函数被捕获变量「是否来自直接父函数」和「在当前闭包维护的 upvalues 列表中的索引信息」。这些索引信息在函数返回时被编译到字节码中:

self.emit_with_constant_idx(OpCode::Closure, Value::Object(func_obj_idx));
for v in upvalues.iter().flatten() {
self.emit_bytes(v.is_local, v.idx);
}

整体流程#

综上所述,被捕获变量的生命周期如下:

Captured Upvalue Lifecycle

单遍解释器:闭包
https://yang-zhihang.github.io/posts/compiler-principles/closure/
作者
ZamYang
发布于
2026-04-13
许可协议
CC BY-NC-SA 4.0