JavaScriptを学習する過程で、多くの開発者が最初に直面する不思議な現象が「ホイスティング(Hoisting)」、日本語で「変数の巻き上げ」と呼ばれる挙動です。
宣言する前の変数や関数を呼び出してもエラーにならなかったり、あるいは逆にエラーになったりと、一見すると直感に反する動きをすることがあります。
ホイスティングは、JavaScriptエンジンがコードを実行する前の「コンパイルフェーズ(プリパース)」で行うメモリ割り当ての仕組みに深く関わっています。
この仕組みを正しく理解することは、バグを未然に防ぐだけでなく、実行コンテキストやスコープの概念を深く理解することにも繋がります。
本記事では、ES2015(ES6)以降の標準であるletやconst、そして従来のvarや関数宣言による挙動の違いを軸に、ホイスティングの本質を解説します。
ホイスティングの本質的な仕組み
多くの教材では、ホイスティングを「コード内の宣言がスコープの最上部に物理的に移動すること」と説明していますが、これはあくまで概念的なイメージです。
実際には、JavaScriptエンジンが実行前にソースコードをスキャンし、識別子(変数名や関数名)をメモリに登録する処理を指します。
実行コンテキストの生成とフェーズ
JavaScriptの実行は、大きく分けて2つのフェーズで行われます。
- 作成フェーズ(Creation Phase):実行コンテキストが作成され、変数や関数のためのメモリ領域が確保されます。この段階で「巻き上げ」が発生します。
- 実行フェーズ(Execution Phase):コードが一行ずつ実行され、値の代入や関数の呼び出しが行われます。
この「作成フェーズ」において、エンジンは宣言を見つけると、その識別子を環境レコード(Environment Record)に登録します。
このとき、宣言の種類(var, let, const, function)によって、初期化のルールが異なるため、挙動に差が生まれるのです。
varによる変数のホイスティング
従来のvarキーワードを使用した変数宣言は、もっとも典型的なホイスティングの挙動を示します。
varで宣言された変数は、作成フェーズでメモリが確保されると同時に、初期値として undefined が自動的に割り当てられます。
varの動作例
以下のコードを見てみましょう。
宣言の前に変数にアクセスしています。
// 宣言前にアクセス
console.log("変数aの値:", a);
// 変数の宣言と代入
var a = "Hello Hoisting";
console.log("代入後の変数aの値:", a);
変数aの値: undefined
代入後の変数aの値: Hello Hoisting
このコードがエラーにならずにundefinedを返すのは、作成フェーズでvar a;という宣言部分だけがメモリに登録され、暗黙的に初期化されているからです。
実際の実行時には、あたかもコードの冒頭に宣言が移動したかのように振る舞います。
しかし、値の代入自体は元の位置で行われるため、代入前の出力はundefinedとなります。
関数宣言のホイスティング
関数宣言(Function Declaration)は、ホイスティングの影響を最も強く受ける要素です。
関数宣言の場合、変数とは異なり、関数本体(定義全体)が丸ごとメモリに格納されます。
関数宣言の動作例
関数を定義する前に呼び出しても、正常に動作します。
// 定義前に呼び出し
greet();
// 関数宣言
function greet() {
console.log("関数宣言によるホイスティングが成功しました。");
}
関数宣言によるホイスティングが成功しました。
このように、関数宣言はスコープ内のどこからでも呼び出すことが可能です。
これは利便性が高い一方で、大規模な開発では「どこで定義されたかわからない関数がどこでも使える」という不透明さを生む原因にもなり得ます。
関数式の場合の注意点
注意が必要なのは、関数を「変数に代入する形式(関数式)」で定義した場合です。
// 関数式の場合(varを使用)
try {
sayHello();
} catch (e) {
console.log("エラー発生:", e.message);
}
var sayHello = function() {
console.log("Hello!");
};
エラー発生: sayHello is not a function
この場合、var sayHelloという変数の宣言自体はホイスティングされますが、その中身はundefinedです。
undefinedを関数として実行しようとするため、TypeErrorが発生します。
関数宣言と関数式では、ホイスティングの挙動が明確に異なることを覚えておきましょう。
letとconstにおける「巻き上げ」とTDZ
モダンなJavaScript開発において主流であるletとconstも、実はホイスティング自体は行われています。
しかし、varとは決定的な違いがあります。
それは、「初期化」が行われるタイミングです。
暫定死角(TDZ: Temporal Dead Zone)
letやconstで宣言された変数は、作成フェーズで識別子として登録されますが、実行フェーズでその宣言行に到達するまで初期化されません。
この「宣言から初期化までの間」のアクセスできない領域を暫定死角(TDZ)と呼びます。
// letの場合
try {
console.log(b);
let b = 10;
} catch (e) {
console.log("letのエラー:", e.message);
}
// constの場合
try {
console.log(c);
const c = 20;
} catch (e) {
console.log("constのエラー:", e.message);
}
letのエラー: Cannot access 'b' before initialization
constのエラー: Cannot access 'c' before initialization
varのときはundefinedが返されましたが、let/constではReferenceErrorがスローされます。
これにより、開発者は「宣言する前に変数を使おうとしている」というミスに即座に気づくことができます。
なぜTDZが必要なのか
TDZの導入には明確な理由があります。
一つは、プログラムの予測可能性を高めるためです。
宣言前に変数を参照できる挙動は、多くのプログラミング言語において一般的ではなく、バグの温床になりがちです。
もう一つは、constの整合性を保つためです。
constは「再代入不可能な定数」を定義するものですが、もしホイスティングによってundefinedで初期化されてしまうと、その後に本来の値を代入する行為が「再代入」とみなされる矛盾が生じてしまいます。
これを防ぐため、宣言行に到達するまで一切のアクセスを禁止しているのです。
クラスのホイスティング
ES2015で導入されたclass構文も、実はホイスティングされます。
しかし、その挙動はletやconstと同様にTDZの制約を受けます。
try {
const instance = new MyClass();
} catch (e) {
console.log("クラスのエラー:", e.message);
}
class MyClass {
constructor() {
this.name = "Hoisting Test";
}
}
クラスのエラー: Cannot access 'MyClass' before initialization
関数宣言(function)のようにどこでも呼び出せるわけではなく、クラス定義より前でインスタンス化することはできません。
クラスを使用する場合は、必ずコードの上部で定義を行う必要があります。
ホイスティング挙動の比較まとめ
各宣言方法によるホイスティングの違いを以下の表にまとめました。
| 宣言方法 | ホイスティング | 初期化タイミング | 宣言前のアクセス結果 |
|---|---|---|---|
var | あり | 宣言時に undefined | undefined |
function | あり | 宣言時に関数本体 | 関数として実行可能 |
let | あり(TDZあり) | 実行行に到達した時 | ReferenceError |
const | あり(TDZあり) | 実行行に到達した時 | ReferenceError |
class | あり(TDZあり) | 実行行に到達した時 | ReferenceError |
この表からわかる通り、現代のJavaScript開発では、意図しない挙動を避けるためにlet, const, classの使用が強く推奨されます。
実務で意識すべきホイスティング対策
ホイスティングの仕組みを理解した上で、私たちがコードを書く際に守るべきプラクティスがいくつかあります。
これらを意識することで、メンテナンス性の高い安全なコードを作成できます。
1. 変数宣言は常にスコープの先頭で行う
JavaScriptエンジンが内部で巻き上げを行うのであれば、人間もそれに合わせた形でコードを書くのが自然です。
関数の冒頭で必要な変数をすべて宣言しておくことで、TDZによるエラーや意図しないundefinedの参照を防ぐことができます。
2. varの使用を避け、const/letを徹底する
特別な理由がない限り、varを使用するメリットはありません。
再代入が必要な場合はlet、それ以外はすべてconstを使うことで、ホイスティングによる混乱の大部分を解消できます。
3. 関数式やアロー関数の利用を検討する
関数宣言(function greet() {...})は便利ですが、ホイスティングによって「どこでも使えてしまう」柔軟性が、コードの可読性を下げることもあります。
const greet = () => {...} のようにアロー関数を変数に代入する形式を取れば、変数のホイスティングルール(TDZ)が適用されるため、「定義した後に使う」という自然な順序を強制できます。
4. リンター(ESLint)の活用
「宣言前の使用」を自動的に検知するために、ESLintなどの静的解析ツールを導入しましょう。
no-use-before-define ルールを有効にすることで、ホイスティングに依存した不安定なコードが記述された際に、エディタ上で警告を出すことが可能です。
発展:メモリ管理とホイスティングの相関
ホイスティングを理解することは、メモリ効率を考える上でも重要です。
JavaScriptのガベージコレクションは、参照されなくなったメモリを解放しますが、ホイスティングによって不必要に長い期間、変数がスコープ内に保持されると、メモリの断片化やリークの原因になる可能性がわずかながら存在します(現代のエンジンでは高度に最適化されていますが)。
特に、大きなデータ構造を扱う際には、必要なタイミングで最小限のスコープで宣言を行うことが、パフォーマンスの観点からも望ましいと言えます。
letやconstによるブロックスコープの活用は、このメモリ管理の面でも非常に有効です。
まとめ
JavaScriptのホイスティングは、言語の柔軟性を支える一方で、初心者からベテランまでを困惑させる独特な仕様です。
しかし、その正体は「実行コンテキストの作成フェーズにおける識別子の登録プロセス」に過ぎません。
varはundefinedで初期化されるため、宣言前でもアクセスできてしまう。function宣言は本体ごと巻き上がるため、どこからでも呼び出せる。letやconstはホイスティングされるが、初期化されるまでTDZ(暫定死角)となり、アクセスするとエラーになる。
これらの違いを明確に理解しておくことで、デバッグの時間を大幅に短縮し、より堅牢なプログラムを設計できるようになります。
現代のJavaScriptにおいては、「ホイスティングに頼らないコードを書くこと」が、プロフェッショナルなエンジニアへの第一歩と言えるでしょう。
コードの読み手にとっても、実行するエンジンにとっても、明快で予測可能な記述を心がけていきましょう。
