Goとツールでジェネリクス


Goの言語仕様にジェネリクスそのものはありません。 Go2でなにかしらの支援が入るかもしれませんが、 その仕様の落とし所はまだまだ定まってはいないようです。

Go1.4にて言語仕様ではなくツールチェインでコードを生成するための 機能「go-generate」が追加されました。 この機能はアセットの埋め込みやバージョン情報生成のほかジェネリクスも実現できます。

「go-generate」を使ってエレガントにジェネリクスできる様にするgennyというツールを紹介します。

go-generate

コーディングとビルドとの間に行う「何らかの処理」を定義できます。 「何らかの処理」はただの1行のシェルコマンドラインです。

go-generateは指定した パッケージの中で「何らかの処理」=コマンドラインを起動するだけの機能です。 Makefileでやってもいい様な気がしますが、以下の2つの支援機能があります。

  • Goのパッケージパスが指定可能
  • コマンドライン定義をGoソースコードコメントに記述できる

これだけですが、Makefileを不要にし、パッケージごとの定義をコードに埋められるため、 ビルド時の手順は一定のまま保つ事ができます。

基本機能の使い方はGenerating code(The Go Blog)に。

yaccコンパイラを入手しておいて

go get golang.org/x/tools/cmd/goyacc

パッケージフォルダにて以下のような処理を行いたいとした時、

$ goyacc -o gopher.go -p parser gopher.y

以下のようなコメントの書かれた.goファイル(名称は任意)があれば・・・

doc.go

package <パッケージ名>

//go:generate goyacc -o gopher.go -p parser gopher.y

以下のコマンドで実行できます。

$ go generate <パッケージパス>

これで「gopher.y」をもとに「gopher.go」が出力されます。

また、リカーシブに実行したい場合は

$ go generate ./...

という様に「…」が使えます。

gennyの紹介

前述の「goyacc」は入力にyaccファイル、出力を.goファイルとするツールでした。 「genny」は双方.goファイルとしてジェネリクスを実現しようというツールです。

リポジトリ: https://github.com/cheekybits/genny

大きな特徴は

  • プレースホルダ型名を任意の型名に置換する仕組み。
  • 複数のプレースホルダが指定でき、組み合わせを展開できる。
  • プレースホルダは二種類、Number(算術可能)と非Number(算術不可)。
  • 入力のコードも有効なGoソースコード。
  • 入力のコードをコンパイルもテストも実行可能。
  • そのためIDEのようなコード支援機能を有効にしたまま入力コードの編集が可能です。
  • (genny以外のジェネリクス系ツールの多くはGo言語仕様から外れてるのでIDEの支援は受けられません。)

サンプル1

生成元ファイル each.go

package each

//go:generate genny -in=$GOFILE -out=gen-$GOFILE gen "KeyType=string ValueType=string,int,bool"

import "github.com/cheekybits/genny/generic"

type KeyType generic.Type
type ValueType generic.Type
type KeyTypeValueTypeMap map[KeyType]ValueType

func (m KeyTypeValueTypeMap) Each(aply func(key KeyType, value ValueType) bool) {
	for k, v := range m {
		if !aply(k, v) {
			break
		}
	}
}

genericパッケージには二種類の型が定義されています。

  • generic.Type: 算術不可なジェネリック型
  • generic.Number:算術可能なジェネリック型

同フォルダにてジェネレート実行

$ go generate

生成先ファイル gen-each.go

// This file was automatically generated by genny.
// Any changes will be lost if this file is regenerated.
// see https://github.com/cheekybits/genny

package each

type StringStringMap map[string]string

func (m StringStringMap) Each(aply func(key string, value string) bool) {
	for k, v := range m {
		if !aply(k, v) {
			break
		}
	}
}

type StringIntMap map[string]int

func (m StringIntMap) Each(aply func(key string, value int) bool) {
	for k, v := range m {
		if !aply(k, v) {
			break
		}
	}
}

type StringBoolMap map[string]bool

func (m StringBoolMap) Each(aply func(key string, value bool) bool) {
	for k, v := range m {
		if !aply(k, v) {
			break
		}
	}
}

サンプル2

生成元ファイル numeric.go

package numeric

//go:generate genny -in=$GOFILE -out=gen-$GOFILE gen "Type=int,int16,int32,int64"

import (
	"github.com/cheekybits/genny/generic"
)

type Type generic.Number

func SumType(values ...Type) Type {
	res := values[0]
	for _, v := range values[1:] {
		res += v
	}
	return res
}

func MaxType(values ...Type) Type {
	res := values[0]
	for _, v := range values[1:] {
		if res < v {
			res = v
		}
	}
	return res
}

func MinType(values ...Type) Type {
	res := values[0]
	for _, v := range values[1:] {
		if res > v {
			res = v
		}
	}
	return res
}

同フォルダにてジェネレート実行

$ go generate

生成先ファイル gen-numeric.go

// This file was automatically generated by genny.
// Any changes will be lost if this file is regenerated.
// see https://github.com/cheekybits/genny

package numeric

func SumInt(values ...int) int {
	res := values[0]
	for _, v := range values[1:] {
		res += v
	}
	return res
}

func MaxInt(values ...int) int {
	res := values[0]
	for _, v := range values[1:] {
		if res < v {
			res = v
		}
	}
	return res
}

func MinInt(values ...int) int {
	res := values[0]
	for _, v := range values[1:] {
		if res > v {
			res = v
		}
	}
	return res
}

...中略...

func SumInt64(values ...int64) int64 {
	res := values[0]
	for _, v := range values[1:] {
		res += v
	}
	return res
}

func MaxInt64(values ...int64) int64 {
	res := values[0]
	for _, v := range values[1:] {
		if res < v {
			res = v
		}
	}
	return res
}

func MinInt64(values ...int64) int64 {
	res := values[0]
	for _, v := range values[1:] {
		if res > v {
			res = v
		}
	}
	return res
}

指定タイプごとに実装が生成されていますね。

foldサンプル

foldを実装してみます。 現実的に扱うものはインターフェースのスライスにすると良いでしょう。 Userというインターフェースに対してfoldする例を含めます。

typedef.go

package fold

type Bytes []byte
type User interface {
	Name() string
}

fold_num.go

package fold

//go:generate genny -in=$GOFILE -out=gen-$GOFILE gen "Num=float64,int"

import (
	"github.com/cheekybits/genny/generic"
)

type Num generic.Number

func FoldNum(l []Num, apply func(a Num, b Num) Num) (res Num) {
	res = l[0]
	for _, v := range l[1:] {
		res = apply(res, v)
	}
	return res
}

fold_type.go

package fold

//go:generate genny -in=$GOFILE -out=gen-$GOFILE gen "Type=string,Bytes,User"

import (
	"github.com/cheekybits/genny/generic"
)

type Type generic.Type

func FoldType(l []Type, apply func(a Type, b Type) Type) (res Type) {
	res = l[0]
	for _, v := range l[1:] {
		res = apply(res, v)
	}
	return res
}

fold_test.go

package fold

import (
	"fmt"
)

func ExampleFoldInt() {
	sum := FoldInt([]int{1, 2, 3, 4}, func(a, b int) int {
		return a + b
	})
	fmt.Println(sum)
	//output:
	//10
}

func ExampleFoldFloat64() {
	sum := FoldFloat64([]float64{1.1, 2.1, 3.1, 4.1}, func(a, b float64) float64 {
		return a + b
	})
	fmt.Println(sum)
	//output:
	//10.4
}

func ExampleFoldString() {
	sum := FoldString([]string{"1", "2", "3", "4"}, func(a, b string) string {
		return a + b
	})
	fmt.Println(sum)
	//output:
	//1234
}

func ExampleFoldBytes() {
	sum := FoldBytes([]Bytes{
		Bytes("1"),
		Bytes("2"),
		Bytes("3"),
		Bytes("4"),
	}, func(a, b Bytes) Bytes {
		return append(a, b...)
	})
	fmt.Println(sum)
	//output:
	//[49 50 51 52]
}

type user string

func (u user) Name() string {
	return string(u)
}

func ExampleFoldUser() {
	sum := FoldUser([]User{
		user("a"),
		user("b"),
		user("c"),
		user("d"),
	}, func(a, b User) User {
		return user(a.Name() + b.Name())
	})
	fmt.Println(sum)
	//output:
	//abcd
}

テスト実行例

$ go generate
$ go test -v
=== RUN   ExampleFoldInt
--- PASS: ExampleFoldInt (0.00s)
=== RUN   ExampleFoldFloat64
--- PASS: ExampleFoldFloat64 (0.00s)
=== RUN   ExampleFoldString
--- PASS: ExampleFoldString (0.00s)
=== RUN   ExampleFoldBytes
--- PASS: ExampleFoldBytes (0.00s)
=== RUN   ExampleFoldUser
--- PASS: ExampleFoldUser (0.00s)
PASS
ok  	github.com/nobonobo/genny-samples/sample-fold	0.013s

foldが実装できたら、mapやfilterも同様に実装できるでしょう。

オーダードマップ

万能のOrderedMapとか作ると結局動的タイプアサーションが必要になったりするので go-generateで任意の構造体バリューを持てる実装を作ってみました。

型安全でメモリは浪費するけどSet順をキープしつつキーでソートも可能にしてみました。

typedef.goに任意の定義を宣言しておいて、 gennyに指定する型にその定義名を渡すと「定義名Map」コンテナ実装がジェネレートされます。

ユーザー型定義ファイル typedef.go

package orderedmap

type Sample struct{}

実装ファイル orderedmap.go

package orderedmap

//go:generate genny -in=$GOFILE -out=gen-$GOFILE gen "Type=Sample"

import (
	"github.com/cheekybits/genny/generic"
)

// Type ...
type Type generic.Type

// TypeItem ...
type TypeItem struct {
	index int
	value *Type
}

// TypeMap ...
type TypeMap struct {
	keys  []string
	items map[string]*TypeItem
}

// NewTypeMap ...
func NewTypeMap() *TypeMap {
	return &TypeMap{
		keys:  []string{},
		items: map[string]*TypeItem{},
	}
}

func (m *TypeMap) Len() int { return len(m.keys) }
func (m *TypeMap) Swap(i, j int) {
	iv := m.items[m.keys[i]]
	jv := m.items[m.keys[j]]
	jv.index, iv.index = iv.index, jv.index
	m.keys[i], m.keys[j] = m.keys[j], m.keys[i]
}
func (m *TypeMap) Less(i, j int) bool { return m.keys[i] < m.keys[j] }

// Get ...
func (m *TypeMap) Get(key string) *Type {
	item, ok := m.items[key]
	if ok {
		return item.value
	}
	return nil
}

// Set ...
func (m *TypeMap) Set(key string, value *Type) {
	m.items[key] = &TypeItem{len(m.keys), value}
	m.keys = append(m.keys, key)
}

// Del ...
func (m *TypeMap) Del(key string) {
	item, ok := m.items[key]
	if ok {
		m.keys = append(m.keys[:item.index], m.keys[item.index+1:]...)
		delete(m.items, key)
	}
}

// Iter ...
func (m *TypeMap) Iter(f func(key string, value *Type) bool) {
	for _, key := range m.keys {
		TypeItem, ok := m.items[key]
		if ok {
			if !f(key, TypeItem.value) {
				break
			}
		}
	}
}

テストファイル orderedmap_test.go

package orderedmap

import (
	"fmt"
	"sort"
)

func ExampleSampleMap() {
	m := NewSampleMap()
	m.Set("hoge1", &Sample{})
	m.Set("moge1", &Sample{})
	m.Set("hoge2", &Sample{})
	m.Set("moge2", &Sample{})
	sort.Sort(m)
	m.Del("moge1")
	m.Iter(func(k string, v *Sample) bool {
		fmt.Println(k, v)
		return true
	})
	// output:
	// hoge1 &{}
	// hoge2 &{}
	// moge2 &{}
}

同フォルダにてジェネレート実行

$ go generate

生成先ファイル gen-orderedmap.go


// This file was automatically generated by genny.
// Any changes will be lost if this file is regenerated.
// see https://github.com/cheekybits/genny

package orderedmap

// github.com/cheekybits/genny 参照

// SampleItem ...
type SampleItem struct {
	index int
	value *Sample
}

// SampleMap ...
type SampleMap struct {
	keys  []string
	items map[string]*SampleItem
}

// NewSampleMap ...
func NewSampleMap() *SampleMap {
	return &SampleMap{
		keys:  []string{},
		items: map[string]*SampleItem{},
	}
}

func (m *SampleMap) Len() int { return len(m.keys) }
func (m *SampleMap) Swap(i, j int) {
	iv := m.items[m.keys[i]]
	jv := m.items[m.keys[j]]
	jv.index, iv.index = iv.index, jv.index
	m.keys[i], m.keys[j] = m.keys[j], m.keys[i]
}
func (m *SampleMap) Less(i, j int) bool { return m.keys[i] < m.keys[j] }

// Get ...
func (m *SampleMap) Get(key string) *Sample {
	item, ok := m.items[key]
	if ok {
		return item.value
	}
	return nil
}

// Set ...
func (m *SampleMap) Set(key string, value *Sample) {
	m.items[key] = &SampleItem{len(m.keys), value}
	m.keys = append(m.keys, key)
}

// Del ...
func (m *SampleMap) Del(key string) {
	item, ok := m.items[key]
	if ok {
		m.keys = append(m.keys[:item.index], m.keys[item.index+1:]...)
		delete(m.items, key)
	}
}

// Iter ...
func (m *SampleMap) Iter(f func(key string, value *Sample) bool) {
	for _, key := range m.keys {
		SampleItem, ok := m.items[key]
		if ok {
			if !f(key, SampleItem.value) {
				break
			}
		}
	}
}

実行例

$ go generate
$ go test
PASS
ok  	github.com/nobonobo/genny-samples/sample-orderedmap	0.012s

この実装は使うときに最低限の機能だけを載せるのがオススメです。

  • コンカレントに参照する場合はsync.RWMutexを埋め込むといいよ。
  • ソート機能いらないならLen/Swap/Lessを消そう。
  • メモリ効率重視ならBTreeインデックスにしよう。
  • MarshalJSON/UnmarshalJSONが必要なら生やすといいよ。
  • キー型変えるのも必要に応じてどうぞ!
  • オーダーが維持されてるのでペジネーション用のメソッドを生やすのもあり。

gennyまとめ

  • gennyを使うとテスタブルでIDEの支援も受けつつジェネリクスの実装ができました。
  • この方法はパフォーマンスや型安全性も高いのです。
  • 必要に応じて機能は増やせるし、適用したいユーザー定義型が増えてもすぐに対応できます。
  • この記事のサンプルは https://github.com/nobonobo/genny-samples ここに全部置いてます。
  • go-get可能にする場合は生成結果もリポジトリに追加しましょう。

おまけでgodzillaサンプル

ES2015をGoにトランスパイルするツール。

リポジトリ: https://github.com/jingweno/godzilla

インストール(gitやnpmが必要)

$ go get -d github.com/jingweno/godzilla/...
$ cd $GOPATH/src/github.com/jingweno/godzilla && make && cp bin/* $GOPATH/bin/

godzillaミニマルテスト

$ echo 'console.log("hello")' | godzillac > sample.go
$ go run sample.go
hello

go-generate設定ファイル doc.go

package main

//go:generate sh -c "godzillac < __js__/sample.js > sample.go"

生成元ファイル __js__/sample.js (アンダースコアで始まるフォルダはGoがパッケージとして探索しない)

console.log('hello');

同フォルダにてジェネレート実行

$ go generate

生成先ファイル sample.go

package main

import (
	. "github.com/jingweno/godzilla/runtime"
)

func main() {
	global := NewDefaultContext().Global
	_ = global

	// line 1: console.log("hello")
	Console_Log([]Object{JSString("hello")})
}

テストファイル sample_test.go

package main

func ExampleHello() {
	main()
	// output:
	// hello
}

テスト実行

$ go test
PASS
ok  	github.com/nobonobo/genny-samples/sample-js2go	0.014s

Goで実行

$ go run sample.go
hello

godzillaはまだサポートライブラリはほとんどありませんのでご注意を。 あとgrumpyなども同様にできるかと思います。

所感

まあ、gennyで作ったジェネリクス実装を実用してみるとリッチな言語ほど楽にならない点に気づきます。

オーバーロードがない!

それはオーバーロードがないため呼び分けを利用者がやらなきゃならない点。

でもオーバーロードは実装の利用者側にショートハンドメリットを得る代わりに 双方に大きなデメリットも呼び込むので私は無い方が好きです。

オーバーロードのない言語のほうがコードを読むときにものすごく素直に追えるので。 あと最も大きなデメリットはgennyのように Goのコードをパースしてなにかを行うようなツールを作るとき、 オーバーロードや継承がない言語仕様だからこそこういったツールを作るときの苦労が少ない。 オーバーロードや継承があったら関連グラフしっかり起こさないと正しく解釈できない。

godzillaの例でもそうだけど、go-generateの上に高級言語を作るというのはすでにできるわけです。

ジェネリクスのないGoの言語仕様が気にくわないのなら ジェネリクスを持つ「Go++言語」を作れる状況なのです。 本当に問題の解決に必要なら実装して見て提案に載せるのは有効な手だと思います。

それでもなおそういった実装が出てこないところをみるとGo言語の良さを 殺さずに「Go++言語」を作るのは容易ではないということなのかも。 もしくはGo言語ユーザーの多くがそのような「Go++言語」を 必要としていないことの現れなんじゃないでしょうか。