あけおめ〜。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 }
そしてこれを実現するためのアプローチを以下のように定めました。
- reflect パッケージを使わず、コード生成で対応する。
- コード生成前のコードもコンパイルできるようにする。
- 書いている途中でも補完などが効くように。
- コード生成の際、対象の関数以外には手を加えない。
- 呼び出し元はすべて同じ
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 が自動的に増えない
T
に io.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)
- 作者: Ivo Balbaert
- 出版社/メーカー: iUniverse
- 発売日: 2012/03/09
- メディア: Kindle版
- この商品を含むブログを見る