【入門編】関数宣言と関数式の巻き上げ挙動の決定的な違い:ASTから読み解く解析順序 – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

こんにちは!フロントエンドからNode.jsの内部ランタイムまで、日夜JavaScriptと向き合っているシニアアーキテクトです。

今回は、JavaScriptを学び始めた誰もが一度はつまずく、そして中級者への登竜門でもある「関数宣言と関数式の巻き上げ(Hoisting)の決定的な違い」についてお話しします。

「なぜ、関数宣言は定義する前なのに呼び出せるのに、関数式はエラーになるの?」
「`var`や`let`の話とどう繋がっているの?」

ネットの海を漂う断片的な知識ではなく、JavaScriptエンジンがコードをどう読み解いているのかという本質的なメカニズムから紐解いていきましょう。ここをクリアすれば、JavaScriptのコードが脳内で完全にトレースできるようになりますよ。一緒にバッチリマスターしていきましょう!

—

1. まずはおさらい:巻き上げ(Hoisting)とは何か?

JavaScriptのエンジン(V8など)は、私たちが書いたコードをいきなり上から順に実行するわけではありません。実行する前に、まずコード全体を「スキャン」して準備をするフェーズ(=作成フェーズ)を持っています。

この準備フェーズのときに、変数や関数の宣言がスコープの先頭に「引き上げられた」かのように振る舞う現象を、私たちは「巻き上げ(Hoisting)」と呼んでいます。

言葉だけだとイメージしにくいので、まずは王道の「関数宣言」から見てみましょう。

// 【コード1】関数宣言の例
sayHello(); // 宣言前なのに呼び出せる!

function sayHello() {
console.log(“こんにちは!巻き上げの力です!”);
}

このコードを実行すると、エラーにならずにちゃんと「こんにちは!巻き上げの力です!」とコンソールに出力されます。不思議ですよね。関数が定義される前に呼び出しているのに、なぜ動くのでしょうか?

その秘密は、JavaScriptエンジンが行う「AST(抽象構木)」の構築と解析のプロセスにあります。

—

2. 関数宣言:コード全体がメモリにロードされる

JavaScriptエンジンは、コードを実行する前に「記憶の部屋(レキシカル環境)」を作ります。

関数宣言 (`function name() {}`) の場合、エンジンはこの文法を見つけると、関数の中身(本体)を含めた完全な状態でメモリ上に配置(登録)します。

そのため、コードのどの行から関数を呼び出しても、すでにメモリ上にその関数が存在しているため、何事もなかったかのように実行できるのです。

[メモリのイメージ:関数宣言]
+——————————————+
| sayHello = function() { … } (実体が常駐) |
+——————————————+
↑
この状態でコードの1行目から実行される!

—

3. 関数式:なぜエラーになるのか?

では、現代のモダンなJavaScriptで主流となっている「関数式(アロー関数や無名関数を変数に代入するスタイル)」ではどうなるでしょうか?

以下のコードを見てください。

// 【コード2】関数式の例(エラーになるパターン)
sayHi(); // 💥 ここでTypeErrorが発生!

const sayHi = () => {
console.log(“こんにちは!”);
};

これを実行すると、次のような無慈悲なエラーに直面します。
`TypeError: sayHi is not a function` (または `ReferenceError`)

「あれ?さっきは動いたのに、なんで今回はダメなの?」と思いますよね。
ここに、関数宣言と関数式の決定的な違いがあります。

決定的な違いの正体:変数としての巻き上げ

関数式は、本質的には「変数に関数を代入しているだけ」です。ここでは `const`(または `let` / `var`)を使って変数を宣言していますよね。

JavaScriptエンジンが事前スキャン(準備フェーズ)を行うとき、`const` や `let` で宣言された変数は、次のように扱われます。

1. メモリには登録されるが、値は入れられない(初期化されない)
2. この「値が入っていない、触ってはいけない状態のエリア」のことを、専門用語で時間的デッドゾーン(TDZ: Temporal Dead Zone)と呼びます。
3. 実際に「関数(値)」がその変数に代入されるのは、コードが上から順番に実行されて、その代入の行に到達した瞬間です。

つまり、先ほどの【コード2】の裏側では、次のようなことが起きているのです。

// 【コード2の裏側の挙動をシミュレーション】
let sayHi; // 1. 準備フェーズ:変数の箱だけ作られる(中身はundefined / TDZ)

sayHi(); // 2. 実行フェーズ:まだ関数が入っていないので「関数じゃないよ!」と怒られる

sayHi = () => { // 3. ここで初めて関数が代入される
console.log(“こんにちは!”);
};

そう、関数式において巻き上げられているのは「関数そのもの」ではなく、「変数の宣言(箱)」だけなのです。中身の関数は、書いた場所に到達するまで絶対にそこに入りません。

—

4. AST(抽象構文木)から読み解く解析順序の裏側

もう少しだけ踏み込んで、JavaScriptエンジンがコードをどう「理解」しているのか、AST(Abstract Syntax Tree:抽象構文木)の視点からも覗いてみましょう。

エンジンはソースコードを文字の羅列ではなく、ツリー構造のデータ(AST)に変換して処理します。

  • 関数宣言 (`function f() {}`)
  • ASTの解析において、宣言と実体がひとまとまりの「FunctionDeclaration」として最優先でトップレベルのスコープに登録されます。そのため、どこからでも参照できるノードとしてツリーに組み込まれます。
  • 関数式 (`const f = () => {}`)
  • これは「VariableDeclaration(変数宣言)」のノードです。変数としての器を作る処理と、右側の「ArrowFunctionExpression(アロー関数)」を評価して代入する処理の2つに分かれています。
  • エンジンは事前スキャン時に変数宣言の器だけを準備しますが、代入処理は実行フェーズのその行に達するまで保留(Deferred)します。

この仕組みを知っていれば、「なぜ関数式は定義より上で使えないのか」が、エンジンの気持ちになってスッキリ理解できるはずです。

—

5. 実務におけるベストプラクティス:私たちはどう書くべきか?

「じゃあ、いつでも巻き上げられてエラーにならない関数宣言のほうが便利なの?」と思われるかもしれませんが、現代の開発現場では、基本的には関数式(特にアロー関数)を使うことが推奨されています。

その理由は主に以下の通りです。

1. コードの予測可能性が高まる

  • 「定義した場所よりも前で呼び出せてしまう」という関数宣言の仕様は、コードが長くなったときにどこで定義されているかを探す旅に出る原因になり、可読性を下げることがあります。
  • 「上から順に読んでいけば必ず定義されている」状態を作るほうが、バグの少ないクリーンなコードになります。

2. スコープの安全管理

  • `const` で関数式を定義することで、意図せず同じ名前の関数を上書きしてしまう事故(再代入)を防ぐことができます。

黄金のルール

  • 基本は `const` による関数式(アロー関数)を使う
  • コードの構造上、どうしても巻き上げの性質を利用したい特別な理由がある場合(あるいは意図的なコードの構成上必要な場合)を除き、「使いたい関数は、必ずそれを使う場所より上で定義する」という秩序を守る。

—

まとめ

いかがでしたでしょうか?

  • 関数宣言 (`function() {}`)
  • 関数名と実体丸ごと巻き上げられるため、定義前でも呼び出し可能。
  • 関数式 (`const f = () => {}`)
  • 巻き上げられるのは「変数の箱」だけ。中身が入るのは実行時なので、定義前に呼ぶとTypeErrorになる。

この違いをASTやエンジンのメモリ管理の視点から理解しておくと、厄介なバグに遭遇したときも「あ、いまTDZに触っちゃったんだな」と瞬時に原因を特定できるようになります。

ここをクリアしたあなたなら、JavaScriptの変数と関数の挙動はもう怖くありません!
明日からのコーディングを、さらに自信を持って楽しんでいきましょう。それでは、また別の知見でお会いしましょう!

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