【中級者向け】forループと非同期処理の罠:クロージャが引き起こす変数共有のバグを解決する
JavaScriptのランタイム構造、そしてV8エンジンの内部挙動において、誰もが一度は踏み抜く地雷がある。それが「`for`ループと非同期処理、そしてクロージャが織りなす変数共有のバグ」だ。
「なぜ`setTimeout`や`Promise`の中でループ変数が最終値に固定されてしまうのか」
「なぜ`var`を`let`に書き換えるだけでこの問題が魔法のように解決するのか」
世の入門書やチュートリアルでは、「`var`は関数スコープで、`let`はブロックースコープだから」という紋切り型の説明で片付けられがちだ。しかし、シニアエンジニアやコアなランタイムの挙動を追う者にとって、その説明は表面的な現象をなぞっているに過ぎない。
本稿では、V8エンジンのメモリレイアウト、レキシカル環境(Lexical Environment)の生成、スコープチェーンの構築、そしてイベントループのタスクキュー消費メカニズムの深層にメスを入れ、この「バグ」の正体を極限まで解剖する。
—
1. 悲劇の再現:なぜ `var` は非同期ループで裏切るのか
まずは、多くの開発者がプロダクションコードで一度はやってしまうアンチパターンを見てみよう。
// 【アンチパターン】varを使用した非同期ループ
function executeLegacyLoop() {
console.log(‘— executeLegacyLoop 開始 —‘);
for (var i = 0; i < 3; i++) {
setTimeout(function() {
// 期待値: 0, 1, 2
// 実際の出力: 3, 3, 3
console.log(`現在のインデックス (var): ${i}`);
}, 100 (i + 1));
}
}
executeLegacyLoop();
このコードを実行すると、コンソールには `3` が3回出力される。非同期処理である `setTimeout` のコールバックが実行される頃には、同期処理である `for` ループが高速に完了し、変数 `i` の値が `3` に達しているからだ……という説明は、半分正しくて半分足りない。
本質的な問題は、「変数 `i` がどこに存在し、誰と共有されているか」にある。
V8エンジンのヒープとスコープの物理実態
`var` で宣言された変数 `i` は、関数スコープ(またはグローバルスコープ)に属する。V8エンジンのメモリ空間において、この `i` はただ一つのメモリセル(Variable Object / Environment Record のスロット)として確保される。
ループが回るたびに `i` の値は `0` から `1`、`2`、そしてループ終了条件を満たす `3` へと上書きされていく。
一方、`setTimeout` に渡された無名関数(クロージャ)は、その関数が定義された時点のレキシカル環境への参照(`[[Scopes]]`内部スロット)を保持している。ここで重要なのは、クロージャが保持しているのは「値のコピー」ではなく、「変数 `i` が存在するメモリ領域への参照(ポインタ)」であるという点だ。
そのため、100ms後、200ms後、300ms後にそれぞれのタイマーコールバックがコールされたとき、それらはすべて「同一のメモリ領域」を覗きに行く。その時、すでにその領域の値は `3` に書き換わっている。これが、すべてのタイマーが `3` を出力する物理的メカニズムである。
—
2. イベントループとマイクロ/マクロタスクの時系列解析
ここで、V8エンジンとブラウザ/Node.js環境のイベントループの挙動を正確にトレースしてみよう。
// イベントループの挙動を追うための精密なコード
console.log(‘Script Start’);
for (var i = 0; i < 2; i++) {
setTimeout(() => {
console.log(`Timer callback: i = ${i}`);
}, 0);
}
console.log(`Script End: final i = ${i}`);
実行フェーズの分解
1. 同期実行フェーズ (Call Stack):
- `Script Start` が出力される。
- `for` ループが回り、`i` が `0` から `2` までインクリメントされ、ループが終了する(この時点でメモリ上の `i = 2`)。
- 2つの `setTimeout` がWeb APIs / Timerに登録され、それぞれのコールバックがマクロタスクキュー(Macrotask Queue)にエンキューされる。
- `Script End: final i = 2` が出力される。
2. 非同期実行フェーズ (Event Loop):
- コールスタックが空になり、イベントループがマクロタスクキューから最初の `setTimeout` コールバックを取り出す。
- コールバックが実行され、コンソールに `Timer callback: i = 2` が出力される(共有されている `i` を参照しているため)。
- 2つ目のコールバックも同様に `Timer callback: i = 2` を出力する。
非同期処理が走る前に、同期コードによって変数の状態が完全に確定しきってしまうという、JavaScriptのシングルスレッド・非同期モデルの特性が如実に現れる瞬間である。
—
3. `let` の登場:ブロックスコープと「イテレーションごとの環境複製」
ES2015(ES6)で導入された `let` および `const` は、この問題を根本から解決した。現代のモダンJavaScriptでは、ループ内の非同期処理には `let` を使うのが絶対的な鉄則となっている。
// 【モダンアプローチ】letを使用した非同期ループ
function executeModernLoop() {
console.log(‘— executeModernLoop 開始 —‘);
for (let i = 0; i < 3; i++) {
setTimeout(function() {
// 期待通りに 0, 1, 2 が出力される
console.log(`現在のインデックス (let): ${i}`);
}, 100 (i + 1));
}
}
executeModernLoop();
なぜ `let` は各反復の値を保持できるのか?(V8の内部挙動)
ここからが本稿のハイライトだ。`let` を `for` ループの頭で宣言したとき、V8エンジンの内部(IgnitionインタープリタおよびTurboFanコンパイラ)では、C++のレイヤで驚くべき最適化と構造化が行われている。
ECMAScript仕様の定義(およびV8のランタイム実装)において、`for (let i = 0; i < 3; i++)` のようなループヘッダーで宣言された `let` 変数は、「ループの各イテレーション(反復)ごとに、新しいレキシカル環境(Lexical Environment)が独立して生成される」という特殊なセマンティクスを持っている。
V8の内部挙動を疑似的に紐解くと、以下のようなことが裏で行われている。
1. 初期環境の作成: ループ全体を囲む外側ブロックスコープが作成される。
2. イテレーション毎の環境バインド:
- 反復1回目 (`i = 0`): 新しい独立したメモリ領域(Environment Record)がインスタンス化され、そこに `i = 0` がバインドされる。このスコープ内で定義されたクロージャは、この「1回目専用の環境」への参照を保持する。
- 反復2回目 (`i = 1`): 前回の環境とは完全に別の新しいメモリ領域がインスタンス化され、そこに `i = 1` がバインドされる(前回値からインクリメントして複製される)。
- 反復3回目 (`i = 2`): 同様に、3回目専用の新しい環境が作られ、`i = 2` が格納される。
結果として、3つの `setTimeout` のコールバックは、それぞれ「異なる物理メモリ空間に存在する、異なる `i`」をクロージャとしてキャプチャしているため、値が競合することなく、期待通りの `0, 1, 2` が出力されるのである。
—
4. 古典的テクニックの深層:IIFE(即時実行関数式)の仕組み
では、もしレガシーな環境(ES5以前)や、どうしても `var` を使わざるを得ない特殊な制約(あるいはコードゴルフ等の極限状態)に直面した場合、どうやってこのスコープ問題に対抗すべきだろうか。
その答えが IIFE (Immediately Invoked Function Expression) によるスコープの強制隔離だ。
// IIFEを用いたスコープの強制切り分け(ES5以前のイディオム)
function executeIifeLoop() {
console.log(‘— executeIifeLoop 開始 —‘);
for (var i = 0; i < 3; i++) {
(function(currentIndex) {
// 即時実行関数に引数として渡すことで、
// その瞬間の `i` の値を関数スコープ内のローカル変数 `currentIndex` に値渡し(コピー)する
setTimeout(function() {
console.log(`現在のインデックス (IIFE): ${currentIndex}`);
}, 100 (currentIndex + 1));
})(i); // 現在の `i` の値を即時評価して渡す
}
}
executeIifeLoop();
なぜIIFEで解決するのか?
JavaScriptは「値渡し(Pass by value)」の言語である(オブジェクトの場合は参照だが、プリミティブ値 `i` は値としてコピーされる)。
IIFEを記述することで、ループの1イテレーションごとに新しい関数スコープが強制的に生成される。そのスコープの引数 `currentIndex` に、その瞬間の `i` の値がコピーされて保持されるため、外側のグローバル/関数スコープの `i` が後からどのように変化しようとも、内側のクロージャは安全に自分の領域の `currentIndex` を守り抜くことができる。
現代のトランスパイラ(Babelなど)が、古いターゲット環境(ES5)へコードをダウングレードする際に行うコード変換のアルゴリズムの原点も、まさにこの仕組みである。
—
5. 高度な応用:非同期処理の直列実行とジェネレータ・Async/Awaitの防壁
実務の現場では、単に `setTimeout` でログを出すだけでなく、ループ内で非同期APIリクエストやデータベースクエリを安全に、かつ意図した順序(あるいは並行処理)でハンドリングすることが求められる。
最後に、`let` のブロックスコープの特性を活かし、かつ `Promise` と `async/await` を組み合わせた堅牢な非同期ループの設計パターンを示そう。
// 非同期処理を安全に直列制御するモダンなパターン
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function processAsyncLoopSafely() {
console.log(‘— processAsyncLoopSafely 開始 —‘);
const tasks = [‘task_A’, ‘task_B’, ‘task_C’];
// for…of ループと let の組み合わせは、各ループごとに完全に安全なレキシカル環境を保証する
for (let [index, taskName] of tasks.entries()) {
// 各イテレーションごとに独立した index と taskName がスコープに存在する
await delay(200 (index + 1));
console.log(`処理完了: [Index: ${index}] -> ${taskName}`);
}
console.log(‘すべての非同期シーケンスが安全に完了しました’);
}
processAsyncLoopSafely();
このパターンの優位性
1. ブロックスコープの完全な分離: `for…of` と `let` の組み合わせにより、各反復における変数は完全に隔離され、意図せぬ変数の書き換えや巻き上げによるバグが物理的に排除される。
2. イベントループの予測可能性: `await` を適切に使用することで、マイクロタスクキューの順序制御が明示的になり、コールバック地獄(Callback Hell)や競合状態(Race Condition)を防ぐ堅牢なアーキテクチャが構築できる。
—
結び:ランタイムの物理法則をハックせよ
JavaScriptは、一見すると「動的でルーズな言語」に見えるかもしれない。しかし、その背後でV8エンジンは、メモリの効率化、隠しクラス(Hidden Classes / Maps)によるプロパティアクセスの高速化、そしてレキシカル環境の厳密な管理をミリ秒単位で実行している。
「なぜ `let` でバグが消えるのか」という問いの裏には、ランタイムがメモリ空間をどのように切り分け、クロージャがどのポインタにしがみついているかという「物理の真実」がある。
コードを書くとは、単に動く文字列を並べることではない。V8という巨大なランタイムエンジンと対話し、そのメモリモデルの挙動を完全に脳内でトレースしながら、完璧な防壁を構築することなのだ。この知見を武器に、あなたのコードから不確実性を完全に排除してほしい。