[T any]の衝撃に備えよ

secondarykey 2021/12/17 13:00
secondarykey 2021/12/21 07:44

Go1.18 リリース予定

Go言語は2021年の2月にGo1.18がリリースされる予定になっています。 多くのGopherは既に知っていると思いますが、Go1.18は待望のGenericsが実装される予定です。Go(V1)言語仕様としてはおそらく一番大きな変更になるのではないでしょうか?

Genericsの言語仕様等は数多くの有志が色々書いてくださっているのでそちらを読んでいただくとして、今回は1.18のBeta1が出ましたので手元で書いてみたという感じの記事です。

きっかけは

mattn氏が 2021/12/11 にGDE for Goにて発表されました

を読んでいて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:

vertical_align_top