真面目に JavaScript のクロージャを解説する

いつも備忘録ばかり書いているので、たまには世の中の駆け出しプログラマの為になるような記事を書きたい。
クロージャみたいなちょっとややこしい概念って、その意味を調べようと思ってググってもあまりよい資料が見当たらない。技術的なトピックを理解するのって「コアとなる考え方を正確に理解する」ことが重要だと思ってるのだが、多くの記事が「クロージャっぽさ」を説明してる感じで、読者としては「なんとなく理解できたがスッキリしない」みたいな気持ちになりそう。

本記事では JavaScriptクロージャについて割と正確に説明する。想定読者は、JavaScript の基本的な文法 (レキシカルスコープ第一級関数 を含む) は分かっているが、クロージャはよく分かっていないプログラマである。 例によって、このような水色の文字は補足的な内容であり読まなくてもよい。

環境 (Environment) とは?

まずは、クロージャの理解に重要な "環境" という考え方を説明します。 この "環境" という言葉は、辞書に載ってる普通名詞の意味でなく、プログラミング用語だと思った方がよいです。世の中のクロージャの解説記事を見ていると、この "環境" が何を意味するのか曖昧なものが多いように感じます。

突然ですが、ソースコード中に

console.log(x)

という文があったとき出力される値は何でしょうか?
答えはもちろん「分からないよ。出力される値は x の値に依存するからね。」といった感じです。

では、次の文ではどんな値が出力されるでしょうか?

console.log(x+y+z)

この場合も同様で「分からないよ。出力される値は xyzの値に依存するからね。」というのが正しいです。

では、x が 3、y が 2, z が 4 のとき console.log(x+y+z) は何を出力するでしょうか?
ここでようやく「9 が出力される」と値をユニークに答えることができるわけです。

ここまで当たり前のような話ですが、重要なポイントがあります。
それは「プログラムのふるまいはその時点での変数の値に依存する」ということです。

x が 3、y が 2, z が 4 のとき」と書くのは冗長なので、簡単に {x:3, y:2, z:4} と書くことにします。
このような変数名と値のマッピング環境 (Environment) と呼ぶことにします。
プログラムのふるまいを知るには、実行される命令だけでなく環境を知る必要があるということです。

なお、環境はプログラムの実行が進むにつれて内容が更新されます。
たとえば、以下にソースコードと、各行の実行が終わったときの環境をコメントとして記載しています。

function f() {
    var x = 3;  // {x:3} 
    var y = 2;  // {x:3, y:2} 
    var z = 4;  // {x:3, y:2, z:4} 
        x = 8;  // {x:8, y:2, z:4}
    console.log(x, y, z);
}

新たな変数が定義されたり、すでに存在する変数の値が更新される場合などは環境が更新されます。

環境 (Environment) という考え方はコンピュータサイエンスとしては一般的なものであり、たとえば SICP の 3.2 章 に載っていたりします。他には、インタプリタを実装するときによく登場する用語です。プログラムを interpret するというのは、プログラムをその environment において評価する行為に他ならないので。

関数の入れ子と環境

次は、2つの関数が入れ子になっている、つまり関数の中に関数が定義されているときの環境について見てみます。

以下のソースコードでは2つの関数 parent と child が入れ子になっています。
関数 parent が実行されるときの各行の実行後の環境をコメントとして記載しています。

function parent() {
    var a = "hello";  // {a:"hello"}
    var b = "world";  // {a:"hello", b:"world"}
    function child() {
        var x = 3;  // {x:3} 
        var y = 2;  // {x:3, y:2} 
        var z = 4;  // {x:3, y:2, z:4} 
        console.log(a, b, x, y, z);
    }
    child();
}

ここで押さえてほしいポイントは 「環境は関数の実行 (呼び出し) ごとに生成される」 ということです。
この例では関数 parent と child それぞれが1回ずつ実行されるので、parent と child のそれぞれに対応する環境が生成されています。

実は、上のソースコードで示した環境には誤りがあります。
変数のスコープの考え方を知っていればその誤りに気付けるのですが、関数 child の中でも parent 内で定義された変数 a, b の値が見えているべきです。

ここで2つ目のポイントなのですが 「関数の定義が入れ子になっているとき、子の環境は親の環境にアクセスできる」ということです。
具体的には、環境を以下のように考えるのが正しいです。

function parent() {
    var a = "hello";  // {a:"hello"}
    var b = "world";  // {a:"hello", b:"world"}
    function child() {
        var x = 3;  // {x:3, a:"hello", b:"world"} 
        var y = 2;  // {x:3, y:2, a:"hello", b:"world"} 
        var z = 4;  // {x:3, y:2, z:4, a:"hello", b:"world"} 
        console.log(a, b, x, y, z);
    }
    child();
}

今回は child の環境の中に、parent の環境 {a:"hello", b:"world"} の内容が含まれています。
子の関数の実行時に親の環境が見えているということです。

クロージャとは?

いよいよクロージャについて説明します。ここまでの話が理解できていれば後は簡単です。

関数の入れ子と環境について重要なポイントは (A) 子の関数の実行時に作成される環境が、 (B) 親の環境 (子の関数を定義している環境) へアクセスできることでした。

特に興味深いのが、(A) の子の関数が (B) の親の関数に返り値として返される場合です。
具体的には以下のソースコードのようなケースです。

function parent() {
    var a = "hello"; 
    var b = "world";
    function child() {
        var x = 3;
        var y = 2;
        var z = 4; 
        console.log(a, b, x, y, z);
    }
    return child;
}

このソースコードは関数定義をしているだけであり、まだ関数 parent も child も実行されていないので、環境は生成されていません。これらの関数を実行するときに環境がどうなるか見ていきます。

関数 parent を実行します。

var ch = parent()

すると、実行後には親の環境として {a:"hello", b:"world"} が生成されます。 この時点では子の関数 child は実行されていないので、child に対応する環境は存在していません。
つぎに、子の関数 child を実行します。関数 parent から返された関数 ch を実行するだけです。

ch();

すると、子の環境が生成され、実行後には {x:3, y:2, z:4, a:"hello", b:"world"} となります。
前述したポイントである「子の関数の実行時に生成される環境は、親の環境 (子の関数を定義している環境) へアクセスできる」ことにより、子の環境が親の環境 {a:"hello", b:"world"} を含んでいるのが肝です。

この状況って少し違和感がありませんか?
親だった関数 parent の実行が終わっているのに親の環境は残り続けていて、その環境に、変数 ch として返された関数がアクセスできるって状況なのですよ。

つまり、変数 ch は、自身の正体が関数 child であることだけでなく、親の環境 (関数 child を定義している環境) を知っているということです。
実は、このような「親の環境」の情報をもつ関数こそが "クロージャ" です。
今回の例では 変数 ch (つまり関数 parent の返り値) がクロージャになっているということです。

なお、クロージャという言葉には "閉じ込める" というニュアンスがあります。クロージャは、親の環境を "閉じ込め" て、その子の関数のみがアクセスできる状態を作り出す技術です。

図で理解するクロージャ

ここまで言葉でクロージャを説明したのですが、図を使って状況を整理してみましょう。 使用するソースコードは先ほどと同じです。

function parent() {
    var a = "hello"; 
    var b = "world";
    function child() {
        var x = 3;
        var y = 2;
        var z = 4; 
        console.log(a, b, x, y, z);
    }
    return child;
}

まずは、関数の定義が入れ子になっていることを以下のように表してみます。

f:id:t-keita:20201126235058j:plain:w200

なにも複雑なことはありません。この図は関数の実行結果など関係なく、ただソースコード上で関数が入れ子に定義されているという事実を示しています。

つぎに、関数 parent を実行します。

var ch = parent()

このとき、関数 parent が実行されるので、対応する環境が生成されるはずです。
この様子を以下のように表してみます。

f:id:t-keita:20201126235730j:plain:w400

そして、子の関数 ch を呼び出しします。

ch();

その結果は以下のような図になります。関数 ch が呼び出された瞬間は ch の環境は空っぽです。

f:id:t-keita:20201127010533j:plain:w400

このタイミングこそが、クロージャが仕事をする瞬間です。
関数 ch はクロージャなので、子の関数の環境が生成されたとき、子の環境が親の環境にアクセスできるのがポイントです。図を書くと以下のようになります。

f:id:t-keita:20201127010655j:plain:w600

このような流れで、関数 ch の実行時に親の環境に存在する変数 ab を使用できるわけです。

一方で、親の環境を "閉じ込める" のがクロージャでした。関数 parent の子でない関数 f や g があったとしても、それらは「parent の環境」へアクセスできません。図を書くとこんな感じでしょうか。

f:id:t-keita:20201127174342j:plain:w600

クロージャのなにが嬉しいのか?

クロージャの利点は、関数の外部から隠蔽した状態、つまり private な変数を実現できることです。
Java などのオブジェクト指向プログラミング言語に触れたことがあれば、クラスの外部から直接操作されない private なフィールドは、高い保守性をもつ設計に欠かせないものであることをご存知だと思います。

カウンタの例 (inc のみ)

クロージャの説明としてありがちなカウンタの例を考えてみましょう。
以下のソースコードを考えます。

function counter() {
    var cnt = 0; // 変数 cnt は関数の外からはアクセスできない(private になっている)
    function inc() {
        cnt++;
        return cnt;
    }
    return inc;
};

関数 counter と関数 inc が入れ子に定義されています。
親の関数 counter で変数 cnt が定義されていて、これを子の関数 inc 内でインクリメントしています。

ここで関数 counter を2回実行し、それぞれによって返された関数 a, b を適当に実行してみます。

var a = counter();
var b = counter();
a(); // => 1
a(); // => 2
b(); // => 1
a(); // => 3
b(); // => 2

コメントとして実行結果を書いているのですが、ab はそれぞれ独立にカウントアップすることが分かります。 関数 a, b それぞれが自身の状態 (変数 cnt の値) を管理しており、外部からは書き換えられない安全な設計になっているのがポイントです。

図に書いてみると状況が分かりやすいと思います。

f:id:t-keita:20201127013938j:plain:w600

ソースコードでは counter(); の実行が2回行われるので、それぞれのタイミングで「counter の環境」が生成され、関数 ab の実行時にはそれぞれ異なる「counter の環境」にアクセスできるというわけです。

また、「counter の環境」へのアクセスは関数 counter 内で定義されている関数のみ (今回は関数 inc のみ) に許されており、外部からの書き換えを保護しています。

カウンタの例 (inc, dec)

つぎは、カウントアップとカウントダウンを許すカウンタを考えてみましょう。
以下のソースコードを見てみます。

function counter() {
    var cnt = 0;
    function inc() {
        cnt++;
        return cnt;
    }
    function dec() {
        cnt--;
        return cnt;
    }
    return {
        inc: inc,
        dec: dec,
    };
};
var c = counter();
c.inc(); // => 1
c.inc(); // => 2
c.inc(); // => 3
c.dec(); // => 2
c.dec(); // => 1
c.inc(); // => 2

今回のポイントは、関数 counter 内で関数 inc と dec が定義されているうえに、両方の関数が返り値として返却されているところです。
この状況では、関数 inc と dec がともに 同じ親の環境を共有する クロージャになっています。

それでは図を見てみましょう。

f:id:t-keita:20201127012652j:plain:w600

なかなか複雑になってしまいましたが、c.incc.dec の実行が同じ「counter の環境」を共有していることが分かります。そのため、関数 c.incc.dec は独立にふるまうのではなく、それぞれの関数の実行結果を互いに反映します。
これはまさにオブジェクト指向における private なクラスフィールドとメソッドのような関係です。

まとめ

クロージャは、関数が入れ子で定義されているときに出現する、親の環境についての情報をもつ関数のことです。
JavaScript 上で、オブジェクト指向でいう private フィールドを実現するための仕組みとも言えます。

説明がややこしくなるのでこの記事では言及していなかったのですが、JavaScript はトップレベル (どの関数にも属さない範囲) にも環境をもっています。これはいわばグローバルな環境であり、グローバル変数はここに属します。そういう意味では、入れ子になっていない関数もグローバルな環境へアクセスできるクロージャであると言えます。この場合、親の環境がグローバルなので、クロージャの利点である「外部からの直接的な状態の変更を保護する」ような役割は顕在化しまん。したがって、入れ子になっていない関数がクロージャであることを意識する必要はまずないと思います。一方で、このような事情から「JavaScript の任意の関数はクロージャである」と言われるのは理論的には正しいです。

思ったより説明が長くなって疲れたのですが、駆け出しプログラマの理解の助けになれば幸いです。

参考文献

Closures - JavaScript | MDN

O'Reilly Japan - Go言語でつくるインタプリタ