[T any]の衝撃に備えよ
Go1.18 リリース予定
Go言語は2021年の2月にGo1.18がリリースされる予定になっています。 多くのGopherは既に知っていると思いますが、Go1.18は待望のGenericsが実装される予定です。Go(V1)言語仕様としてはおそらく一番大きな変更になるのではないでしょうか?
Genericsの言語仕様等は数多くの有志が色々書いてくださっているのでそちらを読んでいただくとして、今回は1.18のBeta1が出ましたので手元で書いてみたという感じの記事です。
きっかけは
mattn氏が 2021/12/11 にGDE for Goにて発表されました
Go1.18でやってくるGenericsとは(Google Docs)
を読んでいてP.78 の実行速度で「ほぼ同じ」とありましたが、 reflect をそれなりに使ってたらGenericsの方が速くなるんじゃないかな?と思ったので書いてみることにしました。
スライスのフィルタを実装してみる
スライス内部のデータの条件で新規にスライスを準備するFilter()関数を書いてみます。
まずはGenerics前のinterface{}を利用したコードです。
func FilterFunc(x interface{}, fn func(int) bool) (interface{}, error) { if x == nil { return nil, fmt.Errorf("x is nil") } rv := reflect.ValueOf(x) k := rv.Kind() if k != reflect.Slice { return nil, fmt.Errorf("x isn't slice") } leng := rv.Len() elm := rv.Type().Elem() rtn := reflect.MakeSlice(reflect.SliceOf(elm), 0, leng) for i := 0; i < leng; i++ { if fn(i) { rtn = reflect.Append(rtn, rv.Index(i)) } } return rtn.Interface(), nil }
煩わしい点は
- スライスであるかのチェックが必要
- スライスの生成、追加、アクセスにおいてreflectが必要
辺りでしょうか?
ベンチマークをとってみる
実行例としてベンチマークを取ってみます。スライスを[]stringで準備して、空文字データを排除するコードを書いてみます。
func BenchmarkStringFilterFunc(b *testing.B) { var slice []string = []string{"Value1", "", "Value3"} b.ResetTimer() for i := 0; i < b.N; i++ { rtn, err := tool.FilterFunc(slice, func(i int) bool { return (slice[i] != "") }) if err != nil { b.Fatalf("tool.FilterFunc() error: %v", err) } ns, ok := rtn.([]string) if !ok { b.Fatalf("cast error: %v", err) } else { if len(ns) != 2 { b.Fatalf("FilterFunc() length error: want 2,got %d", len(ns)) } } } }
FilterFunc()を実行した後、キャストが必要なのがやっかいですし、もしスライスを渡してなかった場合にエラーが発生します。そのエラーがランタイムでしかわからないので、非常に危ないコードになります。
結果としては
cpu: Intel(R) Core(TM) i7-6700 CPU @ 3.40GHz BenchmarkStringFilterFunc-8 2508645 473.7 ns/op BenchmarkStructFilterFunc-8 1959434 564.6 ns/op
473.7 ns/op になっています。下の関数は構造体のスライスを渡した結果です。
Genericsを使ってみる
それではGenericsを使ったコードを見てみましょう。
func FilterFunc[S ~[]E, E any](s S, fn func(E) bool) (S, error) { if s == nil { return nil, fmt.Errorf("s is nil") } n := make([]E, 0, len(s)) for _, elm := range s { if fn(elm) { n = append(n, elm) } } return S(n), nil }
まず関数のところでEはどの型でもOK、Sはそのスライスであることを宣言しています。覚えてしまえば後は普通のGo言語の書き方と一緒ですので、reflectを利用するより、理解しやすくなっていると思います。
Genericsのベンチマークをとってみる
こちらもベンチマークで実行例をあげておきます。
func BenchmarkStringFilterFunc(b *testing.B) { var slice []string = []string{"Value1", "", "Value3"} b.ResetTimer() for i := 0; i < b.N; i++ { rtn, err := tool.FilterFunc(slice, func(v string) bool { return (v != "") }) if err != nil { b.Fatalf("tool.FilterFunc() error: %v", err) } else { if len(rtn) != 2 { b.Fatalf("FilterFunc() length error: want 2,got %d", len(rtn)) } } } }
Genericsでは戻り値がSになる為、キャストが必要なくなっています。 また引数にスライスを渡さなかった場合、
(渡した型の名前)does not implement ~[](関数の引数のものが出てる)
というコンパイルエラーになり、ランタイムエラーではなくなっています。
実行結果は
BenchmarkStringFilterFunc-8 11938100 106.1 ns/op BenchmarkStructFilter-8 8766354 128.6 ns/op
106.1 ns/opと格段に速くなっています。
何故、Generics前は遅いのか?
せっかくですのでベンチマークから遅い理由を見てみましょう。
$ go test -bench . -cpuprofile nongenerics.out
実行して
$ go tool pprof nongenerics.out
で見てみます。
topでメモリ確保っぽいコード以外で目立つのは
90ms 3.26% 36.59% 530ms 19.20% reflect.Value.Slice 60ms 2.17% 47.10% 840ms 30.43% reflect.Append
この2つ。listで見ても
10ms 10ms 30: for i := 0; i < leng; i++ { 20ms 70ms 31: if fn(i) { 50ms 900ms 32: rtn = reflect.Append(rtn, rv.Index(i))
この辺りが時間がかかっています。 ようはスライスをreflectで操作すると遅いという結論になりました。 まぁ普通に考えて何を行うにしても型チェックが必要で大変なのではないでしょうか?
総括
ちょっと従来のコードの遅い部分まで見てしまいましたが、こういう処理を諦めていたフレームワークやライブラリが実装してくれて、より使いやすくなったりもしてくるのではないでしょうか?
きっかけの資料にもありますが、expにsliceパッケージがあるので既に似た処理があるかもしれませんが、書いてみて思っていた以上に使いやすかったです。提案からかなりの議論が続きましたがやっとリリース直前となりました。
個人的には数えられる位しか「Generics」が欲しいと思うことがなかったのでガンガン実装で使っていく気がしていませんが、出くわした時に読めるようにはなっておきたいところです。
The Go gopher was designed by Renee French.
The design is licensed under the Creative Commons 3.0 Attributions license. Read this article for more details: