こんにちは!JavaScriptのコアな仕組み、特に裏側で何が起きているのか気になったことはありませんか?
今回は、多くの開発者が何気なく使っている`for`ループと`let`の組み合わせについて、一歩踏み込んで解説していきますね。「なぜ`let`を使うとうまくいくのか」「メモリやスコープの裏側では何が生成されているのか」を理解すると、JavaScriptのコードを書く視点がガラリと変わります。
ここをクリアすれば、変数スコープやクロージャの挙動はバッチリマスターできますよ。一緒にエンジニアとしての解像度を上げていきましょう!
—
1. 昔のJavaScript(`var`)が抱えていた「あの現象」の正体
まずは、モダンな`let`が登場する前の世界、つまり`var`全盛期に私たちがよく直面していた「あるバグ」を思い出してみましょう。
例えば、画面上の3つのボタンに対して、それぞれクリックされたら「インデックス番号をアラートで出す」という処理をループで書こうとしたとします。
// 【注意】これは古いやり方(var)のアンチパターンです
for (var i = 1; i <= 3; i++) {
setTimeout(function() {
console.log(`現在のインデックスは: ${i} です`);
}, 1000);
}
このコードを実行すると、1秒後に何が出力されると思いますか?
「1秒後、2秒後…」ではなく、なんと1秒後に「現在のインデックスは: 4 です」が3回連続で出力されてしまうのです。
なぜこんなことが起きるのか?(スコープの罠)
`var`には「ブロックスコープ」という概念がありませんでした。あるのは「関数スコープ」または「グローバルスコープ」だけです。
上記のコードで、変数 `i` はループの外側(あるいはグローバル)から見るとただ一つの「共有された変数」として存在しています。
`for`ループの処理自体は一瞬で完了するため、1秒後に`setTimeout`のコールバック関数が実行される頃には、変数 `i` の値はすでにループを抜けたときの最終値である `4` に書き換わってしまっているのです。
これを防ぐために、昔のエンジニアたちは「即時実行関数(IIFE)」というトリッキーな技を使って、無理やりスコープを切り分けるという苦労をしていました。大変な時代でしたよね。
—
2. 救世主 `let`:ループごとに「新しい環境レコード」が生まれる
ここで登場するのが、ES6(ECMAScript 2015)で導入された `let` です。
先ほどのコードの `var` を `let` に書き換えてみましょう。
for (let i = 1; i <= 3; i++) { setTimeout(function() { console.log(`現在のインデックスは: ${i} です`); }, 1000); } 実行結果は、私たちが直感的に期待する通りになります: 1秒後... 現在のインデックスは: 1 です 現在のインデックスは: 2 です 現在のインデックスは: 3 です なぜ `let` に変えただけで、このような綺麗な挙動になるのでしょうか? ここが今回の本題です。
裏側の仕組み:ループの「クローン生成(環境レコードの分離)」
JavaScriptエンジン(V8など)の内部では、`for (let i = …)` という構文を見つけた瞬間、特別な最適化とルールを適用します。
1. 初期化時の親環境:ループが始まる直前に、外側のスコープ(環境レコード)が作成されます。
2. 各イテレーション(繰り返し)ごとのクローン:
`for` ループが1回まわる(イテレーションする)たびに、JavaScriptエンジンはそのループ回専用の新しい環境レコード(メモリ上の空間)を裏側で新しく生成します。
3. 変数のバインド:
その回で使われる変数 `i` は、新しく作られた専用の環境レコードの中に閉じ込められ、前回のループの `i` とは完全に切り離されます。
イメージ図で表すと、メモリ上では以下のように独立した空間が毎回作られているような状態になっています。
[ グローバルスコープ / 外側 ]
│
├── (1回目ループの空間) ──> 変数 i = 1 を保持する独立したメモリ
│
├── (2回目ループの空間) ──> 変数 i = 2 を保持する独立したメモリ
│
└── (3回目ループの空間) ──> 変数 i = 3 を保持する独立したメモリ
コールバック関数(`setTimeout`の中身)は、自分が生まれた瞬間に存在していた「その回専用の環境レコード」をそっくりそのまま記憶(クロージャとして保持)します。だからこそ、後から呼び出されても、それぞれの回が持っていた固有の `i` の値(1、2、3)を正確に保持し続けられるのです。
—
3. メモリ消費への影響と「クリーンアップ」の美学
「ループのたびに新しい環境レコード(クローン)を作るなんて、メモリをすごく圧迫するんじゃないの?」と心配になった鋭い方もいらっしゃるかもしれません。
結論から言うと、現代のJavaScriptエンジン(V8など)は非常に賢く設計されているため、メモリリークの心配はありません。
ガベージコレクション(GC)の優しさ
`let` によって作られたそれぞれのブロックスコープ内の変数やクロージャは、その参照が不要になった瞬間にガベージコレクションの対象となります。
- クロージャがどこからも参照されなくなり、実行コンテキストが消滅すれば、そのループ回専用の環境レコードもヒープメモリから綺麗に解放されます。
- 反対に、古い `var` のように「どこからでも書き換え可能な巨大な共通変数」をダラダラと保持し続けるほうが、意図しないメモリの保持やバグ(メモリリークの温床)を生みやすかったのです。
`let` を使うことは、コードの安全性を高めるだけでなく、「メモリの寿命を必要最小限にコントロールする」というモダンなメモリ管理の恩恵をそのまま受けていることになります。
—
4. 陥りがちな罠:`const` を `for` ループで使うときの注意点
ここで、少し応用的な疑問。「じゃあ、値が再代入されないなら、`for (const i = 1; …)` でもいいのでは?」と思ったことはありませんか?
実は、通常のカウンターをインクリメントするループでは `const` は使えません。
// エラーになる例
for (const i = 1; i <= 3; i++) {
console.log(i);
}
// TypeError: Assignment to constant variable. (定数への代入エラー)
なぜなら、`i++` という処理は「変数 `i` に新しい値を再代入している」からです。`const` は再代入を禁止しているため、ループのカウンタとしては使えません。
ただし、配列を展開する `for…of` ループ の場合は話が別です。
const numbers = [10, 20, 30];
// 各ループのイテレーションごとに新しい定数として値が束縛されるため、constが使える!
for (const num of numbers) {
console.log(num); // 10, 20, 30 が順に出力される
}
`for…of` や `for…in` の世界では、ループのたびに新しい値が定数として「新しく生成(宣言)」されるため、`const` を使ってもエラーになりません。コードの意図として「ループ内でこの変数を書き換えないよ」と明確にしたい場合は、積極的に `const` を使うのがモダンJavaScriptの作法です。
—
まとめ:今日のまとめと次のステップ
今回は、`for`ループ内における `let` の裏側の挙動について深掘りしました。
- `var` の問題:変数が使い回されるため、非同期処理(クロージャ)と組み合わせたときに意図しない値(最後の値)を拾ってしまう。
- `let` の解決策:ループのイテレーションごとに新しい環境レコード(クローン)が生成され、変数がその回ごとに独立して保持される。
- メモリ効率:エンジンが適切にガベージコレクションを行ってくれるため、無駄なメモリ消費の心配はなく、むしろ安全でクリーン。
JavaScriptの言語仕様は、こうしたランタイムの緻密なメモリ管理の積み重ねで成り立っています。「なぜそう動くのか」を低レイヤーの視点からイメージできるようになると、エラーに遭遇したときも怖くなくなりますよ。
この調子で、JavaScriptの奥深い世界を一緒に楽しくマスターしていきましょう!