為什麼 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

getinc 必須看到同一個 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
;;   RETURN

CLOSURE 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
end

returnbreaknext 在 block 裡都可能跳出多層 frame(non-local exit)。

實作上:block 帶一個 target frame 標記。return 從 block 內執行時,產生例外狀流程展開到 target frame。CRuby 的 throw / catch 機制走類似路徑。

mruby IREP 對 block 有專門 opcode:OP_LAMBDAOP_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 要:

  1. allocate Closure object
  2. 為每個 upvalue 找或建 cell
  3. 把 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 配新 cellvar 用單一 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。