クロージャを実装する——upvalueをOpen/Closedの2状態で表現しスタックとヒープを橋渡しする
ネストした関数は「宣言はできるが外側の変数には手が届かない」状態だった
関数を実装した時点では、fn宣言はトップレベルにしか書けませんでした。ネストした関数宣言は型検査の段階で一律にエラーにしていたためです。この制限を外し、関数の中で別の関数を宣言できるようにすると、次のようなコードが型検査こそ通るものの、正しく動かないことがわかります。
fn outer(x: int): int {
fn inner(): int { return x; }
return inner();
}innerから見たxは、自分の引数でもローカル変数でもなく、外側のouterのスコープにある変数です。ところがコンパイラは、関数ごとに完全に独立したコンパイル状態を使い回す設計になっていました。innerをコンパイルしている最中、outerのlocals(どの変数がどのスタックスロットにあるかの一覧)には一切アクセスできません。つまりinnerの中でxを参照しようとしても、「そんな変数は無い」というコンパイルエラーになってしまいます。
この「外側のスコープの変数を、内側の関数が参照する」という仕組みを実現するのがクロージャです。捕まえられる変数のことを、Luaや本処理系が参考にしているCrafting Interpretersのバイトコード方式にならってupvalueと呼びます。
コンパイラ自身をフレームのスタックにする
upvalueを解決するには、「今コンパイルしている関数」だけでなく「一つ外側の関数」「二つ外側の関数」の情報にもアクセスできる必要があります。そこで、VMがすでにframes: Vec<CallFrame>で実行中の呼び出しを積んでいるのと同じ発想を、コンパイラ側にも持ち込みました。
struct FunctionScope {
chunk: Chunk,
locals: Vec<Local>,
scope_depth: usize,
loops: Vec<LoopContext>,
pops_return_value: bool,
upvalues: Vec<UpvalueDesc>,
}
pub struct Compiler {
frames: Vec<FunctionScope>,
}chunk・locals・scope_depthなど、これまでCompilerが直接持っていたフィールドをすべてFunctionScopeに移し、Compiler自身はframesというスタックだけを持ちます。ネストした関数のコンパイルに入るときは新しいFunctionScopeをpushし、終わったらpopします。外側のFunctionScopeはpopされるまでframesの中に残り続けるので、「一つ外側」「二つ外側」という参照がself.frames[frame_index - 1]のような配列インデックスで素直に書けるようになります。
clox本家はCompiler同士をenclosingという再帰的なポインタで繋ぎますが、Rustではオブジェクトの所有権が絡んで扱いにくくなります。関数のコンパイルがちょうどRustの再帰呼び出し(compile_functionが自分自身を間接的に呼ぶ形)になっていることを利用し、その再帰をスタックとして明示的に表現し直したのがこの設計です。
resolve_upvalue——一つ外側から順に、再帰的に手繰る
変数nameを解決する処理は、2段階になりました。
Expr::Ident(name) => {
let frame_index = self.frames.len() - 1;
if let Some(slot) = self.resolve_local_in(frame_index, name) {
self.emit_get_local(slot as u8, line);
return Ok(());
}
if let Some(index) = self.resolve_upvalue(frame_index, name) {
self.emit_get_upvalue(index, line);
return Ok(());
}
Err(self.error(line, column, format!("undefined variable '{name}'")))
}まず自分自身のローカル変数を探し、見つからなければupvalueとして外側を探しに行きます。resolve_upvalueはclox本家のresolveUpvalueをそのままself.framesのインデックス走査に置き換えたものです。
fn resolve_upvalue(&mut self, frame_index: usize, name: &str) -> Option<u8> {
if frame_index == 0 {
return None; // トップレベルには外側が無い
}
let outer = frame_index - 1;
if let Some(slot) = self.resolve_local_in(outer, name) {
// 一つ外側のフレームのローカル変数が見つかった。
// 自分のスコープより長生きする必要があるとマークする。
self.frames[outer].locals[slot].is_captured = true;
return Some(self.add_upvalue(frame_index, slot as u8, true));
}
if let Some(index) = self.resolve_upvalue(outer, name) {
// 一つ外側にも無かった。さらに外側にあるはずなので、
// 一つ外側の関数自身のupvalueとして間接的に参照する。
return Some(self.add_upvalue(frame_index, index, false));
}
None
}ポイントは、見つかった場所によってadd_upvalueに渡すis_localフラグが変わることです。
- 一つ外側のローカル変数として見つかった場合は
is_local: true - 一つ外側にも無く、そちらもさらに外側からupvalueとして捕まえていた場合は
is_local: false(「upvalueのupvalue」)
3段ネストの例で確認します。
fn a(x: int): int {
fn b(): int {
fn c(): int { return x; }
return c();
}
return b();
}cをコンパイルしている最中にxを探すと、一つ外側のbにはxという名前のローカル変数もパラメータも無いので、resolve_upvalueはbについてさらに再帰します。するとbから見た一つ外側のaにはxというパラメータがあるので、まずb自身に「aのスロットを直接指すupvalue」が登録され、続いてcには「bの、今登録したばかりのupvalueを指すupvalue」が登録されます。b自身はxを一度も直接使っていないにもかかわらず、cがxにたどり着くための中継地点として、暗黙のうちにupvalueを1つ持つことになります。
OP_CLOSUREは可変長——捕捉情報がバイトコードに直接埋め込まれる
resolve_upvalueが集めた「どこから何を捕まえるか」という情報(UpvalueDesc { index, is_local })は、クロージャを生成するOP_CLOSURE命令のすぐ後ろに、そのままバイト列として埋め込まれます。
fn emit_closure(&mut self, function: Value, upvalues: &[UpvalueDesc], line: usize) {
let index = self.frame().chunk.add_constant(function);
self.frame().chunk.write_op(OpCode::Closure, line);
self.frame().chunk.write_u8(index, line);
for upvalue in upvalues {
self.frame().chunk.write_u8(upvalue.is_local as u8, line);
self.frame().chunk.write_u8(upvalue.index, line);
}
}fn inner(): int { return x; }のように1つだけ捕まえる関数なら、バイトコードは次のようになります。
OP_CLOSURE <inner関数の定数インデックス>
is_local=1 index=1 ; outerのスロット1(パラメータx)を直接捕まえるこのとき「何個のペアが続くか」はバイトコード自体には書き込みません。OP_CLOSUREが参照する関数の定数(FunctionObj)がupvalue_countというフィールドを持っていて、コンパイラもVMもディスアセンブラも、この値を見てペアの数を判断します。
pub struct FunctionObj {
pub name: String,
pub arity: u8,
pub chunk: Chunk,
pub upvalue_count: u8,
}upvalueの実行時表現——Open(スタックを指す)とClosed(ヒープへ退避済み)
OP_CLOSUREが実行されると、VMはupvalue_countぶんの(is_local, index)ペアを読み、それぞれについてupvalueの実体を作ります。
OpCode::Closure => {
let Value::Function(function) = self.read_constant() else {
unreachable!("compiler always emits a function constant for OP_CLOSURE");
};
let mut upvalues = Vec::with_capacity(function.upvalue_count as usize);
for _ in 0..function.upvalue_count {
let is_local = self.read_u8() != 0;
let index = self.read_u8() as usize;
let upvalue = if is_local {
// 今実行中のフレーム(=直接の外側の関数)のローカルスロットを直接捕まえる
self.capture_upvalue(self.locals_base() + index)
} else {
// 今実行中の関数自身がすでに持っているupvalueを共有する
Rc::clone(&self.current_closure().upvalues[index])
};
upvalues.push(upvalue);
}
self.push(Value::Closure(Rc::new(ClosureObj { function, upvalues })));
}ここで問題になるのが、「捕まえた変数の値を、いつ・どうやって手元に置くか」です。素朴に考えると、クロージャを作る瞬間に値をコピーしてしまえば単純です。しかしそれでは、外側の関数がまだ実行中で、値が今後書き換わる可能性がある間は困ります(この処理系にはまだ代入文がありませんが、将来追加したときに正しく動く設計にしておく必要があります)。かといって、スタック上の位置を指すポインタのようなものをそのまま持たせると、外側の関数がreturnした瞬間にそのスタック領域は破棄され、危険な参照になってしまいます。
そこで、upvalueを次の2状態を持つ値として表現しました。
pub enum Upvalue {
Open(usize), // スタックのこの位置を指している
Closed(Value), // 値をヒープ側へコピー済み
}作られた直後のupvalueはOpenで、スタックのインデックスを指しているだけです。捕まえた変数がまだスタック上に生きている間は、このインデックス経由で常に最新の値を読みます。
OpCode::GetUpvalue => {
let index = self.read_u8() as usize;
let upvalue = Rc::clone(&self.current_closure().upvalues[index]);
let value = match &*upvalue.borrow() {
Upvalue::Open(stack_index) => self.stack[*stack_index].clone(),
Upvalue::Closed(value) => value.clone(),
};
self.push(value);
}同じスタック位置を複数のクロージャが捕まえた場合に、それぞれ別々のセルを作ってしまわないよう、VMはopen_upvaluesという一覧を持って使い回します。
fn capture_upvalue(&mut self, stack_index: usize) -> Rc<RefCell<Upvalue>> {
if let Some(existing) = self.open_upvalues.iter().find(|upvalue| open_slot(upvalue) == Some(stack_index)) {
return Rc::clone(existing);
}
let upvalue = Rc::new(RefCell::new(Upvalue::Open(stack_index)));
self.open_upvalues.push(Rc::clone(&upvalue));
upvalue
}クローズ——スタックから消える瞬間にヒープへ退避する
Openのままでは、捕まえた変数のスコープが終わってスタック領域が破棄・再利用された後に読むと、無関係な値を拾ってしまいます。そこで、変数がスタックから消えるタイミングでupvalueをClosedに昇格させます。このタイミングは2種類あります。
1つ目は、ブロックスコープが終わるときです。コンパイラのend_scope(ブロックを抜けるときにローカル変数をポップする処理)は、そのローカル変数が捕まえられていたかどうかで命令を出し分けます。
fn end_scope(&mut self, line: usize) {
self.frame().scope_depth -= 1;
let scope_depth = self.frame().scope_depth;
while let Some(local) = self.frame().locals.last() {
if local.depth <= scope_depth {
break;
}
let is_captured = local.is_captured;
self.frame().locals.pop();
if is_captured {
self.frame().chunk.write_op(OpCode::CloseUpvalue, line);
} else {
self.frame().chunk.write_op(OpCode::Pop, line);
}
}
}OP_CLOSE_UPVALUEはオペランドを取らず、「今スタックの一番上にある値をクローズしてからポップする」という意味の命令です。
OpCode::CloseUpvalue => {
let stack_index = self.stack.len() - 1;
self.close_upvalue_at(stack_index);
self.pop();
}2つ目は、関数からreturnするときです。パラメータや関数自身の直下のローカル変数は、ブロックスコープのように1つずつOP_POPしていく対象ではなく、OP_RETURNがフレームの領域をまとめて巻き戻します。そのため、コンパイラが命令を出すのではなく、VM側で「このフレームが使っていたスタック領域以上を指しているupvalueを、まとめてクローズする」という一括処理を行います。
OpCode::Return => {
let Some(frame) = self.frames.pop() else {
return Ok(());
};
let result = self.pop();
// このフレーム内で作られたクロージャがまだopenなupvalueを
// 持っているかもしれない。スタック領域を捨てる前にヒープへ退避する。
self.close_upvalues_from(frame.stack_base);
self.stack.truncate(frame.stack_base);
self.push(result);
}動作確認
パラメータ・letで束縛したローカル変数・3段ネストの3パターンで、外側の変数を正しく捕まえられるか確認します。
fn make(x: int): int {
let y = x + 1;
fn get(): int { return x + y; }
return get();
}
fn outer(x: int): int {
fn a(): int {
fn b(): int {
fn c(): int { return x; }
return c();
}
return b();
}
return a();
}
let m = make(10);
let o = outer(7);$ cargo run -- examples/closures.na
stack after execution: [Int(21), Int(7)]make(10)はx(パラメータ、is_local=trueで直接捕まえる)とy(letで束縛したローカル変数)の両方をgetが捕まえ、10 + 11 = 21を正しく返しています。outer(7)はcから見て2つ外側にあるxを、bがupvalueのupvalueとして中継し、最終的に7を返しています。コンパイラ・VM合わせて6件のテストを追加し、既存の85件と合わせて91件が通ることを確認しています。
備考
参照キャプチャ方式を採った理由は、今のところ観測できない
upvalueのキャプチャ方式には、クロージャを作った瞬間に値をコピーしてしまう「値キャプチャ」と、今回実装した「参照キャプチャ(Open/Closed)」の2通りがあります。この処理系にはまだ代入文(x = 2;)が無く、letで束縛した変数は不変なので、実は今の言語機能の範囲内では、どちらの方式で実装しても観測できる違いはありません。それでも参照キャプチャを選んだのは、Crafting Interpretersが解説する標準的な実装に合わせておくことと、将来代入文を実装したときに、外側の変数への書き込みがクロージャ経由でも正しく見えるようにするためです。
クロージャは第一級の値ではない
関数はまだ第一級の値ではなく、fn宣言で定義した名前を直接呼び出すこと(inner();のような形)しかできません。クロージャを変数に代入したり、関数の引数として渡したり、戻り値として返したりすることはまだできません。コールバックとして関数を持ち回るような使い方は、この制限が外れて初めて意味を持ちます。
capturing_a_block_scoped_local_closes_it_when_the_block_endsのようなテストで検証しているのはバイトコードの形だけ
OP_CLOSE_UPVALUEが正しいバイトコードとして出力されていることはテストで確認していますが、「クローズしなければ実際に壊れるプログラム」を書くことは、代入文も第一級関数も無い現状ではできません(クロージャを呼び出せるのは、それが宣言されているスコープが生きている間だけだからです)。クローズ処理の実効性を検証できるテストは、代入文を実装した段階で改めて追加する予定です。