【テクニカル・上級編】関数宣言と関数式:巻き上げの挙動が異なる理由をAST(抽象構文木)から理解する – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

関数宣言と関数式の決定的な断絶:ASTの物理構造とV8ランタイムが隠蔽する「巻き上げ」の真実

JavaScriptエンジニアの多くが、キャリアの初期段階で「巻き上げ(Hoisting)」という概念に出会う。`var`や関数宣言が、コードの物理的な記述位置に関わらずスコープの先頭に引き上げられるかのように振る舞う現象だ。しかし、この言葉はV8をはじめとするモダンJSエンジンの内部挙動を隠蔽し、プログラマに危険な錯覚を植え付ける都合の良い隠れ蓑にすぎない。

「コードが上に持ち上げられている」などという物理現象は、ランタイムのメモリ空間のどこにも存在しない。

本稿では、レキサー(字句解析器)がソースコードを読み込み、パーサーがAST(抽象構文木:Abstract Syntax Tree)を構築する瞬間から、 Ignition(バイトコードインタプリタ)がそれを評価し、TurboFanがJITコンパイルを下すまでの全プロセスを解剖する。なぜ「関数宣言」と「関数式(`let`/`const`あるいは`var`)」でその運命が決定的に分岐するのか。その深層を、ASTのノード構造とスコープ情報(Scope Info)の物理配置から紐解いていく。

—

1. 物理的実態:AST生成フェーズにおけるノードの差異

JavaScriptエンジンがスクリプトを実行する前段階として、ソースコードはバイト列からトークン列へ変換され、最終的にASTへ落とし込まれる。ここでV8のパーサーが何を見ているのかがすべての鍵を握る。

以下の2つのコード片を比較してほしい。一見して似たような関数定義だが、ASTのツリー構造において決定的な違いが生じる。

// パターンA:関数宣言 (Function Declaration)
console.log(foo()); // “bar” が出力される
function foo() {
return “bar”;
}

// パターンB:関数式(letによる代入) (Function Expression)
console.log(baz()); // ReferenceError: Cannot access ‘baz’ before initialization
let baz = function() {
return “qux”;
};

パーサーの視点:`FunctionDeclaration` ノード vs `VariableDeclaration` ノード

ASTのルート直下、あるいはブロックスコープ内において、パターンAの `function foo() {}` はパーサーによって `FunctionDeclaration` という独立したファーストクラスのノードとして認識される。このノードは、識別子(Identifier)の名前である `foo` と、関数本体(FunctionBody)のブロックを完全にカプセル化した状態でパース木に登録される。

一方、パターンBの `let baz = function() {}` は、`VariableDeclaration`(変数宣言) ノードのツリー構造であり、その右辺に無名または名前付きの `FunctionExpression`(関数式) がぶら下がっている形をとる。

この構文木の分類の差こそが、スコープの事前割り当てフェーズ(Pre-parsing / Parsing phase)における処理を分ける物理的な境界線である。

—

2. スコープ割り当てと V8 コンテキスト(Context)の構築

V8(Ignition/TurboFan)は、実行コンテキスト(Execution Context)が生成される前に、静的スコープ分析(Scope Analysis)を行う。この段階で、変数環境(VariableEnvironment)および lexical環境(LexicalEnvironment)のスロットがメモリ上に確保される。

関数宣言のメモリ割り当て:事前インスタンス化

パターンAの関数宣言の場合、V8のパーサーはスコープ解析時にその識別子(`foo`)を発見すると、現在のスコープの変数オブジェクト(またはLexical Environment)の環境レコードに対して、関数オブジェクトそのものを即座にバインドし、メモリ上にインスタンス化する。

したがって、コードの実行フェーズ(Execution Phase)が上から1行目に到達した瞬間、すでにメモリ上には `foo` というポインタが完全な関数オブジェクトを指し示している状態が完成している。これが「巻き上げ」の正体である。コードが移動しているのではなく、コンパイル・スコープ構築の段階で実体がメモリに生成されているのだ。

関数式(`let`/`const`)のメモリ割り当て:時間的デッドゾーン(TDZ)の強制

対してパターンB(`let baz = …`)はどうだろうか。
ここで宣言されているのは `baz` という変数名であり、`let` によって宣言された変数は、Temporal Dead Zone(一時的死灰領域:TDZ)の統制下に置かれる。

スコープ解析時、V8は `baz` の存在をスコープ情報に登録するものの、初期値として `undefined` を割り当てる `var` とは異なり、「未初期化(Uninitialized)」というメタ状態のフラグを立てる。
このフラグが外れるのは、実行フェーズにおいて実際に `let baz = …` の評価文が通過した瞬間のみである。

したがって、評価文に到達する前に `baz()` を呼び出そうとすると、V8のランタイムチェックが未初期化フラグを検知し、即座に `ReferenceError` をスローする。これはセキュリティや型の安全性を担保する上で極めて合理的なランタイムの防壁である。

—

3. ASTのJSON表現から読み解く内部構造の差異

実際に、V8やBabelなどのパーサーが生成するASTの構造をシミュレートしてみよう。以下の簡易的なASTツリー構造を見れば、両者の決定的な違いが物理的に理解できるはずだ。

/ パターンA: FunctionDeclaration のAST断片 /
{
“type”: “FunctionDeclaration”,
“id”: {
“type”: “Identifier”,
“name”: “foo”
},
“params”: [],
“body”: {
“type”: “BlockStatement”,
“body”: [
{
“type”: “ReturnStatement”,
“argument”: {
“type”: “Literal”,
“value”: “bar”
}
}
]
}
}

このノード自体がスコープの先頭で「実体化」の特権を持つ。

/ パターンB: VariableDeclaration (let) のAST断片 /
{
“type”: “VariableDeclaration”,
“declarations”: [
{
“type”: “VariableDeclarator”,
“id”: {
“type”: “Identifier”,
“name”: “baz”
},
“init”: {
“type”: “FunctionExpression”,
“id”: null,
“params”: [],
“body”: {
“type”: “BlockStatement”,
“body”: [
{
“type”: “ReturnStatement”,
“argument”: {
“type”: “Literal”,
“value”: “qux”
}
}
]
}
}
}
],
“kind”: “let”
}

ここでは `baz` という「変数」の宣言と、右辺の「関数式」の評価が分離されている。変数 `baz` は `let` によってTDZに縛られ、右辺の関数式が実行されるまでアクセスが厳格にブロックされる。

—

4. セキュリティとサプライチェーン:プロトタイプ汚染と関数スコープの罠

この関数宣言・関数式のセマンティクスとスコープの挙動を深く理解していることは、単なる言語仕様の知識に留まらない。昨今のNode.jsエコシステムにおけるサプライチェーン攻撃、特にプロトタイプ汚染(Prototype Pollution)からリモートコード実行(RCE)に至る脆弱性のメカニズムをハック・防御する上でも直結する。

悪意あるライブラリが `Object.prototype` を汚染し、その中で関数宣言や不適切なスコープ共有を利用した動的コード評価(`eval` や `Function` コンストラクタ、あるいは非安全なテンプレートエンジン)を誘発する際、V8の変数解決メカニズムの隙間が突かれる。

以下の防衛的コーディングの例を見てほしい。モダンなNode.js環境において、動的に渡されたオブジェクトのプロパティを安全に処理し、関数スコープの意図しない巻き上げや汚染を防ぐためのアークテクチャである。

‘use strict’;

/

  • 堅牢なプロパティ検証と安全な関数実行を行うアーキテクチャ
  • 汚染されたプロトタイプチェーンの影響を完全に遮断する

/
function executeSafely(targetObject, methodName, args) {
// 1. プロトタイプ汚染対策: __proto__ や constructor への直接アクセスを遮断
// Object.hasOwn を使用して、オブジェクト自身のプロパティであるかを厳密に検証する
if (!targetObject || typeof targetObject !== ‘object’) {
throw new TypeError(‘Invalid target object provided.’);
}

// 2. null原型オブジェクト(Object.create(null))の活用による安全領域の確保
const safeContainer = Object.create(null);

// 許可された安全なメソッドのマッピング(ホワイトリスト方式)
safeContainer.compute = function(x, y) {
// 関数式を用いることで意図しないスコープ汚染や巻き上げを防ぐ
const innerOperation = function(a, b) {
return a + b;
};
return innerOperation(x, y);
};

if (!Object.hasOwn(safeContainer, methodName)) {
throw new SecurityError(`Execution of method ‘${methodName}’ is not allowed.`);
}

// 3. 厳格モード下での実行コンテキストの分離
try {
return safeContainer[methodName].apply(null, args);
} catch (error) {
// エラータスクのハンドリング(V8のスタックトレース情報を安全にラップ)
console.error(`[Runtime Security Warning]: Failed to execute method.`, error.message);
throw error;
}
}

// カスタムセキュリティエラークラス
class SecurityError extends Error {
constructor(message) {
super(message);
this.name = ‘SecurityError’;
}
}

// 実行テスト
try {
const result = executeSafely({}, ‘compute’, [10, 20]);
console.log(‘Result:’, result); // “Result: 30”
} catch (e) {
console.error(e);
}

このコードでは、`Object.create(null)` を用いてプロトタイプチェーンを持たない純粋なハッシュマップ(Dictionary)を作成し、さらに `Object.hasOwn()` を使って `Object.prototype` からのプロパティ汚染の伝播を物理的に遮断している。加えて、内部のロジックに関数式(`const innerOperation = function…`)を採用することで、不必要なスコープ拡張や巻き上げによるバグの混入を防いでいる。

—

5. まとめ:ランタイムの支配者になるために

関数宣言と関数式の巻き上げの挙動の差は、単なる「書き方の違い」ではない。それはソースコードがV8のパーサーによって解釈され、ASTという構文木を形成し、最終的にメモリ上のスコープ環境レコードにどのようにバインドされるかという、JavaScriptエンジンの物理的な心臓部の現れである。

  • 関数宣言は、パーサーの静的スコープ解析段階で実体がメモリにインスタンス化されるため、コードのどこからでも呼び出しが可能。
  • 関数式(特に `let`/`const`)は、変数としての宣言と関数オブジェクトの代入が分離され、TDZ(一時的死灰領域)の統制下におかれるため、評価文の通過前はアクセスが厳格に拒絶される。

このメカニズムを骨の髄まで理解したエンジニアにとって、もはや「巻き上げ」は予測不能な魔術ではなく、完全にコントロール可能なランタイムの挙動となる。V8の挙動、ASTの構造、そしてメモリの動きを脳内で正確にトレースできる者だけが、真にセキュアで高速なハイパフォーマンス・アプリケーションをアーキテクトすることができるのだ。

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