クロージャ
Closure ・ クロージャ
関数が定義された場所の変数を捕まえたまま持ち歩く仕組み。状態の隠蔽やコールバックの土台になる。
✦ 30秒で試す — 航海関数はとっくに終わったのに、その変数はまだ生きているこのあと c2() を1回呼ぶと、返ってくる値は?予想してみる概要
クロージャは、関数が「自分が定義された場所で見えていた変数」を捕まえたまま持ち歩く仕組みです。関数を値として別の場所に渡したり、後で実行したりしても、定義した時点で参照していた外側の変数に引き続きアクセスできます。しかも読むだけでなく書き換えることもできます。関数と、その関数が生まれた環境(変数の束)の組 — それがクロージャです。
JavaScript を書いていれば、意識しなくても毎日クロージャを使っています。イベントハンドラが外側で宣言した変数を参照するとき、コールバックがリクエスト時の情報を覚えているとき、その裏で働いているのはこの仕組みです。Python・Ruby・Swift・Kotlin・Rust など、関数を値として扱えるモダンな言語のほぼすべてが備えています。
「関数に状態を持たせる」最小の道具でもあり、関数型プログラミングでの部品の合成から、状態管理ライブラリや React のフックまで、現代のプログラミングの広い範囲を支えています。その一方で、「何を捕まえているか」を意識しないと、ループ変数の取り違えやメモリの抱え込みといった分かりにくいバグの原因にもなります。
なぜ生まれたか
関数を値として渡せる「第一級関数」を言語に入れると、避けて通れない問題が出てきます。関数が外側の関数のローカル変数を参照したまま、その外側の関数の外へ持ち出されたらどうなるか、という問題です。ローカル変数はふつうコールスタックのフレームに置かれ、関数が戻ればフレームごと消えます。内側の関数がその変数を参照したまま返されると、「もう終わった関数の変数」への参照が残ってしまいます。
初期の LISP は、変数を「呼び出された時点で見えている変数」から探す動的スコープを採っていました。そのため、関数を別の場所へ渡して呼ぶと、定義したときとは別の同名の変数をつかんでしまいます。この食い違いは funarg 問題(関数引数の問題)と呼ばれ、1960年代の LISP 実装者を悩ませました。
答えを出したのは、変数の参照先を「定義された場所」で決めるレキシカルスコープと、関数を作るときに定義場所の環境ごと閉じ込める(close over する)という設計です。Peter Landin は1964年の論文で、ラムダ式とその環境の組を評価する機械(SECD マシン)を示し、この組を「クロージャ」と呼びました。1975年に Gerald Sussman と Guy Steele が作った Scheme が、レキシカルスコープとクロージャを LISP の系譜に持ち込み、その後の言語の標準になりました。変数の寿命を「スコープを抜けたら終わり」から「参照される限り生きる」に変えたことで、関数を安心して値として持ち運べるようになったのです。
詳細
レキシカルスコープ — 「どこで定義されたか」で決まる
クロージャの土台はレキシカルスコープ(静的スコープ)です。関数の中に外側の変数名が出てきたとき、それがどの変数を指すかは「どこから呼ばれたか」ではなく「ソースコード上のどこで定義されたか」で決まります。コードを読むだけで参照先が決まるので、人間にも処理系にも扱いやすく、現代の主要言語はほぼすべてこちらを採っています。クロージャは、このレキシカルスコープを「関数がスコープの外へ持ち出されたあと」まで守り抜く仕組みだと言えます。
定番のカウンタの例が、仕組みの核心を最小のコードで見せてくれます。
function makeCounter() {
let count = 0;
const name = "tmp"; // 内側の関数が使わない変数
return () => ++count; // count だけを捕まえる
}
let c1 = makeCounter();
c1(); // 1
c1(); // 2
makeCounter の実行はとっくに終わっているのに、返された関数を呼ぶたびに count は増え続けます。変数 count が、関数と一緒に生き延びているのです。
変数はどこで生きているのか
これを支えているのがメモリの置き場所の工夫です。ふつうのローカル変数はスタックのフレームに置かれ、関数が戻ると消えます。処理系はコンパイルの段階で「内側の関数に捕まえられる変数」を見つけ出し、その変数だけをヒープ上の「環境」(V8 ではコンテキストと呼ぶ小さなオブジェクト)に置きます。関数オブジェクトもヒープ上の値で、コードへの参照に加えて、この環境への参照を持っています。
makeCounter が戻ると、フレームは崩れて name は消えます。一方の count は環境の中にあり、返された関数がその環境を指しているので残ります。関数を持っている変数 c1 から、関数、環境へと参照をたどれる限り、ガベージコレクションは count を回収しません。c1 = null として最後の参照を手放すと、関数も環境もどこからもたどれなくなり、次の GC で回収されます。変数の寿命がスコープではなく参照で決まるという性質は、GC があってはじめて安全に成り立ちます。
次の部品で確かめてください。makeCounter() を呼んでから return すると、フレームが崩れて name は消え、count の箱だけが関数からの矢印に引かれてヒープに残ります。関数を何度か呼んだあと、c1 の矢印を切って GC を走らせると、関数と環境がまとめて回収されます。
- function makeCounter() {
- let count = 0;
- const name = "tmp"; // 捕まえられない
- return () => ++count; // count だけを捕まえる
- }
いちばん下のフレームにある makeCounter() を押して、関数を呼んでみてください。
コールバックとの相性 — クロージャが日常になる場所
クロージャが最も活躍するのは、コールバックやイベントハンドラのように「関数を後で・別の場所で実行する」場面です。ボタンのクリックハンドラがそのボタンに対応するデータを覚えている、非同期処理が終わったときにリクエストを始めた時点の文脈で続きを実行できる — 「そのときの文脈を関数に同梱して未来へ送る」ことが、クロージャの実用上の本質です。JavaScript では、イベントループが後から呼び出すコールバックが文脈を運ぶ手段として、この仕組みが欠かせません。
古典的な罠も、この「同梱」の理解を試します。ループの中でハンドラを登録し、ループ変数を参照させると、全ハンドラが同じ1つの変数を共有してしまい、実行時にはどれもループが終わったあとの最終値を見る、という定番のバグです。クロージャが捕まえるのは「その時点の値のコピー」ではなく「変数そのもの」だからです。JavaScript では var を let に変えると反復ごとに新しい変数が作られて解決します。Python のループや、1.22 より前の Go にも同じ形の罠があり、「値を捕まえたいのか、変数を捕まえたいのか」を意識するよい教材になっています。
状態の隠蔽 — もうひとつのカプセル化
カウンタの例をもう一度見ると、count には返された関数を通す以外に触る手段がありません。外から直接読むことも書き換えることもできない — つまりクロージャは、オブジェクト指向がクラスの private フィールドで実現するカプセル化を、関数だけで実現しています。「クロージャは貧者のオブジェクト、オブジェクトは貧者のクロージャ」という古い警句が示すとおり、両者は「状態と振る舞いを束ねる」という同じ問題への別解です。JavaScript のモジュールパターンや React のフックは、クラスではなくクロージャで状態を隠す設計の実例です。makeCounter を2回呼べば環境も2つでき、それぞれの count は互いに干渉しません。
落とし穴 — 捕まえたものは手放されない
クロージャの力には代償もあります。長生きするクロージャ(登録したままのイベントリスナー、タイマー、キャッシュに入れた関数)が大きなデータを捕まえていると、そのデータは使い終わってもヒープに残り続けます。GC が回収するのは「どこからもたどれないもの」だけなので、クロージャ経由の参照が残っている限り、これはガベージコレクションのある言語でも起こるメモリリークです。
V8 などの処理系では、同じスコープで作られたクロージャたちが1つの環境を共有することにも注意が要ります。大きな配列を捕まえた短命のコールバックと、小さな値しか使わない長命のコールバックが同じ関数の中で作られると、長命の側が共有の環境ごと大きな配列を生かし続けることがあります。対策は、長生きさせる関数を登録したら必ず解除する、必要な値だけをローカル変数に取り出してから関数を作る、の2つです。「この関数は何を捕まえているか」を意識することが、クロージャを使いこなす最後の一歩です。
試問
画面の左はスタック(関数の呼び出しごとに積まれる段)、右はヒープ(関数と、関数が捕まえた変数の箱=環境)です。makeCounter() は「count を1増やして返す関数」を作って返す関数で、札を押して呼ぶ・return する・返ってきた関数を呼ぶ、変数の矢印を ✂ で切る、GC(使われなくなったメモリの回収)を走らせる、ができます。
- function makeCounter() {
- let count = 0;
- const name = "tmp"; // 捕まえられない
- return () => ++count; // count だけを捕まえる
- }
いちばん下のフレームにある makeCounter() を押して、関数を呼んでみてください。
