クロージャについて、その名称は知っているものの具体的な仕組みについてはよく分かっていなかったので、この機会に調べてみました。
前提知識としてのスコープ
クロージャを理解するにあたっての前提知識として、基本ではありますが重要な概念であるスコープについて軽く触れていきます。
スコープとは、実行中のコードから参照できる変数や関数の範囲のことです。スコープの中で定義された変数はそのスコープ内でのみ参照でき、スコープの外側からは参照できません。
例えば、以下のように関数内で定義された変数を外側から参照しようとするとエラーとなります。
function fn() { const test = 'iimon' } console.log(test) // > Uncaught ReferenceError: test is not defined
JavaScriptを例でいえば、スコープには大きくグローバルスコープ、関数スコープ、ブロックスコープの3種類があります。
グローバルスコープ
どこからでもアクセスできる最も外側のスコープです。
const global = 'グローバル'; function fn() { console.log(global); // 参照可 }
ちなみに、undefinedやArrayなども、プログラム実行時に自動的に定義されるビルトインオブジェクトとして、グローバルスコープに属しています。
関数スコープ
文字通りその関数内でのみ有効なスコープです。
function fn() { const test = 'iimon'; console.log(test); // 参照可 } console.log(test); // > Uncaught ReferenceError: test is not defined
ブロックスコープ
{}の中だけ有効なスコープです。ifやfor文もブロックスコープを形成します。
{ let message = 'hello'; const count = 1; } console.log(message); // ReferenceError
レキシカルスコープ(字句スコープ)
レキシカルスコープとは、記述する場所によって、参照できる変数が異なるスコープのことを指します(字句スコープとも呼ばれます)。 具体的には実行時ではなく、コードの記述位置でスコープが決まります。
const value = 'outer'; function inner() { console.log(value); } function outer() { const value = 'inner'; inner(); // 'outer' } outer();
inner()はouter()の中から呼ばれていますが、出力されるのはinnerが書かれた場所から見えるvalue=グローバルの'outer' です。
スコープチェーン
これを踏まえ、スコープチェーンについて見ていきます。 スコープチェーンとは、内側から外側へ順番に変数を探していく仕組みを指します。
以下の例を見てください。
const a = 1; function outer() { const b = 2; function inner() { const c = 3; console.log(a, b, c); // 1, 2, 3 } inner(); } outer();
ここで、inner()の中から変数aを参照する際、inner()内→outer()内→そこになければさらにその外側へというようにスコープを外へ外へと探索していきます。
このように関数がネストしたような状態だと、変数の参照がチェーン状に繋がり、スコープチェーンを形成します。
クロージャ
ここまでを踏まえ、いよいよクロージャについてです。
通常、関数の実行が終われば、その関数で用いられる変数は不要なものと見なされ、ガベージコレクションによってメモリ上から破棄されます (正確には、関数内で定義したオブジェクトや配列を戻り値として返し、その結果が外部から参照されているような場合はその限りではありません)。 しかし、内側の関数が外側の変数を参照している場合、その変数は破棄されず保持し続けます。
function makeCounter() { let count = 0; return function innerFn() { count += 1; return count; } } const counter = makeCounter(); // makeCounterの実行はここで終了 console.log(counter()); // 1 console.log(counter()); // 2 console.log(counter()); // 3
上記の例では、makeCounter()の実行は1行で完了しており、通常ならその時点で関数内のcount変数は破棄されているはずです。 しかし、結果として返されるcounter(innerFn())関数がcountを参照しているため、破棄されずに残り続けているという状態になります。
先述の内容を踏まえれば、配置場所による参照先が決まるというレキシカルスコープの仕組みを前提として、スコープチェーンによりcounter呼び出し時のinnerFn()→makeCounter()の順でcount変数が探索、参照され、makeCounter()のcount変数が保持されているという訳です。
これがクロージャによる作用です。
もう一つ例を見てみましょう。
function greet(name) { return function () { console.log(`こんにちは, ${name}!`); }; } const sayHi = greet('イイモンくん!'); sayHi(); // こんにちは, イイモンくん! → greetの実行は終了しているが、nameを覚えている
通常であればgreet('イイモンくん!')が実行された時点で引数nameは破棄されますが、返された関数がnameを参照しているため、name = 'イイモンくん'ごとsayHiの中に閉じ込められている(closure)状態です。
クロージャの実例
ここから、クロージャの実際の使い方を少し具体的に説明します。
プライベート変数の定義
クロージャを使うことで、外から参照できないプライベート変数を定義することができます。
function createCounter() { let count = 0; return { increment: () => { count += 1; }, decrement: () => { count -= 1; }, get: () => count, } } const counter = createCounter(); counter.increment(); console.log(counter.get()); // 1 console.log(counter.count); // undefined → countは外部から参照不可
上記では、外部からcountを直接操作できないカウンタ関数をクロージャで作成しています。
countはcreateCounter()のローカル変数のため、関数外ではスコープ外扱いとなり、内部のincrement、decrement、getだけが参照可能です。
そのため、外部から直接counter.countのようにアクセスはできませんし、'counter.count = 9999'のような勝手な書き換えも防ぐことができます。
JavaScriptにはES6でクラスが登場するまでprivateキーワードがなかったため、その代替手段としてクロージャが使われていたこともあったようです。
クラスと同じような書き方ができますが、インスタンスを大量に作る必要があったり、継承など拡張の余地がある場合は素直にクラスを使うのがよさそうです。 ただ、そこまでインスタンス化の必要がなかったり、関数として取り回すに十分といった端的なケースならクロージャも一つの手として有効かなと思います。
関数ファクトリーとしての使い方
また、引数を先に固定しておくことで、特定の目的に沿った関数を共通的に生成することもできます。
function createMultiplier(factor) { return (n) => n * factor; } const double = createMultiplier(2); const triple = createMultipllier(3); double(5); // 10 triple(10); // 15
この辺りは、部分適用やカリー化の考えとも通じてきそうです。
注意点
以下のような場合、クロージャによってオブジェクトが保持され続け、メモリリークにつながる可能性があります。
function setupButton() { const hugeData = loadHugeData(); // 大量のメモリを消費するデータ const button = document.getElementById('btn'); // クロージャがhugeDataへの参照を保持 button.addEventListener('click', function handleClick() { console.log(hugeData); }); } setupButton();
画面が切り替わるなどしてbutton要素がDOMから消えた(setupButton()の用が済んだ)としても、メモリ上にはhandleClick関数が残り続けることがあります。 その場合、handleClick()が参照しているhugeDataもメモリに残り続け、結果としてメモリリークに繋がる可能性があります。
対策としては、用が済んだタイミングでremoveEventListenerを使ってイベントリスナーを解除のが有効です。
以下のようなsetInterValの放置もリスクがあります。
function startPolling() { const state = { status: 'loading', hugeData: ~~~ }; // クロージャーがstateへの参照を保持 setInterval(function checkStatus() { console.log(state.status); }, 1000); } startPolling();
setIntervalは、clearIntervalを明示的に呼ばない限り裏側で動き続けるため、内側のcheckStatus()は実行可能な状態として保持され続け、それに紐づく stateオブジェクト全体もメモリに居座り続けることになります。
総じて言えることは、クロージャを使う際には内側の関数がいつ用済みになるかを意識することが重要ということかなと思います。
まとめ
ざっくりにはなりますがこれまでの流れを整理すると、
JavaScriptは書いた場所で参照先が決まるというレキシカルスコープに基づいている。
関数はスコープチェーンにより、外側のスコープまで辿って変数を探索する。
内側の関数が外側の変数を参照する限り、その変数は破棄されずに残り続ける(クロージャ)
ということになるかと思います。
なんとなく複雑な概念だと思って理解を先送りにしてきてしまっていましたが、スコープ等基本的な考えを押さえていけば、さほど難しいことを言っているわけではないと分かりました(むしろスコープの考えを辿っていけば当然の状態)。
個人的には、クロージャを理解することにうよって、意図せぬクロージャの実現による不具合が発生しうるという観点を持てたのは少なからぬ収穫であったかなと思います(知らなければ割とハマりそうなところでもある気がするので)。
ここまで読んでいただきありがとうございます。
下記リンクよりご応募お待ちしております。
iimon採用サイト / Wantedly / Green
参考文献・記事
・関数とスコープ · JavaScript Primer #jsprimer
・JavaScriptで領域を閉じ込める術(初めて使うJavaScriptのクロージャー) #JavaScript - Qiita
・外村将大『独習JavaScript 新版』, 2023, 翔泳社