【テクニカル・上級編】関数宣言の巻き上げと変数宣言の巻き上げ:AST(抽象構文木)生成フェーズにおける優先順位の決定プロセス – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

関数宣言の巻き上げと変数宣言の巻き上げ:AST生成フェーズにおける優先順位の決定プロセス

JavaScriptエンジン、特にV8の内部構造やECMAScript仕様の深淵に踏み込むとき、私たちは単なる「スクリプト言語の挙動」の枠を超え、高度な仮想マシン(VM)のパイプラインと向き合うことになる。

世の中の入門書は「変数はコードの先頭に巻き上げられる(Hoisting)」という定型文でこの現象を片付ける。しかし、シニアエンジニアやランタイムの挙動に最適化を施すアーキテクトにとって、この説明はあまりに表面的であり、害悪ですらある。

実際のJavaScriptエンジン(V8、SpiderMonkey、JavaScriptCoreなど)において、「巻き上げ」という物理的なコードの移動は一切起きていない。これはAST(抽象構文木)の生成フェーズと、それに続くスコープ解析(Scope Analysis)の過程で、Environment Record(環境レコード)へのバインディング登録順序と初期化フェーズが厳密に制御されている結果に過ぎない。

本稿では、パーサーがソースコードを読み込み、ASTを構築し、いかにして実行コンテキスト(Execution Context)のEnvironment Recordへ識別子を登録していくのか、その決定プロセスをランタイムの低レイヤ視点から解き明かす。

—

1. パースフェーズとAST構築における巻き上げの正体

JavaScriptのソースコードがV8に投入された瞬間、スキャナー(Lexical Analyzer)がソースをトークンに分解し、パーサー(Parser)がそれを元にAST(抽象構文木)を構築する。このパースの段階で、すでに識別子の運命は決まっている。

V8のパーサーは、スコープを決定するためにソースコード全体を一度スキャンする(Pre-parserおよびFull Parser)。このとき、関数宣言(Function Declaration)と変数宣言(`var`, `let`, `const`)は、それぞれ異なる戦略でEnvironment Recordにバインディング(Binding)を作成する。

Environment Recordの階層構造

ECMAScript仕様において、スコープは `Environment Record` という抽象的な概念によって管理されている。

1. Declarative Environment Record: 変数、定数、関数、クラス、インポートなどを管理する。
2. Object Environment Record: グローバルスコープや `with` 文のコンテキストなど、オブジェクトのプロパティとバインディングを直接マッピングする。
3. Function Environment Record: 関数の呼び出しに伴い生成され、`this` や `super`、`arguments` を保持する。

巻き上げの挙動の差異は、「いつ識別子が作成され、いつ初期化(Initialization)されるか」のタイムラグに起因する。

—

2. 識別子登録の優先順位:関数宣言 vs 変数宣言

ASTの構築とスコープ解析のフェーズにおいて、関数宣言と変数宣言が同一スコープ内に存在する場合、V8のパーサーおよびスコープアナライザーは厳密な優先順位に従ってバインディングを構築する。

以下のコードスニペットの挙動を、ランタイムの視点から脳内トレースしてみよう。

// 【実験コード】同一スコープ内における関数宣言と変数宣言の競合
console.log(typeof foo); // 出力結果は何か?

var foo = ‘variable’;

function foo() {
return ‘function’;
}

console.log(typeof foo); // 出力結果は何か?

ランタイムの内部挙動の全貌

1. Instantiation(生成フェーズ):
関数宣言 `function foo() {}` は、パース時にその識別子 `foo` を `Declarative Environment Record` に登録し、即座に対応する関数オブジェクトへのポインタで初期化する。

2. Variable Declaration(変数宣言フェーズ):
`var foo = ‘variable’` のパース時、識別子 `foo` も同じスコープの Environment Record に登録される。しかし、`var` の場合、すでに同名の識別子(関数宣言によって登録されたもの)がレコードに存在する場合、既存のバインディングを上書き(無視)する。つまり、変数宣言による初期化前の `undefined` による上書きは、すでにある関数バインディングを壊さない。

3. Execution(実行フェーズ):

  • 1回目の `typeof foo` では、すでに `foo` は関数オブジェクトを指しているため `”function”` が返る。
  • 実行フェーズで `foo = ‘variable’` が評価された瞬間、Environment Record内の `foo` の参照先が文字列プリミティブ `’variable’` に書き換わる。
  • 2回目の `typeof foo` では `”string”` が返る。

これをASTレベルで視覚化すると、以下のようになる。

[Global Scope Environment Record]
├── Binding: “foo” ──> [Function Object: foo()] (関数宣言により優先的に初期化)

実行フェーズの代入文 `foo = ‘variable’` に到達した時点で、このポインタが書き換わる。

—

3. `let` / `const` とTemporal Dead Zone (TDZ) の物理的メカニズム

ES2015(ES6)で導入された `let` および `const` は、`var` のような「巻き上げによる `undefined` の初期化」を行わない。これこそが Temporal Dead Zone(一時的死地:TDZ) の正体である。

{
// — ここからTDZの開始 —
// console.log(bar); // ReferenceError: Cannot access ‘bar’ before initialization

let bar = 42; // — ここでTDZが終了し、バインディングが初期化される —
console.log(bar); // 42
}

V8内部におけるTDZの管理

`let` や `const` の識別子も、実はスコープの先頭でAST解析時に Environment Recordへの登録(Instantiation)は行われている。そのため、JSエンジンは「このスコープに `bar` という変数が存在すること」をすでに知っている。

しかし、`var` と決定的に異なるのは、「Uninitialized(未初期化)」というメタ状態フラグが立てられている点である。

  • `var`: 登録時に自動的に `undefined`(プリミティブ)で初期化される。
  • `let` / `const`: 登録時は単なる「存在の認知(Uninitialized)」に留まり、実際のソースコード上の宣言文(Initialization statement)のバイトコード(例: `LdaImmutableCurrentContextSlot` や `StaCurrentContextSlot` など)が実行されるまで、メモリアドレスへのアクセスがランタイムレベルで厳格にブロックされる。

このガードを突破しようとすると、V8の実行系は即座に `ReferenceError` をスローする。これはセキュリティやバグの早期発見において非常に堅牢な設計である。

—

4. プロトタイプ汚染(Prototype Pollution)とサプライチェーン攻撃への応用

ここまでのスコープ解決とオブジェクトの内部構造(隠しクラス / Hidden Classes / Shapes)に関する知識を悪用、あるいは防御する文脈として、近年のWebセキュリティにおける重大な脅威であるプロトタイプ汚染(Prototype Pollution)を取り上げる。

アタッカーは、ASTの巻き上げやスコープチェーンの隙間ではなく、V8のヒープメモリ上に存在するオブジェクトのプロトタイプチェーンを動的に書き換えることで、アプリケーション全体の制御を奪おうとする。

脆弱な再帰的マージ関数の例

多くのNode.jsライブラリ(古いバージョン)に存在した脆弱なDeep Merge(オブジェクトの結合)処理を模したコードを見てみよう。

// 脆弱なマージ実装(サプライチェーンを狙うターゲットになりやすい)
function maliciousMerge(target, source) {
for (let key in source) {
if (typeof source[key] === ‘object’ && source[key] !== null) {
if (!target[key]) target[key] = {};
maliciousMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}

// 攻撃者が外部入力(JSONパース結果など)経由で送信するペイロード
const payload = JSON.parse(‘{“__proto__”: {“isAdmin”: true}}’);

const safeObject = {};
maliciousMerge(safeObject, payload);

// なんと、アプリケーション内のすべてのオブジェクトが汚染される
console.log({}.isAdmin); // true (RCEや権限昇格のトリガーとなる)

V8のヒープ最適化を狂わせる仕組み

V8は、プロパティの追加順序が同じオブジェクトに対して「隠しクラス(Map)」を共有させ、インラインキャッシュ(Inline Caches: IC)を用いてプロパティアクセスの高速化(O(1)に近いアクセス)を実現している。

しかし、`__proto__` を通じて `Object.prototype` 自体が書き換えられると、V8のプロパティ検索アルゴリズムの前提が崩壊する。
アプリケーションの至る所で「プロパティが存在しない場合のフォールバック挙動」が変わり、予期せぬコードパス(例:管理者権限チェックのバイパスなど)が誘発される。これがサプライチェーン攻撃におけるRCE(リモートコード実行)の足がかりとなる。

防御の要諦:ランタイムの硬化(Hardening)

1. プロトタイプの凍結(Object.freeze):
アプリケーションの起動時や入力検証の前に、ビルトインオブジェクトのプロトタイプを凍結する。

Object.freeze(Object.prototype);
Object.freeze(Array.prototype);

2. Nullプロトタイプオブジェクトの活用:
ハッシュマップ(辞書型オブジェクト)としてオブジェクトを使用する場合は、プロトタイプチェーンを持たないオブジェクトを生成する。

const map = Object.create(null); // __proto__ が存在しない

3. 安全なパーサーの利用と入力のサニタイジング:
JSON.parseのreviver引数や、ajv等のスキーマバリデーターを用いて、キー名に `__proto__`, `constructor`, `prototype` が含まれる入力を完全に拒絶する。

—

5. まとめ:ランタイムを見据えたコード設計

JavaScriptの「巻き上げ」や「スコープ」は、決して魔法のような現象ではない。すべてはASTの構築、Environment Recordへのバインディング登録順序、そしてV8エンジンによるメモリ上の状態管理(TDZのフラグ制御など)という厳密な物理法則に支配されている。

私たちが書く1行のコードが、V8のパーサーをどう刺激し、どのようなバイトコードにコンパイルされ、ヒープ上の隠しクラスやオブジェクトモデルにどう影響を与えるか。その解像度を上げることこそが、真に堅牢で高速なモダンWebアプリケーションを構築するための唯一の道である。

次世代の言語仕様がどのように進化しようとも、ランタイムの底流にある「コンパイルと実行の分離」という原則が変わることはない。常にコードの裏側にあるエンジン1つひとつの挙動に思いを馳せ、優雅で隙のないアーキテクチャを構築し続けよう。

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