Go 1.18 の Generics を使ったキャッシュライブラリを作った時に見つけた tips と微妙な点
GitHub にコードを上げてます。
2021-11-17 時点で Go の Generics の機能を使ったキャッシュライブラリはおそらくないでしょう。Generics を使った例の一つとして参考にしてください。
Star をくれると大喜びします。
本記事ではこのキャッシュライブラリを作ってみて Generics に対して気が付いた点と発見した tips や微妙だった点を紹介していきます。
もし Go の Generics って何ができるんだっけ?となっている方は是非こちらの記事にも目を通してみてください。
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
}
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
に満たしていれば加算し、それ以外では何もしないメソッドです。
これはコンパイルが通りません。
- constraints を使った条件分岐が提供されていない[1]
- 現状 constraints は interface である。Go は interface を使った型アサーションが出来ない
-
n
の型が決まりきってないので+
演算ができない - そもそも
items
がn
と同じ型である保証がない
これを解決するためにインターフェースの再設計を行いました。そもそも何故 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
}
微妙だった点
もう一度 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 が発生することで初めて気がつくことになるでしょう。
筆者は interface{}
を受け付けない comparable
な constraints があるとコンパイル時に気づくことができると考えています。
この constraints は Go の利用者が定義できるのでしょうか?答えはできません。
なぜなら compareble
には「比較可能な構造体」と「比較可能な配列」を含んでいます。これらの constraints は言語ユーザー側で定義することが現状できません。従って筆者は Go の仕様として提供してもらうことを望んでいます。そのプロポーザルも作成したのでもし共感していただけるのであれば 👍 をつけてもらえると嬉しいです。
Discussion