Play with Go closure

はじめに

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 関数はクロージャにもなります。クロージャはその(関数定義)本体の外にある変数を参照する関数値です。その関数は、参照する変数にアクセスしたり値を代入したりします。そういう意味で、関数は変数にバインドされています。

つまり、関数として定義されたコードブロックの外にある変数を参照する関数値です。 ここで「関数値」がしっくり来ないかもしれませんが、単に関数ポインタだと思えばいいです。 以下のコードを見てください。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package main

import "fmt"

func main() {
	f := func() {
		fmt.Println("Hello, World!")
	}
	fmt.Println(f)
	fmt.Println(f())
}

このコードにおいて、f に代入された値が関数値です。実態は、無名関数 func を実行するアドレスです。 実行結果は以下の通りです。表示されるメモリアドレスは実行ごとに変わりうるものです。

1
2
3
% go run main.go
0x102548750
Hello, World!

そして、クロージャとはその関数本体の外部の変数を参照する関数です。 以下のコードにおいて定義された無名関数はクロージャです。counter 変数の値のことです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import "fmt"

func main() {
	var count int
	counter := func() int {
		count++
		return count
	}
	fmt.Println(counter)
	fmt.Println(counter())
}
1
2
3
% go run closure.go
0x100c1c7a0
1

2. Go でのクロージャの基本的な動作

無名関数という一つのハードルを避け、main ともう一つの関数を用いて説明します。

以下、繰り返しになりますが、main 関数内の counter 変数にセットされた値がクロージャです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package main

import "fmt"

func counter() func() int {
	var count int
	return func() int {
		count++
		return count
	}
}
func main() {
	counter := counter()
	fmt.Println(counter()) // 1
	fmt.Println(counter()) // 2
	fmt.Println(counter()) // 3
}

このクロージャは func() int 型の値です。 関数として実行すると、1, 2, 3 と値が増加しているのが分かります。 これは、変数のキャプチャという仕組みを利用しています。Go では、 counter() という関数が定義された時点で count 変数のスコープは counter() 関数内と決まります。また、内部で返される無名関数は、自身の無名関数外部にある count という変数をキャプチャします。この仕組みによってクロージャが意味を持ちます。つまり、キャプチャしないのであれば、この無名関数は宣言されていない変数を参照することになり、コンパイルエラーとなります。undefined: count というよく見るエラーになります。

3. クロージャの実際の使用例

身近な例として、net/http パッケージを用いた例を紹介します。 正直なところ、この例を考えるのに 1h 以上使いました。経験が豊富でなく、実用に耐えうる例を示せる自信がなかったためです。余談です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
	"fmt"
	"log"
	"net/http"
	"os"
)

// 無名関数はレキシカルスコープに定められたスコープにある logger 変数をキャプチャする
func createHandler(logger *log.Logger) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		logger.Printf("Request from %s to %s", r.RemoteAddr, r.URL.Path) // loggerをキャプチャ
		fmt.Fprintf(w, "Hello from %s!", r.URL.Path)
	}
}

func main() {
	// 2つのロガーを定義
	userLogger := log.New(os.Stdout, "user: ", log.LstdFlags)
	adminLogger := log.New(os.Stdout, "admin: ", log.LstdFlags)

    // エンドポイントごとに異なるロガーを設定
	http.HandleFunc("/user", createHandler(userLogger))
	http.HandleFunc("/admin", createHandler(adminLogger))

	// サーバ起動
	log.Fatal(http.ListenAndServe(":8080", nil))
}

上記の例でクロージャは createHandler の戻り値である無名関数です。 この無名関数は、createHandler の引数である logger をキャプチャし、その無名関数内で参照しています。 よって、http.HandlerFunc によってエンドポイントに登録されたハンドラは、サーバ起動時に初期化され、以降再利用されます。

なんとなく、この構成だとリクエストが来るたびクロージャが logger をキャプチャするためメモリ効率が悪そうに見えましたが、ハンドラはあくまでサーバ起動時に登録されるため、以降は再利用されるためメモリ効率は悪くありません。加えて、logger はポインタのため、アドレスを指し示すためのサイズである 8 バイトで十分です。エンドポイントが 10 個あろうと、せいぜい 8 * 10 = 80 バイトしか使わず、現代の多くの Web サービスにおいて無視できるサイズと言えると思います。

4. クロージャの注意点と落とし穴

loop 変数のキャプチャ

ループ時に外部変数をキャプチャすると、想定していない値になることがあります。

下記のプログラムは、クロージャであるゴルーチンがループカウンタ i をキャプチャし出力するものです。 sync パッケージの sync.WaitGroupwg.Wait() により、すべてのゴルーチンが終了するまで待ってから終わるプログラムです。 ぱっと見、ループカウンタの値を出力するだけの、何か意図を隠してそうな怪しいコードに見えますよね。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			fmt.Printf("i=%d, addr=%p\n", i, &i)
		}()
	}
	wg.Wait()
}

結果は以下の通りです。

1
2
3
4
5
6
% go version
go version go1.21.13 darwin/arm64
% go run ./loop_closure.go
i=3, addr=0x1400009c018
i=3, addr=0x1400009c018
i=3, addr=0x1400009c018

すべて i=3 と表示されていますね。 ゴルーチンは非同期で実行されるので、無名関数のクロージャはループ終了後に変数 i をキャプチャし、すべて 3 になってしまっています。

しかし、この問題は Go 1.22 で解消されています。

1
2
3
4
5
6
% go version
go version go1.22.0 darwin/arm64
% go run ./loop_closure.go
i=0, addr=0x14000112018
i=2, addr=0x14000112038
i=1, addr=0x14000112030

出力される順番は非同期のため不定ですが、確かに 3 ではなくループカウンタの値が出力されています。 このため、Go 1.21 までは起こるが、Go 1.22 以降は起こらない問題、と理解して良いと思います。

キャプチャした変数の共有

これは簡単な例です。また、あまりこういう実装をしてしまう人はいないと信じますが、2つの無名関数が同じ変数 count をキャプチャしているせいで、count 変数の値が分かりにくくなります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package main

import (
    "fmt"
)

func main() {
    count := 0
    inc := func() { count++ }
    dec := func() { count-- }
    inc()
    dec()
    fmt.Println(count) // 0
}

パフォーマンスへの影響

通常、関数は、関数内で使用する変数をスタックに保存します。しかし、その関数外でも参照されうる変数はヒープに保存されます。 ヒープはスタックに比べ低速なため、クロージャの多用はパフォーマンスの低下を招きます。 ただ、スタックとヒープでどの程度パフォーマンスに差が出るかというと、次のベンチマークから明らかにします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

import "testing"

func ToHeap() *int {
	v := 1
	return &v
}

func ToStack() int {
	v := 1
	return v
}

func Closure() func() int {
	v := 1
	return func() int {
		return v
	}
}

func BenchmarkToHeap(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = ToHeap()
	}
}

func BenchmarkToStack(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = ToStack()
	}
}

func BenchmarkClosure(b *testing.B) {
	for i := 0; i < b.N; i++ {
		fn := Closure()
		_ = fn()
	}
}

ToHeap() は、変数のアドレスを返します。ToStack() は、変数の値そのものを返します。 ToHeap() の戻り値はアドレスのため、関数内で定義した v に代入された 1 があるアドレスは利用される可能性があります。利用される可能性のあるデータはヒープに保存します。

一方で、ToStack() は関数の終了時に変数 v は不要になります。このため、v はスタックに保存され、関数の終了時に破棄され、それ以上 v を管理する必要はありません。

なお、このようにスタックに保存するかヒープに保存するかを調べることをエスケープ解析と言います。これについては別途紹介したいと思っています。

長くなりましたが結果です。

1
2
3
4
5
6
7
8
9
% go test -bench=. -gcflags="-m -l"
# main/stack_and_heap [main/stack_and_heap.test]
./main_test.go:6:2: moved to heap: v
./main_test.go:17:9: func literal escapes to heap
...
BenchmarkToHeap-10      210585192                5.500 ns/op
BenchmarkToStack-10     1000000000               0.6821 ns/op
BenchmarkClosure-10     150774753                8.021 ns/op
...

Heap は Stack に比べ約 8.3 倍高速です。 参考にしたブログでは約10倍の差があったため、ランタイムによってスタックとヒープの性能差が変わるのかもしれません。

https://note.com/kyfk/n/n56a2e2ef77f6

よって、せいぜいナノ秒単位の差ではありますが、クロージャもヒープに保存するため、パフォーマンスを意識する必要があります。

なお、クロージャは、関数リテラルそのものがヒープに保存されることが実行結果の以下の文から分かります。

./main_test.go:17:9: func literal escapes to heap

5. まとめ

無名関数じゃないよ、クロージャだよ。

Hugo で構築されています。
テーマ StackJimmy によって設計されています。