クロージャを理解する
一苦労じゃなんつって
2024.12.21
クロージャの原理
JavaScriptは、オブジェクトを作成するときにメモリを自動的に確保し、参照がなくなったらメモリを解放する「ガベージコレクション」を採用しています
逆に言えば、「オブジェクト内で変数を参照し続ける限りはメモリを保持し続ける」ということです
クロージャとは、その原理を利用して「関数が状態を持っているように振る舞う」仕組みのことです
※JavaScriptでは関数もオブジェクトです
クロージャを理解することで、グローバルスコープの汚染を防ぎ、より安全で読みやすいコード設計を考えることができます
脳死コード例
状態を記憶する必要のある例として、よくある簡単なカウンタのアプリを書いてみます
// グローバル変数`count`を定義 var count = 0; // カウンターを増やす関数 function increment() { count = count + 1; return count; } // カウンターを減らす関数 function decrement() { count = count - 1; return count; } // 現在のカウントを取得する関数 function current() { return count; } // グローバル変数`count`(現在0)+1を返す console.log(increment()); // 1 // グローバル変数`count`(現在1)+1を返す console.log(increment()); // 2 // グローバル変数`count`(現在2)+1を返す console.log(increment()); // 3 // グローバル変数`count`(現在3)-1を返す console.log(decrement()); // 2 // 現在のグローバル変数`count`を返す console.log(current()); // 1
これでも動くのでまぁいいっちゃいいんですが...
問題点 グローバルスコープを汚染してしまう
変数宣言でvarとか使ってる時点でアウト感はあります
変数countはグローバル変数として定義されているため、直接値を上書きできてしまいます
// 外部から直接操作が可能 count = 100; console.log(current()); // 100
クロージャを利用したコード例
上記コードをクロージャを利用して書き直してみます
// エンクロージャ function createCounter() { // 外部からは直接アクセスできない変数`count`を定義 let count = 0; // カウンタを増やすクロージャ const increment = () => { count = count + 1; return count; } // カウンタを減らすクロージャ const decrement = () => { count = count - 1; return count ; } // 現在のカウントを取得するクロージャ const current = () => { return count; } // 戻り値=クロージャを返却 return { increment, decrement, current }; } // オブジェクト(関数)生成 const counter = createCounter(); // 変数`count`(現在0)+1を返す console.log(counter.increment()); // => 1 // 変数`count`(現在1)+1を返す console.log(counter.increment()); // => 2 // 変数`count`(現在2)+1を返す console.log(counter.increment()); // => 3 // 変数`count`(現在3)-1を返す console.log(counter.decrement()); // => 2 // 現在の変数`count`を返す console.log(counter.current()); // => 2
関数createCounterはクロージャによって内部の変数countを参照し続けるため、メモリが解放されずに状態を保持します
※クロージャの生成元=外側の関数を「エンクロージャ」と呼びます
メリット① グローバルスコープを汚染しない(カプセル化)
変数countは関数createCounterのスコープ内にあり、外部から直接操作できません
関数increment、decrement、currentはクロージャであり、変数countを操作できます
メリット② 独立した状態管理
counter2, counter3を増やしてみます
// 上記コード続き // 複数のカウンタも独立して利用可能 // オブジェクト(関数)生成 const counter2 = createCounter(); console.log(counter2.increment()); // => 1 console.log(counter2.increment()); // => 2 console.log(counter2.increment()); // => 3 console.log(counter2.current()); // => 3 // 参照してるオブジェクトが異なるため比較演算子===は成立しない console.log(counter === counter2); // => false // オブジェクト(関数)生成 const counter3 = createCounter(); console.log(counter3.increment()); // => 1 console.log(counter3.increment()); // => 2 console.log(counter3.increment()); // => 3 console.log(counter3.current()); // => 3
状態が完全に分離されるため、複雑な状態管理が必要な場合でも管理が簡単です
メモリリークに注意する
便利なクロージャも、使い方を誤ればメモリリークを引き起こす可能性があります
逆に言えばクロージャの挙動を知ることでメモリリークの原因を探る手掛かりになる可能性もあります
クロージャが大きな変数を保持し続けるケース
// 巨大なデータ const tooLargeData = "Too Large Data ...."; // エンクロージャ function createLeak(ref) { // このクロージャが巨大なデータを参照し続ける const print = () => { console.log(ref.toString()); }; return { print }; } // オブジェクト(関数)生成 const leak = createLeak(tooLargeData); // tooLargeDataを削除 delete tooLargeData; // tooLargeDataはleakが参照を持ち続けるため削除しても解放されない leak.print(); // => 参照可能なので巨大なデータが出力される
解決方法: メモリを食う処理の場合、必要がなくなったら破棄する
leak = null;
循環参照が発生するケース
以下のコードでは、obj1とobj2が互いを参照するため、関数実行後も解放されずメモリを占有します
function createCircularReference() { // 外部からは直接アクセスできない変数 const obj1 = { name: "Object 1" }; const obj2 = { name: "Object 2" }; // 循環参照が発生 obj1.ref = obj2; obj2.ref = obj1; // デバッグ目的でグローバルスコープに登録してしまう window.circularObjects = { obj1, obj2 }; } // オブジェクト(関数)生成 createCircularReference(); // `obj1`と`obj2`が解放されないままメモリに残る
このコードでは、obj1とobj2の循環参照とwindow.circularObjectsの参照が解消されない限り、obj1とobj2が解放されません
解決方法: 循環参照を手動で解除する
function createCircularReference() { // 外部からは直接アクセスできない変数 const obj1 = { name: "Object 1" }; const obj2 = { name: "Object 2" }; // 循環参照が発生 obj1.ref = obj2; obj2.ref = obj1; // デバッグ目的でグローバルスコープに登録してしまう window.circularObjects = { obj1, obj2 }; // クロージャで参照解除の仕組みを実装 const clearReferences = () => { delete obj1.ref; delete obj2.ref; delete window.circularObjects; }; return { obj1, obj2, clearReferences }; } // オブジェクト(関数)生成 const circular = createCircularReference(); // 必要がなくなったら循環参照の解除を実行 circular.clearReferences(); // ガベージコレクタにより自動的に解放される
弱参照を用いたメモリリーク対策
クロージャからは少し話が逸れてしまいますが、メモリリークついでなので弱参照について触れてみます
クロージャではオブジェクト内部に参照(強参照)が存在する限りガベージコレクタによって解放されませんが、弱参照を用いることでオブジェクト内部に参照が存在しているにもかかわらず、ガベージコレクタがそのオブジェクトを解放することを妨げません
function createWeakCircularReference() { // 外部からは直接アクセスできない変数 let obj1 = { name: "Object 1" }; let obj2 = { name: "Object 2" }; // 弱参照マップデータ生成(ついでに循環参照も発生させてみる) const weakMap = new WeakMap(); weakMap.set(obj1, obj2); weakMap.set(obj2, obj1); // 弱参照生成 const weakRef1 = new WeakRef(obj1); const weakRef2 = new WeakRef(obj2); const unsetObj = () => { // 強参照解放 obj1 = null; obj2 = null; console.log("obj1,obj2の強参照を解放しました"); }; const setHeavyStress = ({ flag=false }) => { if (flag) { // メモリ負荷を生成してガベージコレクションが起動する可能性を高める const load = []; for (let i = 0; i < 1000000; i++) { load.push({ key: `value${i}` }); } console.log("メモリ負荷を生成しました"); console.log("ガベージコレクションが起動した可能性があります"); // ※実行環境に依存するので「可能性」です // ※良いPC使ってる人は数値を上げて負荷を調整してください // ※上げすぎるとPCに良くないのでちょっとずつ調整してください(責任は負いません) } }; return { weakRef1, weakRef2, unsetObj, setHeavyStress }; } // オブジェクトの作成 const { weakRef1, weakRef2, unsetObj, setHeavyStress } = createWeakCircularReference(); // 強参照を解放するタスクをスケジュール(2秒後) setTimeout(() => { unsetObj(); }, 2000); // 負荷を与えるタスクをスケジュール(3秒後) setTimeout(() => { setHeavyStress({ flag: true }); }, 3000); // 状態確認タスクをスケジュール(1秒毎) const intervalId = setInterval(() => { // 弱参照されたオブジェクトを取得 const ref1 = weakRef1.deref(); const ref2 = weakRef2.deref(); if (!ref1 && !ref2) { logToDOM("WeakRefによるオブジェクトの参照が解放されました"); clearInterval(intervalId); } else { logToDOM("WeakRefはガベージコレクションが解放するまでオブジェクトを参照できます"); logToDOM(`WeakRef1 is alive: ${!!ref1}`); logToDOM(`WeakRef2 is alive: ${!!ref2}`); } }, 1000); // 結果表示用のDOMを作成 const outputElement = document.createElement("div"); document.body.appendChild(outputElement); // Pタグに結果を表示する const logToDOM = (message) => { const p = document.createElement("p"); p.textContent = message; outputElement.appendChild(p); };
WeakMapを使うと、キー(オブジェクト)がWeakMap以外のどこからも参照されなくなったときにガベージコレクションの対象となります
上記のコードでは関数setHeavyStressを実行してわざとメモリ負荷を与えてガベージコレクションを促しています
setHeavyStress({ flag: false });
上記のようにflag: false にしてsetHeavyStressを無効にすると、負荷が軽くガベージコレクションが起動する可能性が低いため「WeakRefはガベージコレクションが解放するまでオブジェクトを参照できます」が表示されるはずです
注意点
ガベージコレクションの挙動が実行環境に依存するので想定通りに動く確証はないです
また、setIntervalの中でconsole.logを使ってref1,ref2をブラウザの開発者ツールで出力すると、開発者ツールがobj1,obj2の強参照を保持してしまい想定通りに動かないので、DOMで結果を出力しています
まとめ
- ・クロージャを使うことでグローバルスコープを汚染しない安全で拡張性の高いコードを書ける
- ・なんでもかんでもクロージャを使えばいいというものでもなく、使いどころが重要
- ・メモリ使用量と便利さのトレードオフでもあるため、メモリリークに気を付ける
もしJavaScriptでグローバルスコープを汚しまくるコードを書きそうな衝動に駆られたら、クロージャのことを思い出してみてください