こんにちは!JavaScriptの奥深い世界へようこそ。
普段何気なく使っている変数ですが、その宣言方法である `var` と `let`、そして `const` の裏側で、JavaScriptのエンジン(V8など)がどれほどドラマチックな働きをしているか、意識したことはありますか?
「とりあえず `let` を使っておけば安全なんでしょ?」と思っているそこのあなた。実は、`for` ループの中で `let` を使うとき、V8エンジンのメモリ空間では「目に見えないクローン(レキシカル環境の生成)」という興味深い現象が起きています。
今回は、プログラミング初学者の方や、他の言語からJSの世界に飛び込んできた方に向けて、この「ブロックスコープとメモリの秘密」を優しく、かつ本質的な部分まで一緒に解き明かしていきましょう。ここをクリアすれば、JavaScriptの変数スコープとメモリの挙動はもうバッチリマスターできますよ!
—
1. なぜ `let` は `var` と違うのか?基本の復習
まずは、`for` ループにおける `var` と `let` の挙動の違いを、有名な「非同期コールバックの罠」からおさらいしてみましょう。
// 【varの挙動:すべてが同じ変数を共有する】
for (var i = 1; i <= 3; i++) {
setTimeout(() => {
console.log(`varの値: ${i}`);
}, 1000);
}
// 実行結果(1秒後):
// varの値: 4
// varの値: 4
// varの値: 4
あれ?「1, 2, 3」と出力されてほしいのに、「4, 4, 4」になってしまいましたよね。これは、`var` が「関数スコープ」を持つため、ループ全体で変数 `i` がたった1つしか存在せず、ループが終了した時点の最終値(`4`)をすべての `setTimeout` が共有してしまったからです。
では、これを `let` に変えてみましょう。
// 【letの挙動:ループごとに新しい変数が生まれる】
for (let i = 1; i <= 3; i++) {
setTimeout(() => {
console.log(`letの値: ${i}`);
}, 1000);
}
// 実行結果(1秒後):
// letの値: 1
// letの値: 2
// letの値: 3
見事に「1, 2, 3」と出力されました!
JavaScriptの言語仕様(ECMAScript)では、`for (let i = …)` のループ内において、「各イテレーション(繰り返し)ごとに、新しい変数 `i` がそのブロックスコープ内に新しく作成される」と規定されています。
—
2. メモリの舞台裏:V8エンジンで何が起きているのか?
「ループごとに新しい変数が生まれる」ということは、裏側のメモリ(V8エンジンのヒープ領域)で一体何が起きているのでしょうか?
イメージ図を使って頭の中でV8の動きをトレースしてみましょう。
[varの世界]
グローバル/関数スコープ ──> [ 変数 i (値: 4) ] <--- すべての非同期処理がここを見ている!
[letの世界]
ループ1回目 ──> [ レキシカル環境A: 変数 i = 1 ] ──> setTimeout(1) が保持
ループ2回目 ──> [ レキシカル環境B: 変数 i = 2 ] ──> setTimeout(2) が保持
ループ3回目 ──> [ レキシカル環境C: 変数 i = 3 ] ──> setTimeout(3) が保持
そうなんです。`let` を使ったループでは、JavaScriptエンジンはループが回るたびに、その瞬間だけの「レキシカル環境(Lexical Environment)」のクローン(スナップショットのようなもの)を生成し、そこに新しい変数をバインドしています。
各 `setTimeout` の中にある無名関数は、それぞれ自分が生まれた瞬間に割り当てられた「専用のレキシカル環境」をそっと包み込んで(クロージャを形成して)保持します。だからこそ、後から値が書き換わっても影響を受けず、綺麗に「1, 2, 3」と出力できるわけですね。
—
3. ここがトレードオフ!なぜ `let` はメモリ消費が増える場合があるのか?
ここでエンジニアとして一歩踏み込んだ視点を持ってみましょう。
「ループごとに新しいレキシカル環境が作られる」ということは、当然その分のメモリ消費(割り当てコストとガーベジコレクションの負荷)が増えることを意味します。
`var` であれば、変数の実体はメモリ上の同じ場所を上書きし続けるため、メモリのフットプリント(占有量)は最小限で済みます。しかし、`let` は繰り返し回数分だけ環境オブジェクトがヒープ上に乱立することになります。
数回〜数十回のループであれば、現代の高速なV8エンジンや最新のハードウェアの前では誤差の範囲です。しかし、これが「数万件の巨大な配列を回すループ」や、「ミリ秒単位で高頻度に実行されるアニメーション・ゲームのメインループ」だったらどうでしょうか?
無駄なオブジェクト生成が繰り返されることで、V8のガベージコレクター(GC)が頻繁に動き出し、カクつき(フレームレートの低下やガタつき)の原因になるリスクを孕んでいるのです。
メモリ効率を意識した実用コードの書き換え例
もし極限までのパフォーマンスチューニングが求められるクリティカルな場面で、かつ先ほどのような非同期のクロージャ問題を気にする必要がない(同期処理だけで完結する)場合は、あえて次のようにスコープの設計を工夫することもあります。
// パフォーマンス配慮型:ループの外側でletを宣言し、中身だけを使い回す
let i = 0;
const max = 10000;
console.time(“パフォーマンス計測”);
// ループ内で新しいレキシカル環境を作らせず、同一の変数を効率よくインクリメントする
for (i = 0; i < max; i++) {
// 同期的な処理(重い計算など)をここで高速にこなす
let computedValue = i 2;
}
console.timeEnd("パフォーマンス計測");
もちろん、これは「コードの読みやすさ・安全性(バグの起きにくさ)」と「メモリ・速度の最適化」のトレードオフです。基本的には、コードの保守性と安全性を優先して `let` や `const` を使い、パフォーマンスがボトルネックになっているとプロファイラ(Chrome DevToolsなど)で判明した箇所だけを最適化するのが、現代のモダンな開発アプローチの王道です。
---
4. 陥りやすい文法エラー:TDZ(一時的死神…ではなく「一時的死区間」)
`let` や `const` を語る上で外せないのが、TDZ(Temporal Dead Zone:一時的死区間)です。初学者が必ず一度はハマるこの罠についても触れておきましょう。
{
// ここから変数 i のTDZが始まる
console.log(i); // ReferenceError: Cannot access ‘i’ before initialization
let i = 10; // ここで変数 i が初期化され、TDZが終了する
console.log(i); // 10
}
`var` の場合は、巻き上げ(Hoisting)が起きると同時に値が `undefined` で初期化されていました。そのため、宣言前にアクセスしても `undefined` が返るだけでエラーにはなりませんでしたよね。
しかし、`let` や `const` も巻き上げ自体は起きていますが、「初期化されるまでの間(=TDZ)」にアクセスしようとすると、JavaScriptエンジンが厳格にエラーを投げる仕様になっています。これは、「宣言される前に変数を使ってバグを生み出すのを未然に防ぐ」ための素晴らしい安全装置です。
「宣言するより前には絶対に使わない!」というクリーンなコードを書く習慣を、このTDZがしっかりとサポートしてくれているわけですね。
—
まとめ
いかがでしたでしょうか? 今回の学びをギュッと凝縮して振り返ってみましょう。
1. `let` はループごとにレキシカル環境をクローンするため、非同期処理(`setTimeout` など)でも各イテレーションの変数の状態を安全に保持できる。
2. その裏返しとして、大量のループや高頻度な処理ではメモリ消費が増加するトレードオフが存在する。
3. TDZ(一時的死区間)の存在により、宣言前の変数の誤用をエンジンのレベルでガッチリと防いでくれる。
JavaScriptの変数宣言一つをとっても、その裏側ではV8エンジンがメモリ空間をスマートに、かつ安全に管理するための高度なドラマを繰り広げています。こうした内部挙動の解像度を少しずつ上げていくことで、あなたの書くコードは「ただ動くコード」から「美しく堅牢なプロのコード」へと確実に進化していきますよ。
それでは、また次回の深い世界でお会いしましょう!バッチリマスターしていってくださいね!