【テクニカル・上級編】関数宣言と関数式の巻き上げ優先順位:ASTから読み解く解析順序と実行時の挙動 – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

関数宣言と関数式の巻き上げ優先順位:ASTから読み解く解析順序と実行時の挙動

JavaScriptのランタイム、特にV8エンジンがソースコードをどのようにバイトコードへとコンパイルし、実行メモリ空間(V8 Heap)上でシンボルを解決しているかを真に理解しているエンジニアはそう多くない。多くの開発者は「巻き上げ(Hoisting)」という概念を、ソースコードが上方向に文字通り移動するかのような表層的な理解で済ませている。

しかし、シニアエンジニアやセキュリティ・ランタイムの深淵に挑む者にとって、巻き上げとは「抽象構文木(AST:Abstract Syntax Tree)の構築フェーズと、スコープ内におけるシンボル登録の優先順位決定アルゴリズムそのもの」である。

今回は、同一スコープ内において関数宣言、関数式、そして変数宣言が衝突した際、V8のパーサーとバイトコード生成器(Ignition)がどのようにメモリ上のEnvironment Recordを書き換えているのか、その低レイヤの真実を剥ぎ取る。

—

1. 表面的な「巻き上げ」の幻想とAST構築の現実

JavaScriptエンジンは、コードを実行する前に必ずパース(解析)フェーズを経る。この段階で、V8のパーサーは字句解析(Lexical Analysis)を行い、トークンのストリームからASTを生成する。

しばしば「関数宣言は変数宣言よりも優先される」と言われる。では、次のコード片を見てほしい。

console.log(typeof foo); // “function” と出力される

var foo = 100;

function foo() {
return ‘I am a function’;
}

console.log(typeof foo); // “number” と出力される

なぜ最初の `typeof foo` は `”undefined”` や `”number”`ではなく、`”function”` になるのか。これを理解するには、V8が関数宣言(FunctionDeclaration)と変数宣言(VariableDeclaration)をASTのどのノードとしてどのようにエンコードしているかを追う必要がある。

スコープ配下の巻き上げの優先順位アルゴリズム

V8のパーサーがスコープ(Function Scope または Global Scope)を解析する際、以下の順序でEnvironment Recordにバインディング(識別子)が登録される。

1. 関数パラメータ(Formal Parameters)の登録
2. 関数宣言(Function Declarations)の登録と初期化(関数オブジェクトの生成とヒープへの割り当てが同時に行われる)
3. 変数宣言(Variable Declarations: `var`)の登録(値は `undefined` で初期化されるが、すでに存在する同名の関数宣言バインディングは上書きされない)
4. `let` / `const` 宣言の登録(Temporal Dead Zone: TDZ に配置され、初期化は実行時フェーズまで遅延される)

つまり、コード上の記述順序がどうであれ、パース段階のシンボル解決において「関数宣言」は「変数宣言(`var`)」よりも上位の特権を持つ。変数宣言の `var foo` は、すでにスコープ内に同名の識別子(関数オブジェクトを指している)が存在する場合、その宣言自体が無視(あるいは単なる重複宣言としてスキップ)されるのだ。

—

2. V8のバイトコード生成(Ignition)とメモリ空間の挙動

上記のコードがV8のJITコンパイラによってどのようにバイトコードに変換されるか、その内部挙動を追跡する。

V8は、ソースコードをパースしてASTを作った後、Ignitionと呼ばれるRegister-basedな仮想マシンのためのバイトコードを生成する。このプロセスにおいて、関数宣言は「ホイスティング」という魔法現象を起こしているのではなく、「関数プレフィックスフェーズ(Function Declaration Instantiation)」において、スコープのエントリーポイントで一括してメモリに実体が確保されている。

以下のコードを用いて、代入文(関数式)と関数宣言が競合したときの挙動をさらに深掘りする。

// 複雑な衝突シミュレーション
diagnosticAnalyze();

function diagnosticAnalyze() {
console.log(A); // [Function: A] (変数ではなく関数宣言が勝つ)

var A = ‘overwritten by variable assignment’;

console.log(A); // “overwritten by variable assignment”

function A() {
return ‘original function’;
}

console.log(A); // “overwritten by variable assignment”
}

なぜ2回目の `console.log(A)` で文字列が出力されるのか?

ここが極めて重要なポイントである。
1. パースフェーズ: スコープに入った瞬間、`function A()` の宣言がEnvironment Recordに `A -> Function` としてバインドされる。続く `var A` は、同名の識別子がすでに存在するため、新規のバインディング作成を行わず、単に無視される。
2. 実行フェーズ(上から順に実行):

  • 1行目の `console.log(A)` では、まだ `var A = ‘…’` の代入文に到達していないため、初期化された関数オブジェクトがそのまま参照される。
  • `var A = ‘overwritten by variable assignment’` の行に到達した瞬間、実行時の代入(LHS評価)が発生し、Environment Record内の識別子 `A` が指す参照先が、関数オブジェクトから文字列プリミティブへと書き換えられる。
  • その結果、以降の参照では文字列が返される。

つまり、巻き上げとは「宣言の優先順位(静的)」であり、代入とは「値の書き換え(動的)」である。この2つが時間軸上で混同されることが、多くのバグを生む温床となる。

—

3. 関数式(Function Expressions)との決定的な違い

では、これが関数宣言ではなく「関数式(Function Expression)」であった場合はどうなるか。

// 関数式の場合の挙動
executionTest();

function executionTest() {
console.log(bar); // undefined (varによる変数宣言のみが巻き上げられる)

// bar(); // TypeError: bar is not a function (実行時エラー)

var bar = function() {
return ‘I am a function expression’;
};

console.log(bar()); // “I am a function expression”
}

関数式の場合、AST上では `VariableDeclaration`(例: `var bar = …`)として扱われる。

  • 巻き上げられるのは変数名 `bar` のみであり、代入される無名関数(あるいは名前付き関数式)は、実行時コードがその行に到達するまでメモリ上に評価・生成されない。
  • したがって、代入前に呼び出そうとすれば、値は `undefined` であり、関数呼び出しを行えば `TypeError` がスローされる。

V8のヒープメモリ空間において、関数宣言はコンパイル直後のスコープ構築時に一気にヒープ領域へ関数インスタンスとして焼き付けられるのに対し、関数式は実行時の評価(Evaluation)を待つ必要がある。このランタイムコストとメモリライフサイクルの違いは、大規模アプリケーションの起動パフォーマンス(Cold Start)やメモリフットプリントにも直結する。

—

4. セキュリティ・サプライチェーンの文脈:スコープ汚染とRCEリスク

この変数・関数の巻き上げメカニズムと識別子の衝突挙動は、単なる言語仕様のパズルではない。セキュリティ研究やサプライチェーン攻撃の文脈において、プロトタイプ汚染(Prototype Pollution)やスコープインジェクションの脆弱性を突く際の基礎理論として悪用されることがある。

悪意あるサードパーティ製ライブラリがグローバルスコープや共通のクロージャ環境に対して、動的なプロパティインジェクションや不適切な変数再定義を行った場合、前述の巻き上げと代入の優先順位の隙間を突き、意図しない関数オーバーライドを引き起こす可能性がある。

例えば、フレームワークのコアロジックにおいて、オプショナルな設定変数と関数名が同一の識別子で宣言されており、且つ厳密なモード(`use strict`)やブロックスコープ(`let`/`const`)への移行が不完全なレガシーコードベースが存在すると仮定する。

// 脆弱なレガシーパターンの模倣
var authenticate = function() {
return ‘Default Secure Auth’;
};

function setupSystem() {
// 開発者が意図せず、または攻撃者によってスコープ内に同名の関数宣言が混入・上書きされた場合
if (false) {
function authenticate() {
return ‘Vulnerable Bypassed Auth’;
}
}

// ES5以前の非厳格モードや古いトランスパイラを通したコードでは、
// ブロックスコープ内の関数宣言の挙動がエンジン依存となり、意図しない巻き上げやスコープ漏洩が発生し得る。
}

現代のV8(ES2015以降の仕様に準拠)では、ブロック内(`{}`)の関数宣言はブロックスコープに閉じ込められる仕様になっているが、古いトランスパイラ(Babelの古い設定など)や非標準的な環境、あるいはグローバル汚染を伴うコードベースでは、ASTの解釈違いを突いたロジックハイジャックの余地が生まれる。

シニアエンジニアたる者、コードの記述順序に頼るのではなく、「ASTレベルでシンボルがどこにバインドされ、実行時のどのタイミングでLHS/RHS評価されるか」を完全に脳内でエミュレートできなければならない。

—

5. 結論:コードの意図をV8に正しく伝えるために

JavaScriptの巻き上げメカニズムをマスターするための鉄則は以下の通りである。

1. `var` を捨てよ。 すべてを `const` と、再代入が必要な場合のみ `let` で宣言せよ。これにより、意図しない巻き上げによる `undefined` の参照や、同名識別子によるサイレントな上書き(バグの温床)をTemporal Dead Zone(TDZ)によって完全にコンパイル時・実行時初期にブロックできる。
2. 関数宣言(Function Declaration)のトップレベル巻き上げに依存した設計を避ける。 コードの可読性と予測可能性を高めるため、関数は常に使用する前に宣言・定義する(あるいは関数式やアロー関数を適切な順序で配置する)コーディング規約を徹底する。
3. ランタイムの挙動を疑え。 コードがブラウザやNode.jsのV8エンジン上でどう解釈され、どのEnvironment Recordに紐づくのか。その微細な変化を見抜く目を持つことこそが、真のフロントエンド・バックエンドを統べるチーフアーキテクトの条件である。

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