TL

クロージャの実装原理(環境キャプチャとアップバリュー)

クロージャが状態を覚え続ける仕組みを内部から理解すれば、性能の勘所もメモリリークの原因も見抜ける。環境キャプチャの実装を原理から解き明かします。

応用クロージャアップバリューコンパイラヒープランタイム最終更新: 2026-06-21
TL;DR要点だけ先に
  • 1.クロージャは「コード(関数ポインタ)+捕捉した環境への参照」の組で、捕捉変数はスタックではなくヒープ上のセルへ昇格させて関数の寿命まで延命します。
  • 2.Lua のアップバリューは変数がスタック上の間は open、関数が抜けると close してヒープへ退避する二段構えで、共有と寿命を両立します。
  • 3.コンパイラはクロージャ変換で自由変数を環境構造体へ集約し、関数を環境引数つきの素の関数へ書き換えます。捕捉は値か参照かで意味が変わります。

クロージャの正体は「コード+環境」の組

クロージャとスコープ で見たとおり、クロージャは「定義時の外側変数を覚え続ける関数」です。では実装上、この「覚える」は何で表現されているのか。答えは、クロージャが単なる関数ポインタではなく コード(関数本体)と環境(捕捉した変数の置き場)への参照の組 だという点にあります。関数の中で参照されるが、その関数自身では宣言していない変数を 自由変数(free variable) と呼びます。クロージャが捕捉しなければならないのは、まさにこの自由変数群です。

問題は変数の寿命です。通常、関数のローカル変数はコールスタック上のフレームに置かれ、関数が戻ればフレームごと巻き戻されて消えます。ところがクロージャは、生成元の関数がとうに戻った後も自由変数を参照し続けます。スタックに置いたままでは破棄済みの領域を指してしまう。つまり 捕捉された変数だけは、スタックの寿命を超えて生き延びさせる必要がある——これが実装上の核心です。

ヒープへの昇格(環境のボックス化)

解決策は、捕捉される変数をスタックフレームに置かず、ヒープ上に確保したセル(box)へ昇格させることです。コンパイラは「この変数はクロージャに捕捉される」と静的に判定し、その変数の実体をヒープに移します。ローカル変数のスロットはそのセルへの参照になり、生成されたクロージャも同じセルを指します。こうすれば生成元が戻ってもセルは生き続け、ガベージコレクション が「もう誰も参照していない」と判断するまで回収されません。クロージャの寿命管理が GC に乗るのはこのためです。

このセルを介す間接参照こそ、複数のクロージャが同じ変数を 共有 できる理由です。同じセルを2つのクロージャが指していれば、一方が書き換えた値をもう一方が読みます。ポインタと参照 でいう共有参照そのものが、捕捉変数の実体になっているわけです。

捕捉される変数だけが昇格する

すべてのローカル変数をヒープに置くのは無駄です。実用処理系は エスケープ解析 を行い、クロージャに捕捉される(=関数の外へ逃げる)変数だけをヒープへ昇格させ、逃げない変数は従来どおりスタックに残します。捕捉が一切なければヒープ確保はゼロで、クロージャは普通の関数と同じ速度になります。

アップバリューと open / closed の二段構え

「常にヒープへ昇格」は単純ですが、捕捉されない大多数の変数まで巻き込むと無駄が出ます。Lua の処理系が採る アップバリュー(upvalue) 方式は、この遅延確保を洗練させた代表例です。アップバリューとは、内側の関数から見た外側のローカル変数への間接参照です。

要点は二状態を持つことです。捕捉対象の変数がまだ生成元のスタックフレーム上に生きている間、アップバリューは open 状態で、スタックスロットを直接指します。生成元の関数がそのスコープを抜ける瞬間、ランタイムはアップバリューを close し、変数の現在値をアップバリュー自身の内部へコピーして退避させ、参照先をそこへ切り替えます。

状態参照先いつコスト
openスタック上のスロット生成元がまだ生きている間確保ゼロ・スタックを直接読む
closed退避先(ヒープ相当)生成元のスコープを抜けた後close 時に値を1回コピー

この設計の利点は、変数がスタック上にある限りヒープ確保を先延ばしにでき、しかも同じ open アップバリューを複数のクロージャが共有すれば書き換えが互いに見える点です。VM は「どのスタックスロットがどの open アップバリューに対応するか」のリストを持ち、スコープ離脱時に該当分だけまとめて close します。VMとバイトコード実行 の文脈では、GETUPVAL / SETUPVAL といった専用命令でアップバリューを読み書きします。

クロージャ変換:コンパイラ側の書き換え

ランタイムの仕掛けを支えているのが、コンパイラ段での クロージャ変換(closure conversion) です。自由変数を持つネストした関数は、そのままでは「外側の文脈」に依存していて単独で呼べません。クロージャ変換は、自由変数を 環境(environment)という構造体に集約 し、関数を「環境を明示的な引数として受け取る、自由変数を持たない素の関数」へ書き換えます。よく似た技法に ラムダリフティング(lambda lifting) がありますが、こちらは自由変数を環境構造体ではなく追加の引数へ展開してトップレベル関数へ持ち上げる点が異なります。

// 変換前(f は自由変数 base を捕捉している)
function makeAdder(base):
  function f(x): return x + base   // base は自由変数
  return f

// 変換後(環境 env を明示化)
function f_lifted(env, x): return x + env.base
function makeAdder(base):
  env = heap_alloc({ base: base })   // 捕捉変数を環境へ
  return Closure(code = f_lifted, env = env)  // コードと環境の組

変換後のクロージャ値は「f_lifted という関数ポインタ」と「env への参照」のペアになります。呼び出しは closure.code(closure.env, 引数...) という形に展開され、自由変数アクセスはすべて env.フィールド への参照に置き換わります。これが冒頭の「コード+環境の組」の正体です。実装によっては、必要な自由変数だけを詰めた フラットな環境 を作る方式と、外側の環境へのポインタを繋ぐ 連結リスト的な環境 を作る方式があり、前者はアクセスが速く、後者は確保が軽いというトレードオフがあります。

値で捕捉するか、参照で捕捉するか

最後に意味論上の分岐点を押さえます。捕捉が 参照(by reference) なら、クロージャはセルを共有するので、生成後に外側で変数を書き換えるとクロージャから見える値も変わります。JavaScript や Lua のクロージャはこちらで、これが クロージャとスコープ のループ変数共有の罠の正体です——全クロージャが同一セルを指すため、ループ終了後の最終値を一斉に読みます。

一方 値(by value) での捕捉は、生成時点の値を環境へコピーします。C++ のラムダの [=] は明示的にこの系統で、捕捉後に元を変えてもクロージャの値は固定です。Java は「捕捉変数は事実上 final(再代入不可)」という制約を課すため、参照捕捉でありながら値捕捉と区別がつかなくなります。なお C# は変数自体をヒープへ持ち上げる参照捕捉で、捕捉変数の再代入を許す点では JavaScript 側に属します。

試験・面接での頻出ポイント

「クロージャはなぜ生成元の関数が戻っても変数を覚えていられるか」と問われたら、捕捉変数がヒープ上のセルへ昇格し、クロージャがそれを参照するため GC されず延命される と答えます。「ループ内クロージャが全部同じ値になる」のは 参照捕捉で単一セルを共有するから、ブロックスコープ(反復ごとの新規束縛)で各反復に別セルを割り当てれば直る、という因果まで言えると万全です。

捕捉が招くメモリリーク

クロージャは環境を生かし続けるため、巨大なオブジェクトを1つでも捕捉すると、クロージャが生存する限りそのオブジェクト全体が回収されません。イベントハンドラやキャッシュにクロージャを長期保持する設計では、不要になった参照を意図的に切らないと、捕捉経由でメモリが積み上がります。同じ捕捉の仕組みは、関数を中断・再開する async/awaitとコルーチン でローカル変数をスタック外へ退避する処理とも地続きです。

まとめ

クロージャの実体は コード(関数ポインタ)と捕捉した環境への参照の組 です。捕捉される変数はスタックの寿命を超えるため、エスケープ解析で見抜かれて ヒープ上のセルへ昇格 し、クロージャの生存中は GC が延命します。Lua のアップバリューは open / closed の二段構え で確保を遅延しつつ共有を保ち、コンパイラ側の クロージャ変換 が自由変数を環境構造体へ集約して関数を環境引数つきの素の関数へ書き換えます。捕捉が参照か値かで挙動が変わり、参照捕捉の共有セルがループ変数の罠を生み、捕捉の延命作用がメモリリークの温床にもなる——内部実装まで降りれば、これらは一つの原理の表と裏として一望できます。

プログラミング Article

クロージャの実装原理(環境キャプチャとアップバリュー)を実務で読む

TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。

解決すること

クロージャ

比較で見る軸

難易度: advanced / カテゴリ: プログラミング / タグ数: 5

導入後に効く点

Lua のアップバリューは変数がスタック上の間は open、関数が抜けると close してヒープへ退避する二段構えで、共有と寿命を両立します。

先に潰すリスク

用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。

数字・仕様の読み方
難易度
advanced
カテゴリ
プログラミング
タグ数
5

判断チェックリスト

  • 自社の用途が「クロージャ / アップバリュー」に近いか確認する。
  • 強みである「クロージャは「コード(関数ポインタ)+捕捉した環境への参照」の組で、捕捉変数はスタックではなくヒープ上のセルへ昇格させて関数の寿命まで延命します。」が本当に評価軸になるか確認する。
  • 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
  • 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
  • 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

クロージャアップバリューコンパイラヒープランタイムクロージャアップバリューコンパイラ