JS クロージャ実戦:理論から実務レベルの応用まで
本章は、これまでの章で紹介したレキシカルスコープとクロージャの理解を直接の土台としています。
ここでは実用的な応用に焦点を当て、一般的なプログラミング上の問題を解決する方法や、JavaScript アプリケーションにおける状態(State)の管理方法をデモンストレーションします。
私たちの目標は、クロージャの理論を理解する段階から、現実のシナリオで自信を持って実装できる段階へと移行することです。
1. クロージャを用いたカウンタの実装
クロージャの最も古典的な例の一つが、カウンタの作成です。なぜこれが有用なのか、そしてクロージャがどのようにそれを可能にしているのかを分析してみましょう。
1.1 基本的なカウンタの例
function createCounter() {
let count = 0; // `count` は外部関数のスコープ内で初期化される
return {
increment: function() { // `increment` 関数は `count` に対してクロージャを形成する
count++;
return count;
},
decrement: function() { // `decrement` 関数も `count` に対してクロージャを形成する
count--;
return count;
},
getValue: function() {
return count;
}
};
}
const counter = createCounter(); // カウンタのインスタンスを作成
console.log(counter.increment()); // 出力: 1
console.log(counter.increment()); // 出力: 2
console.log(counter.decrement()); // 出力: 1
console.log(counter.getValue()); // 出力: 1解説:
createCounter()関数: この外部関数は、count変数が宣言されるスコープを定義します。- count 変数:
0で初期化され、カウンタの値を保持します。重要なのは、countがグローバル変数ではなく、createCounter()関数のローカル変数であるという点です。 - 返却されるオブジェクト:
createCounter()関数は、increment(増加)、decrement(減少)、getValue(値の取得)という3つのメソッドを含むオブジェクトを返します。 - クロージャの動作: 各メソッド(
increment、decrement、getValue)はcount変数を閉鎖(close over)しています。これは、createCounter()関数の実行が完了した後でも、これらのメソッドがcountへのアクセス権を保持し続けることを意味します。クロージャがなければ、countにアクセスできなくなるか、メソッドを呼び出すたびにリセットされてしまいます。 - インスタンスの作成:
const counter = createCounter();により、カウンタの特定のインスタンスが作成されます。各インスタンスは独自のcount変数を持ち、他のカウンタからは独立しています。
1.2 高機能なカウンタの例
カウンタの例を拡張して、初期値の設定や増分ステップ(Step)の指定機能を追加してみましょう。
function createAdvancedCounter(initialValue = 0, step = 1) {
let count = initialValue;
return {
increment: function() {
count += step;
return count;
},
decrement: function() {
count -= step;
return count;
},
getValue: function() {
return count;
},
setValue: function(newValue) {
count = newValue;
}
};
}
const counter1 = createAdvancedCounter(); // デフォルトの initialValue と step を使用
const counter2 = createAdvancedCounter(10); // initialValue = 10, step = 1
const counter3 = createAdvancedCounter(100, 5); // initialValue = 100, step = 5
console.log(counter1.increment()); // 出力: 1 (0から開始し、1増加)
console.log(counter2.increment()); // 出力: 11 (10から開始し、1増加)
console.log(counter3.increment()); // 出力: 105 (100から開始し、5増加)
counter3.setValue(200);
console.log(counter3.getValue()); // 出力: 200解説:
- パラメータ:
createAdvancedCounterはinitialValueと step を引数として受け取るようになり、柔軟性が向上しました。デフォルト引数が使用されているため、引数が渡されない場合は0から開始し、毎回1ずつ増加します。 - カスタマイズ: 各カウンタのインスタンスは、異なる開始値と増分ステップで初期化できるようになりました。
- setValue メソッド:
setValueメソッドが追加され、カウンタの値を直接特定の数値に設定できるようになりました。
2. クロージャによるプライベート変数の実現
クロージャは、JavaScript においてプライベート変数をシミュレートするために頻繁に使用されます。JavaScript には Java や C++ のような言語にある組み込みのプライベート変数サポートが(かつては)ありませんでしたが、クロージャによって同様の効果を得るメカニズムが提供されます。
2.1 課題:データのカプセル化
オブジェクト指向プログラミングにおいて、データのカプセル化(Encapsulation)とは、データとそのデータを操作するメソッドを一つにまとめ、オブジェクトの特定のコンポーネントへの直接的なアクセスを制限することです。これは以下の理由で重要です。
- 予期せぬ修正の防止: データが定義済みのメソッドを通じてのみ変更されることを保証します。
- 実装詳細の隠蔽: そのオブジェクトを使用するコードに影響を与えずに、オブジェクトの内部動作を変更できるようにします。
2.2 解決策としてのクロージャ
クロージャを使用すると、特定の関数(およびその内部関数)の中からしかアクセスできない変数を作成でき、実質的にそれらを「プライベート」変数にすることができます。
2.3 事例:銀行口座
function createBankAccount(initialBalance) {
let balance = initialBalance; // `balance` はプライベート変数
return {
deposit: function(amount) {
if (amount > 0) {
balance += amount;
return balance;
} else {
return "預金額は正の数である必要があります。";
}
},
withdraw: function(amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
return balance;
} else {
return "残高不足または引き出し額が無効です。";
}
},
getBalance: function() {
return balance;
}
// 関数の外部から `balance` に直接アクセスすることはできない
};
}
const account = createBankAccount(1000);
console.log(account.deposit(500)); // 出力: 1500
console.log(account.withdraw(200)); // 出力: 1300
console.log(account.getBalance()); // 出力: 1300
// 以下のコードはエラーになるか、あるいは何の効果もありません。
// なぜなら `balance` はプライベートだからです:
// account.balance = 0; // これで実際の残高が変わることはありません
console.log(account.getBalance()); // 出力: 1300解説:
- balance 変数:
balance変数はcreateBankAccount関数内部で宣言されています。関数の外部から直接アクセスすることはできません。 - メソッド:
deposit、withdraw、getBalanceメソッドはcreateBankAccount内部で定義されており、これらはbalance変数に対してクロージャを形成しています。 - プライバシー:
createBankAccount外部のコードは、これらのメソッドを介してのみbalanceと対話できます。balance変数を直接読み取ったり修正したりする方法はありません。 - カプセル化: これによりカプセル化が提供されます。内部状態(残高)が保護され、それに対するアクセスは定義されたインターフェース(メソッド)を通じて制御されます。
2.4 プライベート変数のメリット
- データの整合性:
balanceに対する予期せぬ、あるいは悪意のある修正を防ぎます。depositとwithdrawメソッドのみが残高を変更でき、それらの中に検証ロジック(例:金額が負でないかチェックする)を含めることができます。 - 抽象化: 銀行口座の内部表現(残高がどのように保存されているか)が外部の世界から隠されます。これにより、後で実装の詳細を変更しても、
createBankAccount関数を使用しているコードを壊さずに済みます。例えば、ユーザーの入出金方法に影響を与えずに、利息の計算方法を変更するといったことが可能です。 - メンテナンス性: データへのアクセスを制御することで、コードの理解とメンテナンスが容易になります。
3. 非同期処理における状態の保持
setTimeout、setInterval、イベントリスナーなどの JavaScript における非同期(Asynchronous)操作を扱う際、クロージャは特に有用です。これらは非同期操作が最終的に実行されるときに、外部スコープからの値を「記憶」しておくことを可能にします。
3.1 課題:非同期コードと変数スコープ
非同期操作は即座には実行されません。それらはキューに入れられ、現在のコードの実行が終了した後に実行されます。もし非同期コールバック内で外部スコープの変数を直接使用しようとすると、予期せぬ挙動を引き起こすことがあります。
3.2 事例:setTimeout とループ
以下のコードを考えてみましょう。短い遅延の後に配列内の各要素のインデックスを出力しようとしています。
function delayedLog() {
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, i * 1000); // i * 1000 ミリ秒の遅延
}
}
delayedLog(); // 出力: 5, 5, 5, 5, 5 (遅延の後)なぜ期待通りに動作しないのでしょうか?
問題は、setTimeout のコールバックが最終的に実行される頃には、ループがすでに完了しており、すべてのクロージャにおいて i の値が 5 になっていることです。var キーワードは関数スコープで i を宣言するため、すべてのコールバックが同じ i 変数を共有してしまいます。
3.3 クロージャによる値のキャプチャ
この問題を解決するために、クロージャを使用してループの各反復(Iteration)における i の値をキャプチャ(Capture)することができます。
function delayedLogCorrected() {
for (var i = 0; i < 5; i++) {
(function(index) { // 即時実行関数式 (IIFE)
setTimeout(function() {
console.log(index);
}, index * 1000);
})(i); // i の現在の値を引数として渡す
}
}
delayedLogCorrected(); // 出力: 0, 1, 2, 3, 4 (遅延の後)解説:
- IIFE:
setTimeoutの呼び出しを即時実行関数式(IIFE)でラップしています。これにより、ループの各反復ごとに新しいスコープが作成されます。 - index 引数: IIFE は
iを引数として受け取り、それをパラメータindexに代入します。これにより、各反復で新しい変数indexが作成され、その時のiの値が保持されます。 - クロージャ:
setTimeoutのコールバックは、その特定の反復における正しい値を持つindex変数を閉鎖(close over)しています。
3.4 let を使用する方法(モダンなアプローチ)
よりモダンで簡潔な解決策は、var の代わりに let キーワードを使用することです。let はブロックスコープ(Block Scope)の変数を宣言するため、ループの反復ごとに自動的に新しい変数が作成され、IIFE を使わなくても実質的にクロージャが作成されます。
function delayedLogLet() {
for (let i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, i * 1000);
}
}
delayedLogLet(); // 出力: 0, 1, 2, 3, 4 (遅延の後)解説:
letはブロックスコープ:letキーワードはforループの各反復において、iの新しいバインディングを作成します。これは、各setTimeoutコールバックが異なるi変数を閉鎖(close over)し、それぞれの変数がその反復固有の値を保持していることを意味します。
モダンな JavaScript では let を使用する方法が推奨されますが、IIFE による方法はクロージャの基本概念や、変数の値をキャプチャする方法を理解する上で非常に価値があります。