こんにちは!フロントエンドからNode.jsの内部挙動まで、日夜JavaScriptと向き合っているシニアアーキテクトです。
今回は、JavaScriptの変数宣言(`let`)と、ループ処理の組み合わせが生み出す「ブロックスコープとメモリ管理の深淵」についてお話しします。
「`for`ループの中で`let`を使うと、なぜか動くけれど、メモリの裏側では何が起きているんだろう?」
「クロージャを組み合わせたときに、変数が消えずに残る理由って?」
ここをクリアすれば、JavaScriptのランタイムが頭の中で完全にビジュアライズできるようになり、メモリリークとは無縁の堅牢なコードが書けるようになりますよ。さあ、一緒にV8エンジンの内部世界を覗いてみましょう!
—
1. 基礎知識:なぜ`let`の登場で世界が変わったのか?
JavaScriptの歴史の長い間、変数の宣言には`var`しかありませんでした。この`var`、実は「関数スコープ」という性質を持っており、ブロック(`{}`)を無視して外側に漏れ出すという厄介な特徴がありました。
特に`for`ループでこれが問題になりました。例えば、次のようなコードを見たことはありませんか?
// varを使った古い書き方
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 何が出力されるでしょうか?
}, 100 i);
}
// 出力結果: 3 が 3回 連続で表示される(0, 1, 2ではない!)
「あれ? 0, 1, 2 と順番に出てほしいのに、全部 `3` になってしまった……」というのは、JavaScriptを学び始めた誰もが通る登竜門ですよね。
これは、`var`で宣言された`i`がループブロックの外(関数またはグローバル)に1つしか存在せず、非同期処理(`setTimeout`)が実行される頃には、ループがすでに完了して`i`が`3`になっていたからです。
救世主 `let` の登場
ES2015(ES6)で導入された`let`は、「ブロックスコープ」をもたらしました。`for`ループの頭で`let i`と書くと、なんと「ループの回数分だけ、変数のクローン(レキシカル環境)が新しく作られる」という魔法のような仕様になっています。
同じコードを`let`で書き換えてみましょう。
// letを使った現代的な書き方
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // ちゃんと 0, 1, 2 と出力される!
}, 100 i);
}
// 出力結果: 0, 1, 2 が順番に出力される
なぜ`let`だと、こんなに美しい挙動になるのでしょうか? 次のセクションで、V8エンジンのメモリ空間を覗きながらその正体に迫ります。
---
2. メモリの裏側:`let`が生成する「レキシカル環境」のクローン
JavaScriptの実行エンジン(Google ChromeやNode.jsで使われているV8など)は、変数を管理するために「レキシカル環境(Lexical Environment)」というメモリ上の領域を作ります。
`for (let i = 0; i < 3; i++)` のループが回るとき、V8エンジンは裏側で次のような驚きの処理を行っています。
1. ループの外側(親スコープ)に、ループ制御用のベースとなるレキシカル環境を作る。
2. ループが1回回る(イテレーションの)たびに、その瞬間だけの「新しいレキシカル環境(クローン)」を独立して生成する。
3. その新しい環境の中に、その時点の`i`の値をコピーして保存する。
4. ループ内の非同期関数(クロージャ)は、自分が生まれた瞬間に存在していた「その回専用のレキシカル環境」をずっと記憶(参照)し続ける。
イメージとしては、以下のようなカプセルがループの回数分(0回目、1回目、2回目)だけ別々に作られている状態です。
[ グローバル・親スコープ ]
└─ forループ全体の枠組み
├── [ レキシカル環境クローン #0 ] -> i = 0 を保持
│ └─ 0回目の setTimeout がこの環境を指している
├── [ レキシカル環境クローン #1 ] -> i = 1 を保持
│ └─ 1回目の setTimeout がこの環境を指している
└── [ レキシカル環境クローン #2 ] -> i = 2 を保持
└─ 2回目の setTimeout がこの環境を指している
「1つの変数を使い回す」のではなく、「ループの回数分、別々の変数(箱)が新しく用意される」からこそ、それぞれのクロージャが異なる値(0, 1, 2)を保持できるわけですね。
—
3. クロージャとガベージコレクション(GC)の切っても切れない関係
ここで一つの疑問が浮かび上がります。
「ループの回数分だけレキシカル環境が作られるなら、メモリをたくさん消費して危険なのでは?」
ご安心ください。ここでJavaScriptの自動メモリ管理機構である「ガベージコレクション(GC)」が賢く働きます。
クロージャが絡んだときのメモリの寿命
通常、ブロックを抜けた変数やレキシカル環境は、その役目を終えた瞬間にGCによってメモリから綺麗に解放(Sweep)されます。しかし、「クロージャ」がその環境内の変数を参照し続けている場合話は別です。
以下のコードを見てみましょう。
function createHandlers() {
const handlers = [];
for (let i = 0; i < 3; i++) { // この関数は、ループ内のレキシカル環境(i)を「キャプチャ(保持)」するクロージャになる handlers.push(function() { return i; }); } return handlers; } const myHandlers = createHandlers(); console.log(myHandlers[0]()); // 0 console.log(myHandlers[1]()); // 1 このケースでは、`createHandlers()`の実行が終わって`for`ループを抜けた後も、返却された配列(`myHandlers`)の中にある無名関数たちが、それぞれのレキシカル環境への参照を握りしめています。 そのため、これら3つのレキシカル環境のクローンは、GCの回収対象から外れ、メモリ上に生き続けます。
メモリリークを防ぐための意識
もし、このハンドラー配列が不要になったにもかかわらず、どこかから参照され続けたり、何万回もの巨大なループで同様のクロージャを大量生成して放置したりすると、不要なレキシカル環境がヒープメモリを圧迫し続ける原因(メモリリーク)になります。
モダンなJavaScriptエンジンは非常に優秀ですが、「スコープのクローンとクロージャが組み合わさると、メモリの寿命がその関数(クロージャ)の寿命に依存するようになる」という物理法則(?)だけは、エンジニアとして常に頭に入れておく必要があります。
—
4. 実践:パフォーマンスとメモリのトレードオフを理解する
最後に、実務で役立つちょっとしたテクニックと注意点をご紹介します。
パフォーマンスを極限まで気にする極端なコードレビューなどで、「`let`をループ内で使うと、毎回スコープのオブジェクトが生成されて遅くなるから、外側で一度`let i;`と宣言して使い回した方がいいのでは?」という議論を見かけることがあります。
結論から言うと、現代のV8エンジン(TurboFanコンパイラなど)は凄まじく最適化されているため、通常のアプリケーションコードの規模でこのオーバーヘッドを気にする必要はほとんどありません。
それよりも、可読性が下がり、`var`時代のようなスコープ汚染やバグを生むリスクのほうが圧倒的に高いです。
黄金律:変数は「必要な場所の、最も狭いスコープ」で宣言する
これこそが、モダンJavaScriptにおける最高のメモリ管理であり、バグを防ぐ特効薬です。
// 良い例:使う直前、かつブロックごとに let で宣言する
function processItems(items) {
for (let i = 0; i < items.length; i++) {
let currentItem = items[i]; // このブロックだけで生きる変数
let processedValue = transform(currentItem);
console.log(processedValue);
}
// この時点で、currentItem や processedValue 用のメモリは即座に解放の準備が整う
}
変数の寿命が短ければ短いほど、V8エンジンのガベージコレクタは効率よく働き、メモリ空間をクリーンに保ってくれます。
---
まとめ
いかがでしたでしょうか? 今回のポイントをギュッと凝縮して振り返ります。
1. `let`のブロックスコープは、`for`ループのイテレーションごとに独立した変数のクローン(レキシカル環境)を生み出す。
2. この仕組みのおかげで、非同期処理やクロージャを使っても、ループ変数が上書きされるバグが綺麗に防がれている。
3. クロージャが変数を参照し続けている間は、そのレキシカル環境のクローンはメモリ上に保持されるため、不用意な巨大ループや不要な参照の保持には少しだけ気を配る。
4. 基本方針として、変数は常に「最も狭いスコープで`let` / `const`宣言する」ことで、コードの安全性もメモリ効率も最大化される。
ここをしっかりと理解できれば、あなたの書くJavaScriptコードは、単に「動く」だけでなく、ランタイムの挙動まで見通した「美しいコード」になりますよ。
それでは、次回の記事でも、さらに一歩進んだエンジニアリングの世界でお会いしましょう!バッチリマスターしていきましょうね。