ASTから読み解く巻き上げの優先順位:関数宣言と変数宣言の衝突の真実
JavaScriptのエンジン――特にV8をはじめとするモダンなJITランタイムは、私たちが書いたテキストとしてのコードを、文字通りそのまま実行しているわけではない。ソースコードはパーサーによって字句解析(Lexical Analysis)および構文解析(Syntactic Analysis)され、AST(抽象構文木:Abstract Syntax Tree)へと変換される。
「巻き上げ(Hoisting)」という言葉は、ジュニアエンジニア向けの入門書では「変数の宣言がスコープの先頭に引き上げられる現象」と魔術的に説明されがちだ。しかし、シニアエンジニアやランタイムの挙動を深く理解する者にとって、これは魔術でも何でもない。ASTの構築フェーズ(Creation Phase)とコードの実行フェーズ(Execution Phase)の分離、そしてスコープ(Variable Environment / Lexical Environment)へのバインディング登録順序の必然的な結果に過ぎない。
本稿では、ASTの視点から「関数宣言」と「変数宣言」が衝突した際に、V8エンジン内部のスコープがどのような優先順位でバインディングを解決しているのかを、低レイヤのメモリ管理とあわせて完全に解剖する。
—
1. パーサーの視点:AST構築と巻き上げのメカニズム
V8のIgnition(インタープリター)やSparkplug、TurboFan(コンパイラ)へコードが渡る前段階として、Parserはソースコードを走査し、スコープ構造を決定する。
JavaScriptの実行コンテキスト(Execution Context)が生成される時、V8はコードを実行する前に「作成フェーズ(Creation Phase)」を経る。このフェーズで以下の処理が厳密な順序で行われる。
1. 関数宣言(Function Declaration)の走査と登録:識別子に対する関数オブジェクトの参照が、スコープの環境レコード(Environment Record)に直接バインドされる。
2. 変数宣言(`var`)の走査と登録:識別子が環境レコードに登録され、初期値として `undefined` が割り当てられる。
3. レキシカル宣言(`let` / `const`)の走査と登録:識別子は登録されるものの、値の初期化は行われない。これが、いわゆるTDZ(Temporal Dead Zone:一時的死領域)を生み出す物理的な理由である。
ここで重要なのは、「関数宣言は、同名の `var` 変数宣言よりも常に高い優先度でスコープを占有する」という事実だ。ASTのレベルで同一識別子に対するエントリがどのように扱われるのか、具体的なコードで確認しよう。
// — 思考実験:関数宣言とvarの衝突 —
console.log(typeof target); // 出力はどうなるか?
var target = “変数としての初期化”;
function target() {
return “関数としての定義”;
}
console.log(typeof target); // 再代入後の出力は?
上記のコードを実行した際、多くの開発者は「`var target` によって `undefined` が最初に出力され、その後に `“変数としての初期化”` が来るのではないか」と誤解する。しかし、実際の出力は以下のようになる。
function
string
なぜ、1回目の `typeof target` で `“function”` が返るのか? それは、作成フェーズにおけるバインディングの上書き順序に起因する。
—
2. バインディングの優先順位とV8ヒープの挙動
スコープの環境レコード(Variable Environment)に識別子が登録される際、V8内部のアルゴリズムは次のように動作する。
1. パーサーが `function target() {}` を検出すると、環境レコードに `target` キーを作成し、ヒープ上に確保された関数オブジェクトへのポインタを即座に割り当てる。
2. その後、パーサーは `var target` を検出する。しかし、すでに環境レコード内に同名の識別子 `target` が存在し、それが「関数宣言」によって生成されたものである場合、`var` による再宣言は既存の関数バインディングを上書き(あるいは無視)する。
3. 結果として、作成フェーズ完了時点での `target` の実体は、`undefined` ではなく関数オブジェクトそのものとなる。
これが、関数宣言が変数宣言よりも「優先される」メカニズムの正体である。
しかし、実行フェーズ(Execution Phase)に入ると状況が変わる。コードの実行フローが上から下へと進むにつれ、実際の代入文(Assignment Expression)に到達した時点で評価が走る。
var target = “変数としての初期化”;
// ↑ この文に実行スレッドが到達した瞬間、環境レコード内の ‘target’ の値は
// 関数オブジェクトへの参照から、文字列プリミティブへの参照へと書き換わる。
そのため、2回目の `typeof target` では、すでに変数が代入された後であるため `”string”` が出力されるのだ。
—
3. ASTの構造から見る「変数の再宣言」の非対称性
次に、`let` や `const` が登場したモダンなスコープにおけるASTの挙動を見てみよう。`var` は関数スコープを持ち、巻き上げ時に `undefined` で初期化されるが、`let` および `const` はブロックスコープを持ち、レキシカル環境(Lexical Environment)に属する。
// — 例外を投げる衝突ケース —
let a = 10;
function foo() {
console.log(a); // ここで何が起きるか?
let a = 20;
}
foo();
このコードを実行すると、`console.log(a)` で値が外側のスコープの `10` を参照するのではなく、`ReferenceError: Cannot access ‘a’ before initialization` がスローされる。
なぜこのエラーが発生するのか?(TDZの本質)
パーサーが関数 `foo` の内部を構文解析する際、ブロック内に `let a` が存在することを検知する。これにより、ブロックローカルなレキシカル環境に識別子 `a` が登録される。
ここで重要なのは、「`let` や `const` の宣言は、巻き上げは行われるが、初期化は行われない」という点だ。
作成フェーズにおいて、識別子 `a` は「未初期化(Uninitialized)」というメタ状態としてマークされる。この未初期化状態のメモリスロットにアクセスしようと試みた瞬間、V8のランタイムガードが発動し、強制的に `ReferenceError` をスローする。これがTDZの物理的な正体である。
外側に同名の `a = 10` が存在していこうとも、ブロックスコープ内に同名の `let a` が宣言されている時点で、そのブロック全体(宣言の物理的な行に到達するまで)が `a` のTDZとなる。パーサーはASTの段階ですでにこのスコープの遮断を決定しているため、外側の変数を透過的に参照することはできない。
—
4. セキュリティ・コンテキストにおけるリスク:スコープ汚染と巻き上げの悪用
この変数の巻き上げやスコープの仕様、そしてプロトタイプチェーンの仕組みを悪用した攻撃手法が、いわゆるプロトタイプ汚染(Prototype Pollution)や、悪意あるコードインジェクションである。
特に、非同期処理やコールバック地獄のなかで変数名や関数名が意図せず衝突(Shadowing)した場合、予期せぬスコープのハイジャックが発生し、セキュリティ上の脆弱性(サプライチェーン攻撃等)に直結する。
以下の悪質なコードパターンを見てほしい。
// — 危険な識別子衝突の模倣 —
let config = {
isAdmin: false
};
function processUser(inputConfig) {
// 意図しない変数巻き上げとスコープのシャドーイング
if (inputConfig.isAdmin) {
var isAdmin = true; // 関数スコープとして巻き上げられる
console.log(“管理者権限で実行中…”);
}
// 開発者がグローバルまたは外側の config を参照しているつもりが…
if (isAdmin) { // ← ここで var の巻き上げにより、inputConfig.isAdmin が false でも未定義エラーにならずに評価される!
runSuperuserOperations();
}
}
`var` の持つ「関数スコープ全体への巻き上げ」と「同一識別子の許容(重複宣言のエラーが出ない)」という仕様は、大規模なコードベースにおいて意図しないバインディングの共有を引き起こす。脆弱性診断の現場において、古いコードベースに残された `var` の乱用は、ロジックのバイパスを許す致命的なアタックサーフェイス(攻撃表面)となり得る。
—
5. チーフアーキテクトからの提言:モダンランタイムを支配するコード設計
JavaScriptランタイムを極限まで最適化させ、V8のインラインキャッシュ(Inline Caches: ICs)や隠しクラス(Hidden Classes / Maps)の恩恵を最大限に受けるための鉄則を提示する。
1. `var` の完全な排除:
現代のJavaScriptにおいて `var` を使用する正当な理由はもはや存在しない。すべての変数宣言は `const` をデフォルトとし、再代入が必要な場合のみ `let` を使用すること。これにより、巻き上げによる意図しない `undefined` の混入や、スコープ衝突によるバグをASTの段階でコンパイルエラー(SyntaxError / ReferenceError)として検知できるようになる。
2. スコープの最小化と巻き上げの意識:
関数宣言はコードのどこに書いても動作する(ホイストされる)が、可読性とランタイムの挙動の予測可能性を担保するため、関数は必ず「使用する前」に宣言するフローを徹底すべきである。
3. 厳格モード(`use strict`)の強制:
現代の環境ではデフォルトで有効化されているが、暗黙的なグローバル変数の生成や、不適切な巻き上げ挙動を抑制するためにも、すべてのモジュールおよびスクリプトで厳格モードが維持されていることを確認すること。
コードは単なるテキストではない。それはV8エンジンに指令を与えるための厳密な物理的設計図である。ASTがどのように構築され、ランタイムのメモリ空間にどのようにバインディングが展開されるのかを常に脳内でトレースできる者だけが、真に堅牢で高速なアプリケーションを構築できる。