読者です 読者をやめる 読者になる 読者になる

詩と創作・思索のひろば

ドキドキギュンギュンダイアリーです!!!

Go で reflect パッケージを使わずにジェネリックな関数を実現する

Go

あけおめ〜。Go 界においてジェネリクスを求めるのは(今のところ)はかない望みでしかないし、もちろん重々承知していることですが、それでもときどき複雑なものを書こうとするとどうしても複数の型に対応する関数が欲しくなる。そこでこの冬休みになにかうまい方法はないかと考えて、作ってみました。

要件

今回はこんな関数が実現したくなりました。(擬似コードです)

// map[string]int とか map[string]foo.Bar を受けつける
func keys(m map[string]T) []string {
    keys := make([]string, 0, len(m))
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}

そしてこれを実現するためのアプローチを以下のように定めました。

  1. reflect パッケージを使わず、コード生成で対応する。
  2. コード生成前のコードもコンパイルできるようにする。
    • 書いている途中でも補完などが効くように。
  3. コード生成の際、対象の関数以外には手を加えない。
    • 呼び出し元はすべて同じ keys 関数の呼び出しになっていて、keys_bool(m) とか keys(wrapper(m)) とかに書き換えられない。

コンパイルできるが実行時にエラーとなるコードを、コンパイルできて実行時にもエラーとならないコードに変換する、という寸法です。実行時の安全性は変換器によって保証します。

1 を実現しているツールとして、例えば gengen があります。

方法

上記の keys 関数のシグネチャでは汎用的な型の引数を受け付けられないので interface{} に変更し、代わりに型を type switch で指定するように変更します。以下はそのスタイルで書いた完全なコードです。

// keys.go
package main

import “fmt”

type T interface{}

func keys(m interface{}) []string {
    switch m := m.(type) {
    case map[string]T:
        keys := make([]string, 0, len(m))
        for key := range m {
            keys = append(keys, key)
        }
        return keys
    default:
        panic(fmt.Sprintf("unexpected value of type %T", m))
    }
}

プレースホルダ的な型として、interface{} である T という型を定義します。(interface{} である、大文字で構成された型をプレースホルダとみなすことにします。)

これは他の場所から keys(map[string]bool{}) のように呼び出してもコンパイルできるコードになっていて、実行時に panic します。ならばあとは case 節を実際の型にあわせて増やせばよい、というわけでコード生成の出番です。この関数がどんな引数で呼び出されているかを解析し、それにあわせて case 節を増やしてやります。

実装

ここまでの話を実装したのが motemen/go-typeswitch-gen です。これに tsgen というコマンドが含まれています:

go get github.com/motemen/go-typeswitch-gen/cmd/tsgen

使い方は簡単で、汎用化したい、type switch を含んだ関数の定義されているファイルをコマンド引数として渡します。

tsgen keys.go

自動的に同じパッケージからの呼び出しを検出し、型のパターン(map[string]T など)を実際の引数の型(map[string]bool など)と突き合わせて case 節を追加した type switch を生成し、変更後のファイルの内容を標準出力に印字します。また -w を与えるとファイルを直接書き換えます。

上の keys の例だと、以下のように展開されます。

func keys(m interface{}) []string {
    switch m := m.(type) {
    case map[string]bool:
        keys := make([]string, 0, len(m))
        for key := range m {
            keys = append(keys, key)
        }
        return keys
    case map[string]T:
        keys := make([]string, 0, len(m))
        for key := range m {
            keys = append(keys, key)
        }
        return keys
    default:
        panic(fmt.Sprintf("unexpected value of type %T", m))
    }
}

またあまり出番はないかもしれませんが var x T のように書いてあった場合も var x bool として展開されます。

リポジトリの _example ディレクトリに例が同梱されているので、それを使ってみると簡単です:

% ghq get motemen/go-typeswitch-gen
% ghq look motemen/go-typeswitch-gen
% cd _example/keys
% tsgen -w keys.go
% git diff

また呼び出し元の解析のためには main 関数かテストが必要なので、対象のファイルと同一パッケージにそれらがない場合には -main 引数で指定します。

% tsgen -main github.com/motemen/foo ./util/util.go

go generate と組み合わせる

Go 1.4 から導入された go generate と組み合わせて使うのも簡単です。ファイルの先頭に以下のように書くだけ:

//go:generate tsgen -w $GOFILE

go generate すると当該のファイルが書き換わります。

既知の問題・今後の課題

import が自動的に増えない

Tio.Reader などがマッチした場合 case io.Reader: が生えますが、import "io" は追加されません。とりあえず goimports でしのげます。

コメントの位置が変になる

ベースとなる case 節にコメントを書いていた場合、生成後のコードでコメントの位置が変になります。

返り値を汎用化できない

さまざまな引数の型で同じ関数を呼び出せるようにしている都合上、返り値を汎用化させることはできません。呼び出し元の type assertion を解析するというのはできそう。 現在のところはコールバック関数を引数として与えることで任意の型を受け取れます。foreach の例:

func foreach(a interface{}, cb interface{}) {
    switch a := a.(type) {
    case []T:
        switch cb := cb.(type) {
        case func(int, T):
            for i, e := range a {
                cb(i, e)
            }
        }
}

「任意の数値型」のようなプレースホルダを定義できない

プレースホルダが interface{} だと加算が定義されていないので、数値の配列の総和、のような関数が定義できません。たぶんプレースホルダを type NumberT float64 のように定義できればよさそうだと思ってます。

終わり

tsgen で、既存の関数をベースにしてさまざまな引数の型に対して同じような処理を行える関数を生成できることを紹介しました。Go の静的解析ネタ、いろいろあるのでどこかで書くか話すかできればよいなと思ってます。

みなさま 2015 年もよろしくお願いいたします。

The Way to Go: A Thorough Introduction to the Go Programming Language (English Edition)

The Way to Go: A Thorough Introduction to the Go Programming Language (English Edition)