📚

Go 1.18 の Generics を使ったキャッシュライブラリを作った時に見つけた tips と微妙な点

2021/11/17に公開

GitHub にコードを上げてます。

2021-11-17 時点で Go の Generics の機能を使ったキャッシュライブラリはおそらくないでしょう。Generics を使った例の一つとして参考にしてください。

Star をくれると大喜びします。
https://github.com/Code-Hex/go-generics-cache

本記事ではこのキャッシュライブラリを作ってみて Generics に対して気が付いた点と発見した tips や微妙だった点を紹介していきます。

もし Go の Generics って何ができるんだっけ?となっている方は是非こちらの記事にも目を通してみてください。

https://zenn.dev/syumai/articles/c42hdg1e0085btnen5hg

any でゼロ値を返す

これは @syumai さんから教えてもらった tips です。

次のような any と error を返すコードをよく書くことになるでしょう。関数内で error が発生した時に今までゼロ値と error を返すコードを記述していたはずですが、ちょっと頭を捻る必要が出てきました。

func Do[V any](v V) (V, error) {
	if err := validate(v); err != nil {
		// 何を return ?
	}
	return v, nil
}

func validate[V any](v V) error

ここで return 0, err を記述したとします。これはコンパイルエラーになります。なぜなら any は string など int 以外の型になり得るからです。ではどうすれば良いのでしょうか?

type parameter の V を使って一度変数宣言をしましょう。そうすることで次のようにコンパイルが可能な形で書けます。

func Do[V any](v V) (V, error) {
	var ret V
	if err := validate(v); err != nil {
		return ret, err
	}
	return v, nil
}

さらに named return values を利用することで 1 行分シンプルに書くことが可能です。

func Do[V any](v V) (ret V, _ error) {
	if err := validate(v); err != nil {
		return ret, err
	}
	return v, nil
}

https://gotipplay.golang.org/p/0UqA0PIO9X8

any で constraint の分岐をしようとしない

go-generics-cache ライブラリで保存された値が Number constraint を満たしていた場合に値を加算、減算可能な Increment, Decrement という二つのメソッドを提供しようと考えました。

Increment を例に出しますが、筆者は最初こんな感じのコードを書きました。

type Cache[K comparable, V any] struct {
	items map[K]V
}

func (c *Cache[K, V]) Increment(k K, n V) (val V, _ error) {
	got, ok := c.items[k]
	if !ok {
		return val, errors.New("not found")
	}

	switch (interface{})(n).(type) {
	case Number:
		nv := got + n
		c.items[k] = nv
		return nv, nil
	}
	return val, nil
}

加算したい値 n の type を使って満たしている constraints に合わせた処理をすることを考えていました。ここでは Number に満たしていれば加算し、それ以外では何もしないメソッドです。

これはコンパイルが通りません。

  1. constraints を使った条件分岐が提供されていない[1]
  2. 現状 constraints は interface である。Go は interface を使った型アサーションが出来ない
  3. n の型が決まりきってないので + 演算ができない
  4. そもそも itemsn と同じ型である保証がない

これを解決するためにインターフェースの再設計を行いました。そもそも何故 Cache 構造体にメソッドを生やしたかったのでしょうか。

  • Cache 構造体が保持するフィールドの情報を引き継ぎたい
  • Cache のメソッドを扱いたい

これを解決するために構造体の埋め込みを行うことにしました。そこで Number constraints を必ず扱える NumberCache 構造体を定義することにしました。

type NumberCache[K comparable, V Number] struct {
	*Cache[K, V]
}

こうすると Cache 構造体に渡される値の型は必ず Number constraint を満たすことが保証できます。そしてこの NumberCache 構造体に Increment メソッドを与えることができました。

func (c *NumberCache[K, V]) Increment(k K, n V) (val V, _ error) {
	got, ok := c.Cache.items[k]
	if !ok {
		return val, errors.New("not found")
	}
	nv := got + n
	c.Cache.items[k] = nv
	return val, nil
}

https://gotipplay.golang.org/p/poQeWw4UE_L

微妙だった点

もう一度 Cache 構造体の定義を見てみましょう。

type Cache[K comparable, V any] struct {
	items map[K]V
}

map のキーが comparable と呼ばれる == != を行える型を受け付けられる言語仕様として導入される constraints を使っています。

実はこの constraints が微妙だと感じています。なぜ微妙なのか説明するために comparable な値を比較する関数を定義します。

func Equal[T comparable](v1, v2 T) bool {
	return v1 == v2
}

一見比較可能な型のみを受け付けられ、コンパイル時に比較できない型が渡ってきそうだった場合にエラーになって気づけて便利だと思うかもしれません。

しかし Go の仕様では interface{} もこの comparable を満たしてしまいます。筆者はこの点に対して微妙だと感じています。

interface{} を満たすことができると次のようなコードはコンパイルが可能です。

func main() {
	v1 := interface{}(func() {})
	v2 := interface{}(func() {})
	Equal(v1, v2)
}

これは比較できない型である func()interface{} にキャストすることで comparable な型として見なすことが可能になることを示しています。 interface{} は実行時に初めてそれが比較可能な型なのかどうかを知ることになります。

複雑なコードであれば実行して panic が発生することで初めて気がつくことになるでしょう。

https://gotipplay.golang.org/p/tbKKuehbzUv

筆者は interface{} を受け付けない comparable な constraints があるとコンパイル時に気づくことができると考えています。

この constraints は Go の利用者が定義できるのでしょうか?答えはできません。

なぜなら compareble には「比較可能な構造体」と「比較可能な配列」を含んでいます。これらの constraints は言語ユーザー側で定義することが現状できません。従って筆者は Go の仕様として提供してもらうことを望んでいます。そのプロポーザルも作成したのでもし共感していただけるのであれば 👍 をつけてもらえると嬉しいです。

https://github.com/golang/go/issues/49587

脚注
  1. proposal は出ているようですが Go 1.18 までに間に合いそうもありません。 ↩︎

Discussion