為什麼 closure 比 function 難實作#
普通函式只需要:bytecode + 引數 + locals。閉包多一個東西:捕獲外部 scope 的變數。
function makeCounter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const c = makeCounter();
c(); // 1
c(); // 2
makeCounter 已經返回 ── 它的 frame 應該被釋放了,但 inner function 還在用 count。所以 count 不能放在 stack frame 裡。
三種 closure 變數的實作#
1. 全部放 heap(最簡單)#
每個 function 的 local 都是 heap object:
struct Cell { Object obj; Value value; };count 是個 Cell*,inner function 持有 reference。makeCounter frame 釋放後,count cell 還在 heap 上活著。
問題:所有 local 都付 heap allocation 代價 ── 不需要 closure 的也付。慢。
2. Escape analysis(編譯期判斷)#
編譯期看 count 是否被內層 closure 捕獲:
- 沒被捕獲 ➡️ 放 stack
- 被捕獲 ➡️ 配 heap cell
Java、Go 編譯器都做 escape analysis。對 VM 來說是 compiler-side 的工作,runtime 不變。
3. Upvalue(runtime 半搬遷,Lua 經典)#
count 一開始在 stack frame 上(cheap)。inner function 透過 upvalue 指向 stack 位置。當 outer function 返回(stack 要被收掉)時,把 stack 上 count 的值搬到 heap,並更新 upvalue 指向 heap location ── 這叫 closing the upvalue。
outer 還在執行:
inner.upvalue ──► outer.stack[count_slot]
outer 返回時:
把 outer.stack[count_slot] 值複製到 heap cell
inner.upvalue ──► heap cell
outer 的 stack 收掉優點:
- 沒被捕獲的 local 從不上 heap(普通變數零代價)
- 被捕獲也只配置一次 cell
代表:Lua 5.x、Wren、Crafting Interpreters 都用這個。
多個 closure 共享同一變數#
function make() {
let x = 0;
const get = () => x;
const inc = () => (x += 1);
return { get, inc };
}
const o = make();
o.inc();
o.inc();
o.get(); // 2
get 跟 inc 必須看到同一個 x,不是各自 copy。
upvalue 的解:兩個 closure 的 upvalue 都指向同一個 cell。當 make 返回時,cell 被 close 一次,兩個 closure 一起更新。
實作上需要一條 open upvalue 鏈 ── VM 維護所有「指向當前 stack」的 upvalue,方便在 frame pop 時批次 close。
struct Upvalue {
Object obj;
Value *location; // 指向 stack 或自己的 closed cell
Value closed; // close 後存值的地方
Upvalue *next; // 鏈到下一個 open upvalue
};
void close_upvalues(Value *limit) {
while (vm.open_upvalues && vm.open_upvalues->location >= limit) {
Upvalue *u = vm.open_upvalues;
u->closed = *u->location;
u->location = &u->closed; // 改指自己
vm.open_upvalues = u->next;
}
}frame pop 時呼叫 close_upvalues(frame->base),把所有指到該 frame 的 upvalue 搬家。
bytecode 層面#
捕獲一個 outer local:
;; def make()
;; x = 0
;; inc = -> { x += 1 }
;; inc
;; end
PUSH_INT 0
STORE_LOCAL x ;; x 在 stack slot 0
CLOSURE inc_proto, [LOCAL 0] ;; 建一個 closure,捕獲 local 0 為 upvalue 0
STORE_LOCAL inc
LOAD_LOCAL inc
RETURN
;; inc_proto:
;; LOAD_UPVALUE 0 ;; 讀 x
;; PUSH_INT 1
;; ADD
;; STORE_UPVALUE 0 ;; 寫回 x
;; LOAD_UPVALUE 0
;; RETURNCLOSURE opcode 比一般函式多一步:建 closure object + 配 upvalue 陣列。
LOAD_UPVALUE / STORE_UPVALUE 走 indirect:透過 closure 的 upvalue 表 ➡️ cell ➡️ 實際 value。
Closure 物件 layout#
struct Closure {
Object obj;
Function *func;
int upvalue_count;
Upvalue *upvalues[]; // flexible array
};每次執行 CLOSURE 都產一個新 Closure ── 同一函式可有多份 closure,各自捕獲不同 upvalue。
Ruby Block 的特殊性#
Ruby 的 block 看起來像 closure,但有額外語意:
[1, 2, 3].each do |i|
puts i
return "early!" # 從外層 method 跳出,不是 block
endreturn、break、next 在 block 裡都可能跳出多層 frame(non-local exit)。
實作上:block 帶一個 target frame 標記。return 從 block 內執行時,產生例外狀流程展開到 target frame。CRuby 的 throw / catch 機制走類似路徑。
mruby IREP 對 block 有專門 opcode:OP_LAMBDA、OP_BLOCK,分別建 lambda(嚴格 arity、return 不外跳)與 block(彈性 arity、return 外跳)。
block_given? 與 yield#
def each_three
yield 1
yield 2
yield 3
end
each_three { |x| puts x }yield 不是普通 method call ── 它呼叫附在當前 frame 上的 block。VM 設計上 frame 多一個 block 欄位:
struct Frame {
Function *func;
Value block; // 這次呼叫帶來的 block,或 NIL
// ...
};yield opcode 拿 frame->block,當作函式呼叫。
效能:closure 開銷#
每個 closure 要:
- allocate Closure object
- 為每個 upvalue 找或建 cell
- 把 upvalue 指標串進 closure
對 callback 密集的程式(map、filter、event handler),closure 配置可能成為熱點:
// 每次迴圈都新建一個 closure
for (let i = 0; i < 1000000; i++) {
arr.map((x) => x * 2);
}優化角度:
- 逃逸分析:如果 closure 沒逃出當前 frame,可以 stack-allocate
- inline:JIT 把 closure 直接 inline 到 caller
- closure caching:同一 site 反覆建立同樣 closure 時,重用
upvalue 與 GC 的互動#
closure 持有 upvalue ➡️ upvalue 持有 value ➡️ value 可能是物件 ➡️ 物件持有更多 ref
GC 必須能 trace 整條鏈。closure 與 upvalue 都是 GC 物件,scan 時:
void scan_closure(Closure *c) {
mark(c->func);
for (int i = 0; i < c->upvalue_count; i++)
scan_upvalue(c->upvalues[i]);
}
void scan_upvalue(Upvalue *u) {
// 如果 closed,scan u->closed
// 如果 open,u->location 還在 stack 上,stack scan 會自然涵蓋
if (u->location == &u->closed)
mark_value(u->closed);
}對 generational GC:closure 通常 short-lived(lambda 用過就丟),多數死在 young generation。
JavaScript 與 Python 的差異#
// JS:每次迴圈一個新 i
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 印 0, 1, 2
// 但用 var:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 印 3, 3, 3
let 每次 iteration 是新 binding,每個 closure 捕獲新的 i。var 是 function-scope,所有 closure 捕獲同一個 i。
VM 實作層面:let 在迴圈頂端為每次 iteration 配新 cell,var 用單一 cell。看似小事,bytecode emitter 必須對著 source 正確 emit。
Python 的對應陷阱:
funcs = [lambda: i for i in range(3)]
[f() for f in funcs] # [2, 2, 2] ← 全是 i 最後的值Python 沒有 per-iteration binding,所有 lambda 共享 i。要避開:
funcs = [lambda i=i: i for i in range(3)] # 用預設引數捕獲當下值物件導向 + closure:method 是不是 closure?#
某種意義上是。obj.method 可以視為「method 函式 + self 上下文」── 一個 1 個 upvalue 的 closure,upvalue 是 self。
JavaScript 的 bound function 就是這個:
const fn = obj.method.bind(obj);
fn(); // this 永遠是 obj
實作:bind 產生一個 closure,內部捕獲 obj,呼叫時把 obj 當 this 傳入原 method。
小結#
- closure 必須讓「函式活得比 outer scope 久」── 變數需要 escape
- 最優雅的解:upvalue + open list,沒被捕獲的 local 零成本
- 多個 closure 共享變數 ➡️ upvalue 共享 cell
- bytecode:CLOSURE opcode 建 closure + 配 upvalue;LOAD/STORE_UPVALUE 透過 indirection
- Ruby block 多了 non-local return 的複雜度
- 隱性陷阱:循環中的 closure,per-iteration binding 與否決定行為
下章看物件模型:類別、繼承、method dispatch。