【実務・中級編】AST(抽象構文木)から読み解く巻き上げの優先順位:関数宣言と変数宣言が衝突した時の解析順序 – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

コードレビュー:「その巻き上げバグ、なぜ起きるのか?」

テックリードの私だ。今日のコードレビューで、また若手がやってくれた。次のようなコードを見てほしい。

// レビュー対象のコード
function processUserData() {
console.log(typeof fetchUser); // ここは何が出力されるか?

var fetchUser = function() {
return “ユーザーA”;
};

function fetchUser() {
return “ユーザーB”;
}

console.log(fetchUser());
}

processUserData();

「先生、`var fetchUser`で上書きしてるから、最初は`undefined`で、最後は『ユーザーA』になりますよね?」

……甘い。これだからJavaScriptの仕様の深層を理解していない奴は困る。
実際の出力結果をコンソールで確認してみろ。最初の`typeof`は `function` になり、最後の実行結果は 「ユーザーB」 になる。`var`で代入したはずの「ユーザーA」は、完全に無視されているのだ。

なぜこんな現象が起きるのか?「JavaScriptは上から下に実行される」という浅い理解では、V8エンジンのパーサーが裏で行っている壮大な前処理を見落とす。今日は、AST(抽象構文木)とコンテキスト生成のフェーズにまで踏み込み、関数宣言と変数宣言が衝突した際の「真の優先順位」を解き明かそう。

—

1. V8エンジンはコードをどう解釈しているか:ASTと巻き上げ(Hoisting)の正体

JavaScriptのコードが実行される前、V8などのエンジンはソースコードをトークンに分解し、AST(抽象構文木)を構築する。そして実行コンテキスト(Execution Context)の作成フェーズ(Creation Phase)において、いわゆる「巻き上げ(Hoisting)」が発生する。

ここで重要なのは、「すべてが同じ優先順位で巻き上げられるわけではない」という厳然たるルールだ。

パーサーがスコープ内をスキャンする際、宣言の登録には明確なヒエラルキーが存在する。

1. 関数宣言 (Function Declaration):最優先。関数オブジェクトそのものがメモリ上に構築され、識別子にバインドされる。
2. 引数 (Arguments):関数の仮引数がスコープに登録される。
3. 変数宣言 (Variable Declaration – `var`):最後。識別子は登録されるが、値は`undefined`で初期化される(`let` / `const` はTDZ(一時的デッドゾーン)に置かれ、初期化されない)。

つまり、冒頭のコードで何が起きているか。ASTのパース段階で、エンジンはこう脳内変換している。

// エンジン内部の解釈(概念コード)
function processUserData() {
// 1. 関数宣言が最優先で巻き上げられ、メモリ上に実体が作られる
var fetchUser = function fetchUser() {
return “ユーザーB”;
};

// 2. var fetchUser の「宣言」はすでに存在するため、重複する宣言はスキップされる
// (代入文である `fetchUser = function() { … }` はこの時点では実行されない)

console.log(typeof fetchUser); // “function” (ユーザーBの関数が入っている)

// 3. ここで初めて代入文が実行される
fetchUser = function() {
return “ユーザーA”;
};

console.log(fetchUser()); // “ユーザーA” ではなく、実は最初の段階で上書き…いや、待て。
}

おっと、私の説明に引っかかったかな? 最後の出力結果の挙動について訂正しよう。
実際にコードを動かすと、最初の`typeof`は`”function”`になり、最後の`fetchUser()`は「ユーザーB」ではなく「ユーザーA」になるケースもある。なぜなら、`var fetchUser = function(…)` の評価(代入)は、コードが上から下に流れる実行フェーズ(Execution Phase)で行われるからだ。

整理しよう。

  • 作成フェーズ(Hoisting): `function fetchUser()` がスコープのトップに陣取り、関数全体をメモリに確保。その後の `var fetchUser` は「同名の識別子が既に存在するため」宣言としては無視される。
  • 実行フェーズ(Execution): 最初の `console.log` 時点では、まだ `var` の代入文に到達していないため、メモリに残っているのは最初の「ユーザーB」の関数。しかし、その直後に `fetchUser = function() { return “ユーザーA”; }` という代入(Assignment)が実行されるため、最終的には「ユーザーA」になる。

……非常にややこしい。そして、この「ややこしさ」自体が、プロダクションコードにおいて絶対になくすべきバグの温床なのだ。

—

2. なぜこの挙動を知る必要があるのか?実務におけるリスク

実際のフロントエンド開発やNode.jsのバックエンド設計において、変数名と関数名がコンフリクトするようなコードを書く人間は即座にクビにすべきだが、大規模なレガシーコードベースや、自動生成されたコード、複雑なクロージャが絡み合うコンポーネント設計においては、意図せずこのスコープ汚染踏み抜くことがある。

特に、以下のような悪影響を及ぼす。

  • 予測不可能な型エラー: 「関数だと思って呼んだら、まだ代入前で `undefined` だった(あるいは古い関数だった)」というランタイムエラー。
  • メモリの無駄遣い: V8のガベージコレクター(GC)がスコープチェーンの解決ミスや意図せぬ参照保持によって解放を行えず、メモリリークの温床になる。
  • コードレビューのコスト増: 開発者がコードの実行順序を脳内トレースするのに無駄な時間を奪われる。

—

3. 【プロダクションコード例】巻き上げ事故をゼロにする堅牢な設計パターン

テクニカルリードとしての私からの答えはシンプルだ。「巻き上げに依存するな。すべての変数はブロックスコープで閉じ込め、関数は関数式または明確なアロー関数として定義せよ」。

以下に、実務の現場ですぐに応用可能な、保守性が高くV8の最適化エンジンにも優しいモダンなコードパターンを示す。

/

  • @file userProcessor.js
  • @description 巻き上げ事故を防ぎ、V8のヒープ効率と可読性を極限まで高めた堅牢な設計

/

‘use strict’;

/

  • ユーザーデータを安全に処理するクラス/モジュール設計

/
class UserDataProcessor {
#cache = new Map();

/

  • ユーザーデータを非同期で取得・処理する
  • @param {string} userId
  • @returns {Promise}

/
async process(userId) {
// let / const を使用し、TDZ(一時的デッドゾーン)を活用して
// 宣言前のアクセスをコンパイルレベル・ランタイムレベルで完全に阻止する
const fetchUserImpl = () => {
if (this.#cache.has(userId)) {
return this.#cache.get(userId);
}
return “デフォルトユーザー”;
};

// スコープを明確に分離し、変数の衝突(シャドーイング)を物理的に不可能にする
const rawUser = await this.#safeFetch(userId, fetchUserImpl);

return this.#sanitize(rawUser);
}

/

  • プライベートヘルパーメソッド(関数宣言ではなくメソッド構文を使用)

/
async #safeFetch(userId, fallbackFn) {
try {
// 外部API連携のシミュレーション
if (!userId) {
return fallbackFn();
}
return `ユーザー_${userId}`;
} catch (error) {
console.error(‘Failed to fetch user:’, error);
throw error;
}
}

#sanitize(userData) {
// 文字列処理のパフォーマンスを意識した不変(Immutable)な操作
return String(userData).trim();
}
}

// モジュールとしてのエクスポート
export const userDataProcessor = new UserDataProcessor();

この設計が優れている理由(アーキテクトの視点)

1. `var` の完全排除と `let`/`const` の徹底:
`var` は関数スコープを持ち、巻き上げによってバグを生む最大の元凶だ。`const` を基本とし、再代入が必要な場合のみ `let` を使うことで、変数の生存期間(ライフサイクル)を最小限のブロック内に閉じ込めている。
2. 関数式(アロー関数)の活用:
関数宣言 (`function name() {}`) はファイル全体に巻き上げられるため、スコープのどこからでも参照できてしまい、依存関係が曖昧になる。定数にアロー関数を代入するスタイルを取ることで、「コード上部から順に定義され、上から下に流れる」という直感的なメンタルモデルを強制できる。
3. クラス構文とプライベートフィールド (`#cache`):
V8エンジンは、隠しクラス(Hidden Classes / Shapes)の概念を使ってオブジェクトのプロパティアクセスを最適化する。プライベートフィールドを適切に定義することで、インスタンスの形状が動的に変わるのを防ぎ、インラインキャッシュ(Inline Caching)のヒット率を最大化、DOMやメモリ上のパフォーマンスを極限まで高めている。

—

4. チーフアーキテクトからの最終提言

JavaScriptの言語仕様は柔軟で、時として「動けばいいや」という雑なコードを許容してしまう。しかし、フロントエンドの大規模化や、Node.jsによる高スループットなサーバーサイド開発において、その「甘え」は必ずメモリリークや難解なバグという形で牙をむく。

ASTがどう解釈しようと、V8がどうメモリを割り当てようと、「人間が読んで1秒で意図が理解でき、巻き上げの罠が入り込む隙間さえないコード」を書くこと。それが、真にプロダクションを任せられるエンジニアの矜持だ。

今日のレビューはここまでだ。各自、自分のリポジトリの `var` を今すぐ `const` に書き換え給え。

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