はじめに
1. クロージャとは何か
何かを知るには、まず公式の Playground で遊んでみることが大事です。 そして、公式 playground に書いてある説明がすべてです。
Go functions may be closures. A closure is a function value that references variables from outside its body. The function may access and assign to the referenced variables; in this sense the function is “bound” to the variables.
訳すとこうです。
Go 関数はクロージャにもなります。クロージャはその(関数定義)本体の外にある変数を参照する関数値です。その関数は、参照する変数にアクセスしたり値を代入したりします。そういう意味で、関数は変数にバインドされています。
つまり、関数として定義されたコードブロックの外にある変数を参照する関数値です。 ここで「関数値」がしっくり来ないかもしれませんが、単に関数ポインタだと思えばいいです。 以下のコードを見てください。
|
|
このコードにおいて、f に代入された値が関数値です。実態は、無名関数 func を実行するアドレスです。
実行結果は以下の通りです。表示されるメモリアドレスは実行ごとに変わりうるものです。
|
|
そして、クロージャとはその関数本体の外部の変数を参照する関数です。
以下のコードにおいて定義された無名関数はクロージャです。counter 変数の値のことです。
|
|
|
|
2. Go でのクロージャの基本的な動作
無名関数という一つのハードルを避け、main ともう一つの関数を用いて説明します。
以下、繰り返しになりますが、main 関数内の counter 変数にセットされた値がクロージャです。
|
|
このクロージャは func() int 型の値です。
関数として実行すると、1, 2, 3 と値が増加しているのが分かります。
これは、変数のキャプチャという仕組みを利用しています。Go では、 counter() という関数が定義された時点で count 変数のスコープは counter() 関数内と決まります。また、内部で返される無名関数は、自身の無名関数外部にある count という変数をキャプチャします。この仕組みによってクロージャが意味を持ちます。つまり、キャプチャしないのであれば、この無名関数は宣言されていない変数を参照することになり、コンパイルエラーとなります。undefined: count というよく見るエラーになります。
3. クロージャの実際の使用例
身近な例として、net/http パッケージを用いた例を紹介します。
正直なところ、この例を考えるのに 1h 以上使いました。経験が豊富でなく、実用に耐えうる例を示せる自信がなかったためです。余談です。
|
|
上記の例でクロージャは createHandler の戻り値である無名関数です。
この無名関数は、createHandler の引数である logger をキャプチャし、その無名関数内で参照しています。
よって、http.HandlerFunc によってエンドポイントに登録されたハンドラは、サーバ起動時に初期化され、以降再利用されます。
なんとなく、この構成だとリクエストが来るたびクロージャが logger をキャプチャするためメモリ効率が悪そうに見えましたが、ハンドラはあくまでサーバ起動時に登録されるため、以降は再利用されるためメモリ効率は悪くありません。加えて、logger はポインタのため、アドレスを指し示すためのサイズである 8 バイトで十分です。エンドポイントが 10 個あろうと、せいぜい 8 * 10 = 80 バイトしか使わず、現代の多くの Web サービスにおいて無視できるサイズと言えると思います。
4. クロージャの注意点と落とし穴
loop 変数のキャプチャ
ループ時に外部変数をキャプチャすると、想定していない値になることがあります。
下記のプログラムは、クロージャであるゴルーチンがループカウンタ i をキャプチャし出力するものです。
sync パッケージの sync.WaitGroup と wg.Wait() により、すべてのゴルーチンが終了するまで待ってから終わるプログラムです。
ぱっと見、ループカウンタの値を出力するだけの、何か意図を隠してそうな怪しいコードに見えますよね。
|
|
結果は以下の通りです。
|
|
すべて i=3 と表示されていますね。
ゴルーチンは非同期で実行されるので、無名関数のクロージャはループ終了後に変数 i をキャプチャし、すべて 3 になってしまっています。
しかし、この問題は Go 1.22 で解消されています。
|
|
出力される順番は非同期のため不定ですが、確かに 3 ではなくループカウンタの値が出力されています。 このため、Go 1.21 までは起こるが、Go 1.22 以降は起こらない問題、と理解して良いと思います。
キャプチャした変数の共有
これは簡単な例です。また、あまりこういう実装をしてしまう人はいないと信じますが、2つの無名関数が同じ変数 count をキャプチャしているせいで、count 変数の値が分かりにくくなります。
|
|
パフォーマンスへの影響
通常、関数は、関数内で使用する変数をスタックに保存します。しかし、その関数外でも参照されうる変数はヒープに保存されます。 ヒープはスタックに比べ低速なため、クロージャの多用はパフォーマンスの低下を招きます。 ただ、スタックとヒープでどの程度パフォーマンスに差が出るかというと、次のベンチマークから明らかにします。
|
|
ToHeap() は、変数のアドレスを返します。ToStack() は、変数の値そのものを返します。
ToHeap() の戻り値はアドレスのため、関数内で定義した v に代入された 1 があるアドレスは利用される可能性があります。利用される可能性のあるデータはヒープに保存します。
一方で、ToStack() は関数の終了時に変数 v は不要になります。このため、v はスタックに保存され、関数の終了時に破棄され、それ以上 v を管理する必要はありません。
なお、このようにスタックに保存するかヒープに保存するかを調べることをエスケープ解析と言います。これについては別途紹介したいと思っています。
長くなりましたが結果です。
|
|
Heap は Stack に比べ約 8.3 倍高速です。 参考にしたブログでは約10倍の差があったため、ランタイムによってスタックとヒープの性能差が変わるのかもしれません。
https://note.com/kyfk/n/n56a2e2ef77f6
よって、せいぜいナノ秒単位の差ではありますが、クロージャもヒープに保存するため、パフォーマンスを意識する必要があります。
なお、クロージャは、関数リテラルそのものがヒープに保存されることが実行結果の以下の文から分かります。
./main_test.go:17:9: func literal escapes to heap
5. まとめ
無名関数じゃないよ、クロージャだよ。