非同期処理とループにおける変数のスコープ問題
以下のJavaScriptコードを実行した際のコンソール出力結果について考察します。
var counter = 0;
while (counter < 3) {
setTimeout(function() {
console.log("Async output: " + counter);
}, 100);
console.log("Sync output: " + counter);
counter++;
}
console.log("Loop completed: " + counter);
このコードの出力順序と値は、JavaScriptのイベントループと変数スコープの仕様を理解していないと直感に反する結果になります。実際の出力は以下のようになります。
Sync output: 0
Sync output: 1
Sync output: 2
Loop completed: 3
Async output: 3
Async output: 3
Async output: 3
イベントループとタイマーのメカニズム
上記の挙動を理解するには、setTimeoutとイベントループ(Event Loop)の仕組みを知る必要があります。
setTimeoutやsetIntervalといったタイマー関数は、指定された時間が経過した瞬間にコールバック関数を実行するわけではありません。これらは「マクロタスク」としてタスクキューに追加され、現在の同期コード(呼び出し履歴スタック)がすべて完了した後に、イベントループによってキューから取り出されて実行されます。
以下の例で確認してみましょう。
console.log("Start");
setTimeout(function() {
console.log("Timer executed");
}, 0);
console.log("End");
遅延時間を0ミリ秒に設定しても、出力は「Start」、「End」、「Timer executed」の順になります。これは、setTimeoutのコールバックがタスクキューに回され、メインスレッドの同期処理が完全に終了するまで待機させられるためです。
クロージャの概念とスコープチェーン
最初のループの例で「Async output」がすべて3になった理由は、varで宣言されたcounter変数が関数スコープ(この場合はグローバルスコープ)に属しており、ループ終了時には値が3になっているためです。非同期コールバックが実行される時点では、すでにループが終了しており、同じcounter変数を参照しています。
これを解決し、各イテレーションの変数の状態を保持するために「クロージャ」を利用します。クロージャとは、外部関数のスコープにある変数への参照を維持する内部関数のことです。
即時実行関数(IIFE)によるスコープの分離
従来のアプローチでは、即時実行関数式(IIFE)を使用してブロックごとに新しいスコープを作成し、変数を束縛します。
var index = 0;
while (index < 3) {
(function(currentIndex) {
setTimeout(function() {
console.log("Closure Async: " + currentIndex);
}, 100);
})(index);
index++;
}
この場合、currentIndexは各IIFEのローカル変数として機能し、それぞれの非同期コールバックが固有の値を保持します。
ブロックスコープ(let/const)の活用
モダンなJavaScript(ES6以降)では、letやconstを使用してブロックスコープを形成することで、より簡潔にこの問題を解決できます。
for (let step = 0; step < 3; step++) {
setTimeout(function() {
console.log("Block Scoped Async: " + step);
}, 100);
}
letで宣言された変数はループの各イテレーションごとに新しいバインディングを作成するため、IIFEを使用せずに意図した通りの出力(0, 1, 2)を得ることができます。
クロージャの特性と設計への影響
クロージャはJavaScriptにおいて強力な機能ですが、その特性を正しく理解しておく必要があります。
データのカプセル化と状態の保持
クロージャを利用すると、外部からのアクセスを制限したプライベートな状態を保持できます。
function createSecureVault(initialSecret) {
let secret = initialSecret;
return {
getSecret: function() {
return secret;
},
updateSecret: function(newSecret) {
secret = newSecret;
}
};
}
const vault = createSecureVault("password123");
console.log(vault.getSecret()); // "password123"
vault.updateSecret("newpassword456");
// 変数 'secret' には外部から直接アクセスできない
メモリ管理への配慮
クロージャは、外部関数の実行が終了した後も内部変数への参照を維持します。これにより、ガベージコレクションの対象から外れ、メモリ上に保持され続けます。大規模なデータセットをクロージャ内で保持したり、不要になったクロージャの参照を解放しなかったりすると、メモリリークの原因となるため、注意深いメモリ管理が求められます。