スコープチェーンとクロージャの内部表現(環境レコード)
クロージャが変数を覚えている理由を内部から説明できるようになる。レキシカル環境・環境レコード・外部参照のチェーン構造から、捕捉と保持の仕組みを原理で解説します。
- 1.実行中のスコープはレキシカル環境(Lexical Environment)で表され、変数を保持する環境レコードと、外側を指す外部参照([[OuterEnv]])の対で構成される。
- 2.変数解決は現在の環境レコードから外部参照をたどって外へ向かう探索=スコープチェーンで、ネスト構造ではなく定義位置(レキシカル)で決まる。
- 3.クロージャは関数オブジェクトが生成時の外部環境への参照([[Environment]])を持つこと。捕捉とは値のコピーではなく環境レコードへの参照保持で、GC到達可能なまま残る。
なぜ環境レコードを知るのか
クロージャは「関数が外側の変数を覚えている仕組み」と説明されがちですが、これでは「いつ何が捕捉され、いつまで生き残るのか」を正確に予測できません。ループ内のvarが最後の値だけを返す、letなら各反復が別々の値を持つ、といった挙動の差も、表層の説明では腑に落ちないはずです。鍵はECMAScript仕様の内部構造で、スコープは**レキシカル環境(Lexical Environment)**という実行時データ構造で表現されます。本稿はこの環境レコードと外部参照のチェーンを土台に、変数解決とクロージャの捕捉・保持を原理から説明します。前提として JavaScript の値と関数、実行モデルは イベントループの内部動作 を押さえておくと理解が速いです。
レキシカル環境:環境レコードと外部参照の対
コードを実行するとき、エンジンは各スコープに対応するレキシカル環境を生成します。これは概念上、二つの要素の対です。
| 構成要素 | 役割 | 保持するもの |
|---|---|---|
| 環境レコード (Environment Record) | このスコープで宣言された識別子と値の対応表 | 変数・関数・引数の束縛(binding) |
| 外部参照 ([[OuterEnv]]) | 一つ外側のレキシカル環境へのポインタ | 親スコープの環境への参照1本 |
環境レコードは識別子(変数名)から値(または束縛の格納場所)へのマップです。外部参照は仕様上の内部スロット[[OuterEnv]]で、コードの定義位置で外側にあたる環境を指します。重要なのは、外部参照が「呼び出した側」ではなく「定義された場所の外側」を指す点です。これがレキシカル(静的)スコープの本質で、関数がどこから呼ばれたかではなく、ソース上どこに書かれたかでスコープが決まります。
仕様は環境レコードを用途別に分けています。関数本体の引数やvar/let/constを持つ宣言的環境レコード(Declarative)、グローバルやwithが使うオブジェクト環境レコード(Object)、関数呼び出しでthisやsuperを束縛する関数環境レコード(Function)などです。実装の詳細は異なりますが、「識別子→値の表+外側への参照」という骨格は共通です。
スコープチェーン:外へたどる識別子解決
変数を参照すると、エンジンは現在のレキシカル環境の環境レコードを調べます。見つからなければ[[OuterEnv]]をたどって一つ外側の環境を調べ、また見つからなければさらに外へ——と進みます。グローバル環境まで探して無ければReferenceErrorです。この外向きの探索経路がスコープチェーンです。
const g = "global";
function outer() {
const o = "outer";
function inner() {
const i = "inner";
return g + o + i; // i は自身の環境、o は外部参照先、g はさらに外
}
return inner();
}
innerの中でoを参照すると、innerの環境レコードには無いので[[OuterEnv]](outerの環境)を見て発見します。チェーンは一方向(内→外)で、外側から内側の変数は見えません。また探索は近い側が優先なので、内側で同名を宣言すると外側をシャドーイングします。
スコープチェーンの形は実行時のコールスタックの深さではなく、ソースコード上のネストで固定されます。innerを別の関数に渡して遠くから呼んでも、innerの[[OuterEnv]]は定義時のouterの環境を指したままです。だから「どこで呼ぶか」を変えても見える変数は変わりません。
関数オブジェクトと [[Environment]]:捕捉の正体
ここがクロージャの核心です。関数を定義(評価)した瞬間、エンジンは関数オブジェクトを作り、その内部スロット[[Environment]]にそのとき実行中のレキシカル環境への参照を格納します。これが「外側のスコープを捕捉する」の実体です。
後にその関数を呼び出すと、新しいレキシカル環境(呼び出しごとの引数やローカル変数を持つ)が作られ、その[[OuterEnv]]に[[Environment]]の値がセットされます。つまり呼び出し時のスコープチェーンの外側は、呼び出し元ではなく定義時に捕捉した環境になります。クロージャとは特別な機能ではなく、「関数オブジェクトが[[Environment]]で外部環境を持つ」という常時成り立つ性質の名前です。
クロージャが捕捉するのは変数の「その時の値」ではなく、環境レコードそのものへの参照です。だから捕捉後に外側で変数を書き換えれば、クロージャから見える値も変わります。「スナップショットを撮る」のではなく「同じ箱を共有する」と理解してください。
function counter() {
let n = 0;
return {
inc: () => ++n, // どちらのクロージャも同じ n を共有
get: () => n,
};
}
const c = counter();
c.inc(); c.inc();
c.get(); // 2 :同一の環境レコードを参照しているため
incとgetは同じcounter呼び出しの環境を捕捉するので、**同一のn**を共有します。値のコピーなら共有は起きません。共有こそが参照捕捉の証拠です。
寿命とGC:環境はいつまで残るか
関数を抜けると、その実行コンテキストはコールスタックから取り除かれます。素朴には対応するレキシカル環境も消えるはずですが、捕捉している関数が生きている限り、その環境レコードは[[Environment]]経由で到達可能なため回収されません。これがクロージャが変数を「保持」する正体です。スコープの寿命は構文ブロックの終わりではなく、到達可能性で決まります。
function makeAdder(x) {
return (y) => x + y; // x を捕捉
}
const add5 = makeAdder(5);
// makeAdder は終了済みだが、add5 が捕捉環境を生かしている
add5(3); // 8
makeAdderの実行は終わっていますが、返された関数がxを含む環境を捕捉しているので、xは生き続けます。逆に言えば、不要になった関数(イベントハンドラ、setIntervalのコールバックなど)が大きなデータを捕捉したまま残ると、その環境ごと回収されずメモリリークになります。到達可能性に基づく回収の詳細は ガベージコレクションのブラウザ内動作 を参照してください。
var と let:ループで挙動が分かれる理由
スコープと環境の観点から、有名な「ループ+クロージャ」問題が説明できます。varは関数スコープで、ループ全体でただ一つの束縛を共有します。一方letは反復ごとに新しい環境レコードを生成し、各反復が独立した束縛を持ちます。
| 宣言 | 束縛の単位 | ループでの捕捉結果 |
|---|---|---|
| var i | 関数スコープに1個 | 全クロージャが最終値を共有 |
| let i | 反復ごとに新規生成 | 各クロージャが各回の値を保持 |
const vfns = [];
for (var i = 0; i < 3; i++) vfns.push(() => i);
vfns.map((f) => f()); // [3, 3, 3] :単一の i を共有
const lfns = [];
for (let j = 0; j < 3; j++) lfns.push(() => j);
lfns.map((f) => f()); // [0, 1, 2] :反復ごとに別の j
var版は全クロージャが同じiを捕捉し、ループ終了後の値3を返します。let版は仕様上、各反復の前に前回の値を引き継いで新しい束縛を作る(CreatePerIterationEnvironment)ため、各クロージャがその回専用の環境レコードを捕捉します。捕捉が「参照の共有」だと理解していれば、この差は当然の帰結です。
TDZと巻き上げ:束縛はいつ作られるか
環境レコード上の束縛は、コードのどこで「生成」され、どこで「初期化」されるかが分かれています。varは環境生成時に束縛が作られundefinedで初期化済みになるため、宣言前に参照してもundefinedが返ります(巻き上げ)。対してlet/constは束縛こそ環境生成時に作られますが未初期化のまま置かれ、宣言文に到達して初めて初期化されます。
この「束縛は存在するが未初期化」の区間が**一時的デッドゾーン(TDZ, Temporal Dead Zone)**です。TDZ中にその識別子へアクセスするとReferenceErrorになります。
console.log(a); // undefined :var は初期化済み
var a = 1;
console.log(b); // ReferenceError :b はTDZ(束縛はあるが未初期化)
let b = 2;
TDZのエラーは「変数が存在しない」からではありません。環境レコードには既に束縛が登録されており、初期化前だから触れないという意図的な保護です。スコープチェーンの探索ではlet bが先にヒットするため、外側の同名変数を拾うこともしません。巻き上げの有無で挙動が分かれるのは、束縛の生成と初期化のタイミングが宣言の種類で違うからです。
まとめ
スコープは実行時のレキシカル環境で表され、環境レコード(識別子→値の表)と外部参照[[OuterEnv]](外側の環境へのポインタ)の対です。変数解決は環境レコードから外部参照をたどるスコープチェーンで、経路は呼び出し位置ではなく定義位置で固定されます。クロージャは特別な機能ではなく、関数オブジェクトが定義時の環境を[[Environment]]に保持する常時の性質で、捕捉は値のコピーではなく環境レコードへの参照共有です。だから複数クロージャは同じ束縛を共有し、捕捉が生きる限り環境はGCで回収されません。varとletのループ差も、束縛が関数スコープに1個か反復ごとに新規かの違いで説明でき、TDZは「束縛はあるが未初期化」の保護区間です。実行時の最適化の土台は JavaScriptエンジンの内部 と合わせて押さえると、変数アクセスの速さまで一貫して理解できます。
Web/フロントエンド Article
スコープチェーンとクロージャの内部表現(環境レコード)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
変数解決は現在の環境レコードから外部参照をたどって外へ向かう探索=スコープチェーンで、ネスト構造ではなく定義位置(レキシカル)で決まる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「JavaScript / クロージャ」に近いか確認する。
- 強みである「実行中のスコープはレキシカル環境(Lexical Environment)で表され、変数を保持する環境レコードと、外側を指す外部参照([[OuterEnv]])の対で構成される。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。