在这一节中,我们将阐述解释器中闭包的实现原理。
前提
在我们实现的单遍解释器中,我们采用的是线性中间表达,即字节码。
语言运行的整体流程为:源代码 -> 解析器 -> 字节码 -> 虚拟机执行。
开始
想象这样一段代码:
fun outer() { let x = "data"; fun inner() { print(x); } return inner;}let closure = outer();closure();outer 函数调用并返回给 closure 变量后,我们又对 closure 变量进行了函数调用,试图访问已经死去的局部变量 x,这时如果有闭包机制,我们就能够正常访问到 x 的值。
我们先从几个亟待解决的问题开始入手,逐个击破:
Q1:闭包捕获的变量怎么处理?
在我们的虚拟机中,局部变量随函数而生,随函数而死(栈帧弹出)。但闭包需要变量「长生不老」,这就产生了一个矛盾:如何让一个本该在栈上销毁的变量,在堆中继续存在?
如果我们一开始的设计就是将所有局部变量都存储在堆上,那么闭包的实现就会非常简单,因为闭包可以直接通过引用来访问这些变量。并且在函数返回时,可以选择性地让被捕获变量不被销毁。但这种设计方式会导致性能问题,因为大多数局部变量并不需要被闭包捕获,存储在堆上会增加内存的分配。
另一个想法就是将被捕获的变量存储在堆上,这样函数调用结束后,局部变量仍然存在于堆上,闭包可以通过引用来访问这些变量。
明晰了这个思路后,我们可以将闭包捕获的变量分为两个阶段:
Open阶段:当闭包捕获一个局部变量但还未返回时,这个变量处于Open状态,此时它仍然存储在栈帧中,我们可以直接在当前栈帧外访问和修改该变量;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。
- 如果这个值来自父函数的栈,那么我们就直接将这个值的栈索引存储到闭包结构体中;
- 如果这个值来自父函数已经捕获过的 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);}整体流程
综上所述,被捕获变量的生命周期如下: