【テクニカル・上級編】varの巻き上げをAST解析から紐解く:コンパイルフェーズで何が起きているのか – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

varの巻き上げをAST解析から紐解く:V8コンパイルフェーズとEnvironment Recordの物理実態

JavaScriptエンジニアであれば、`var`の巻き上げ(Hoisting)という現象を知らない者はいない。しかし、「なぜ初期化前にアクセスしても`ReferenceError`ではなく`undefined`になるのか」「この挙動は言語仕様(ECMAScript)とV8エンジンの内部構造において、具体的にどのようなステップで発生しているのか」を正確に説明できる者は一握りである。

本稿では、一般的な解説書にあるような「変数がスコープの先頭に引き上げられる」という幻想を破壊する。抽象構文木(AST)の生成から、V8におけるコンパイルフェーズ、そして`Environment Record`へのバインディング登録に至るまで、ランタイムの深層をコードと理論で丸裸にする。

—

1. 構文解析(Parsing)とASTにおけるVariableDeclarationの正体

JavaScriptエンジン(V8)に渡されたソースコード文字列は、まずBabelやTypeScriptのトランスパイラを通さずとも、V8自身のパーサによって字句解析(Lexical Analysis)および構文解析(Syntactic Analysis)を受け、AST(抽象構文木)へと変換される。

ここで重要なのは、「巻き上げ」という現象はコードが物理的に移動しているわけではないという点だ。パーサがソースコードを走査し、ASTを構築する段階で、スコープ内に存在する変数宣言を静的に検出し、紐解くべきスコープのメタデータとして記録しているに過ぎない。

以下のコードをV8のパーサがどのように解釈するか、ASTの構造を脳内(あるいはASTexplorer等のツール)でイメージしてほしい。

console.log(hoistedVar); // 出力: undefined
var hoistedVar = 42;
console.log(hoistedVar); // 出力: 42

ASTレベルにおいて、`var hoistedVar = 42;`という文は、大きく分けて2つのノードに分解される。
1. `VariableDeclaration`(変数宣言ノード:`var hoistedVar`)
2. `AssignmentExpression`(代入式ノード:`hoistedVar = 42`)

V8のコンパイルパイプラインにおいて、スコープ解析(Scope Analysis)フェーズが走る際、パーサはこの`VariableDeclaration`を見つけると、その変数が属する関数スコープまたはグローバルスコープのVariableMap(変数マップ)に対して、識別子(Identifier)を事前に登録する。これが「巻き上げの物理的トリガー」の正体である。

—

2. コンパイルフェーズ:Environment Recordへのバインディング登録

ASTが生成された後、Ignition(バイトコードインタプリタ)へ渡る前のバイトコード生成(Bytecode Generation)フェーズにおいて、スコープの実行コンテキスト(Execution Context)が構築される。

ECMAScript仕様において、変数のスコープ管理は Lexical Environment(語彙的環境) と Variable Environment(変数環境) という概念で定義されており、その実体が Environment Record(環境レコード) である。

`var`宣言と`let`/`const`宣言の本質的な違いは、このEnvironment Recordへの登録フェーズにおける初期化のセマンティクスにある。

FunctionEnvironmentRecord と DeclarativeEnvironmentRecord の挙動差

1. `var`宣言(Function Environment / Global Environment)

  • コンパイルフェーズ(Instantiation Phase)において、識別子がEnvironment Recordに作成されると同時に、値として `undefined` がバインド(初期化)される。
  • そのため、コードの実行フェーズ(Execution Phase)で宣言文に到達する前にアクセスしても、すでにバインディングが存在し、かつ`undefined`が入っているため、エラーにならずそのまま値が取得できる。

2. `let` / `const`宣言(Declarative Environment)

  • コンパイルフェーズで識別子は登録されるものの、初期化は行われない(Uninitialized状態)。
  • この初期化される前のアクセス禁止領域が、いわゆる Temporal Dead Zone(TDZ:一時的死活領域) であり、この状態でアクセスするとV8は `ReferenceError` を送出する。

この挙動の違いを、V8の内部挙動を模したNode.js上のコードと、メモリ上の振る舞いとして確認する。

‘use strict’;

function evaluateHoistingInternals() {
// コンパイル/スコープ生成フェーズで、このスコープのEnvironment Recordに
// ‘leakedVar’ が var として登録され、即座に undefined で初期化される。

console.log(leakedVar); // => undefined (ReferenceErrorではない)

if (false) {
var leakedVar = ‘V8 Internal Secret’;
}

console.log(leakedVar); // => undefined (if文のブロックに入っていないため代入は走らない)
}

evaluateHoistingInternals();

上記のコードで特筆すべきは、`if (false)` の中にある `var leakedVar` さえも、スコープ全体のコンパイルフェーズで巻き上げの対象になるという点だ。ブロックレベルスコープを持たない `var` は、関数スコープ(あるいはグローバルスコープ)全体を巻き込み、Environment Recordの変数マップを汚染する。これが、大規模コードベースにおいて `var` がバグの温床となるアーキテクチャ上の理由である。

—

3. V8のメモリ管理と「隠しクラス(Hidden Classes / Maps)」の観点

ここまでASTとEnvironment Recordの観点から解説したが、ではこの巻き上げられた変数やスコープ内の変数は、V8のヒープメモリ上でどのように表現されているのだろうか?

JavaScriptのオブジェクトは動的なハッシュマップではなく、V8エンジン内では Hidden Classes(V8内部用語では `Map` と呼ばれる) と呼ばれる構造体によって、C++の構造体に近いオフセットアクセスに最適化されている。

しかし、スコープ内のローカル変数や、クロージャによってキャプチャされる変数は、オブジェクトのプロパティというよりも、Context(コンテキストオブジェクト)と呼ばれるヒープ上の領域、あるいはスタックフレーム上に配置される。

クロージャとContext Allocation

`var`で宣言された変数が関数スコープを越えて保持される場合、V8はその変数をスタック上ではなく、ヒープ上の `Context` に割り当てる(Context Allocation)。

function createCounter() {
var count = 0; // この var 変数は Context に昇格する
return {
increment: function() {
count++;
return count;
},
decrement: function() {
count–;
return count;
}
};
}

const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2

V8のコンパイラ(Ignition / TurboFan)は、AST解析の段階で「どの変数がクロージャから参照されているか(Context-allocated)」を静的に解析する。`var`であれ`let`であれ、このスコープ解析の仕組み自体はモダンなV8において高度に最適化されているが、`var`の関数スコープ(ブロックスコープを無視する性質)は、不要な変数を長期間ヒープ上に生存させ、ガベージコレクション(GC)の効率を低下させるメモリリークの原因になり得る。

—

4. セキュリティ・サプライチェーンの文脈:変数巻き上げとプロトタイプ汚染・スコープハイジャック

シニアエンジニアやセキュリティ研究者にとって、この「巻き上げ」や「Environment Recordの挙動」は、単なる言語仕様のトリビアではなく、セキュリティ脆弱性(RCEやプロトタイプ汚染)をアナライズ・爆破するための重要知見となる。

依存関係(NPMパッケージなど)のサプライチェーン攻撃において、悪意のあるコードがグローバルスコープや共通モジュールのスコープを汚染する際、`var`の特性や巻き上げの隙を突いた変数シャドウイング(Variable Shadowing)が利用されることがある。

特に、非厳格モード(Non-strict mode)や古いNode.js環境、あるいは古いビルドシステムを通したコードでは、意図しないグローバル変数の漏出(Implication Global)が発生する。

// 脆弱なレガシーコードのパターン
function processUserPayload(payload) {
// var を使った不適切な再宣言や、巻き上げによる既存スコープ変数の上書き
var config = parsePayload(payload);

if (config.isAdmin) {
// 意図しないグローバル汚染を引き起こす可能性のある記述
authContext = ‘SUPER_ADMIN’; // var を付け忘れるとグローバルオブジェクトに生える
}
}

もし、このようなコードが非厳格モードで実行され、かつスコープの巻き上げや暗黙のグローバル変数の生成(Implicit Globals)が組み合わさると、攻撃者は実行コンテキストのグローバルオブジェクト(Node.jsなら `global`、ブラウザなら `window`)を書き換え、プロトタイプチェーンを介したリモートコード実行(RCE)の足がかりを作ることが可能になる。

モダンなV8環境(`’use strict’;` がデフォルトのESモジュールやクラスベースのアーキテクチャ)では、このような暗黙のグローバル生成は `ReferenceError` によって即座に遮断される。しかし、レガシーなサードパーティ製ライブラリを読み込むモノリシックなNode.jsアプリケーションでは、依然としてこのランタイムの防壁を理解し、防御を固める必要がある。

—

5. チーフアーキテクトからの提言:ランタイム防壁の構築

今日のフロントエンドおよびNode.jsバックエンド開発において、`var`を使用する正当な理由は1ミリも存在しない。
BabelやTypeScriptといったトランスパイラが存在しようとも、ブラウザやNode.jsのネイティブランタイム(V8)が解釈する際のAST解析コストやEnvironment Recordの複雑性を増やし、ヒープ上のContext Allocationに悪影響を与えるコードを書くべきではない。

1. 常に `’use strict’;`(またはES Modules)を強制する
暗黙のグローバル変数生成や、緩いスコープバインディングをコンパイルエラーとして弾く。
2. `let` と `const` によるTDZ(一時的死活領域)の活用
変数は宣言より前には絶対にアクセスできないという厳格なランタイムの制約を利用し、ロジックの破綻や巻き上げに起因するバグをコンパイル・実行の初期段階で検知する。
3. ASTとV8の挙動を脳内トレースする習慣
コードを書く際、「この記述はV8のパーサにどう読まれ、Environment Recordのどのマップにどのようにバインドされるか」を意識することで、パフォーマンスと堅牢性を極限まで高めたシステムアーキテクチャを実現できる。

JavaScriptは、もはや単なる「おもちゃのスクリプト言語」ではない。V8という世界最高峰のJITコンパイラエンジン上で動く、極めて高度な仮想マシンの上で我々はコードを走らせているのだ。そのランタイムの鼓動を支配できぬ者に、真にスケーラブルでセキュアなアプリケーションを設計することはできない。

タイトルとURLをコピーしました