はじめに
Goには、1.18でジェネリクス(型パラメータ)が導入されました。ジェネリクスの導入と共に、インタフェースを型パラメタに対する型制約として利用し、新しく型セットという概念が入りました。
型制約はインタフェースとして宣言可能ですが、『比較可能な型』のような制約のもつ型セットが無限集合になる、かつ現時点でのインタフェースでの表現では難しい制約を組み込みで提供することで解決しています。
たとえば、『比較可能な型』を表現する制約としてcomparableが導入されました。ここでは、Go1.18で提供されたcomparableについてと、それがGo1.20でどう変わるのかを解説します。
なお、本記事で扱う内容は、Go1.20でリリースとしていますが、今後の変更によっては変わる可能性があります。しかし、すでに変更はRC1版としてリリースされているため、入る可能性は高いでしょう。
Go1.18で導入されたcomparableとは?
==演算子で比較したり、マップのキーにするためには、それらの型が比較可能である必要があります。たとえば、int型は比較可能な型ですが、int型のスライスh([]int)は比較できません。また、関数なども比較できませんし、比較できない型をフィールドや配列の要素として持つ型も比較できません。
Go1.18のリリースに合わせて公開されたgolang.org/x/exp/mapsでは、Keys関数やValues関数など、Goエンジニアが待っていた機能が提供されています。
たとえば、Keys関数はマップのキーをスライスで取得できますし、Values関数は値をスライスで取得できます。ジェネリクスの導入により、for range文でループする必要がなくなりました。
maps.Keys関数は次のように宣言されています。
func Keys[M ~map[K]V, K comparable, V any](m M) []K {
r := make([]K, 0, len(m))
for k := range m {
r = append(r, k)
}
return r
}
このコードをいきなり説明するには難しすぎるため、まずはmaps.Keys関数より機能が劣るMyKeys関数を作って、徐々に改善していきながら説明していきましょう。
最初はジェネリクスを使わないバージョンを考えましょう。map[string]int型を引数にとって、キーの一覧を[]string型で取得するような関数にしてみます。
これはジェネリックな関数(ジェネリクスを使った関数)を定義する際にも使えるテクニックですが、いきなりジェネリクスを使うのではなく、特定の具象型でまずは書いてみると整理しやすくなるのでおすすめです。
さて、map[string]int型版のMyKeys関数は次のようになります。
// ver. 1
func MyKeys(m map[string]int) []string {
r := make([]string, 0, len(m))
for k := range m {
r = append(r, k)
}
return r
}
多くのGoエンジニアが書いてきた、いつものコードですね。
これをキーと値の型を型パラメータにして、汎用化してみましょう。
// ver. 2
func MyKeys[K comparable, V any](m map[K]V) []K {
r := make([]K, 0, len(m))
for k := range m {
r = append(r, k)
}
return r
}
map[string]int型版と比べると、string型だったところが型パラメータKに、int型だったところが型パラメータVになっています。型パラメータは実際に関数を使う際に、MyKeys[string, int](m)のように型引数を指定して使用します。この場合は、Kがstring型、Vがint型になります。もちろん、型推論が働くので、MyKeys(m)と型引数を省略することもできます。
ここで、MyKeys関数の[K comparable, V any]の部分は型パラメータを宣言しています。comparableとanyは、それぞれ型パラメータKとVに対する型制約です。
型パラメータKに指定できる型は、比較可能である必要があるため、comparable制約が使われています。
また、anyはGo1.18で導入されたinterface{}の型エイリアスです。interface{}(empty interface)と同様の意味になります。つまり、任意の型を許す型制約(制約がない)です。
実は、MyKeys関数のver. 2では、任意のマップ型を引数に渡すことはできません。たとえば、次のように宣言されたマップを渡そうとするとコンパイルエラーになります。
追記(2022/12/13):この記述は誤りです。修正中です。
type MyMap map[string]int
そこで、MyKeysを次のように改良することでDefined typeなマップも受け付けれるようにします。
type Map[K comparable, V any] interface {
~map[K]V
}
// ver. 3
func MyKeys[M Map[K, V], K comparable, V any](m M) []K {
r := make([]K, 0, len(m))
for k := range m {
r = append(r, k)
}
return r
}
制約Mapは、次のように省略してインラインで書けます。
// ver. 3
func MyKeys[M ~map[K]V, K comparable, V any](m M) []K {
r := make([]K, 0, len(m))
for k := range m {
r = append(r, k)
}
return r
}
制約に~map[K]Vと書くと、interface{~map[K]V}と書いた場合と同じになります。
これでようやくmaps.Keys関数と同じになりました。実は、maps.Keys関数でもGo1.19まででは引数に指定できないマップが存在しました。
言語仕様で規定している比較可能(comparable)には、インタフェースを含みます。インタフェースの場合、そのインタフェースを実装している動的型(dynamic type)が比較可能ではない場合もあります。その場合は比較演算子やマップのキーに指定するとパニックを起こします。
一方、Go1.19までのcomparable制約は、インタフェースを許可していませんでした。つまり、map[any]string型のようなマップは、型としては言語仕様に沿っており、コンパイルは通りますが、comparable制約を満たしません。
つまり、このようなマップはmaps.Keys関数の引数に指定できなかったのです。
Go1.19までのcomparableの種類については『Go言語のcomparableには3つの意味がある』という記事が分かりやすいでしょう。
Go1.20でこう変わる
Issue#56548で提案されたプロポーザルによって、インタフェースもcomparable制約を満たすようになります。つまり、maps.Keys関数の引数にmap[any]string型のようなインタフェースをキーに持つマップを指定できるようになります。
Go1.20のRC1版(リリース候補版)が公開されているため、次の手順でgo1.20rc1コマンドをインストールすると試せます。
$ go install golang.org/dl/go1.20rc1@latest
$ go1.20rc1 download
なお、go1.20rc1コマンドは通常のgoコマンドと同様に扱えます。
インタフェースを除く比較可能な型であることを保証するには?
Go1.19までのcomparable制約の特性を使って、ある型Tがインタフェースを除く、比較可能な型であることを保証するためには、次のように記述できました。
func isComparable[_ comparable]() {}
var _ = isComparable[T]
型パラメータを1つ取るisComparable関数を作り、型制約をcomparableとすることで、isComparable関数をインスタンス化する際に指定する型引数を比較可能でかつインタフェースではない型に絞る方法です。
var _ = isComparable[T]は、型Tでインスタンス化を行っているため、型Tがcomparable制約を満たさないとコンパイルが通りません。
そのため、次のような型を考えた場合、型OKはコンパイルは通りますが、型NGはコンパイルが通りません。
type OK struct {
n int
}
type NG interface {
F()
}
しかし、Go1.20では、インタフェースもcomparable制約を満たすため、型NGもcomparable制約を満たすようになってしまいます。プロポーザルにはこの件に関する質問が出ています。
GoチームでGoの最初の設計者の1人であるRobert Griesemer氏によると、次のような関数を定義すると同じようなチェックができると返信しています。
func TisComparable[P T]() {
_ = isComparable[P]
}
func isComparable[_ comparable]() {}
質問者はこれに対して、「Clever.」と返信しています。筆者はここを読んだだけではさっぱり分からず、何度も読み直してやっと理解しました。
まずは、型Tが次のように構造体で比較可能であった場合を考えます。
type T struct {
n int
}
TisComparable関数の型パラメータと型制約に[P T]と記述されているため、型Tは制約として扱われます。この場合、型Tはインタフェースではないため、interface{T}と書いた場合と同様になります。つまり、この型制約は型Tだけを許可する制約となります。ちなみに、interface{int | string}のように書くとint型またはstring型のどちらかとなります。
型パラメータPは制約interface{T}を満たす型しか許されないため、型引数として指定できるのは型Tだけとなります。そのため、_ = isComparable[P]は_ = isComparable[T]と同義となるため、型Tは比較可能(ここではインタフェースも許される)となりコンパイルが通ります。
では、次に型Tが次のようなインタフェースの場合を考えます。
type T interface {
F()
}
ここでの型Tは、インタフェースであるため[P T]のように型制約に用いた場合、素直にそのまま解釈されます。つまり、型パラメータPには、メソッドFを実装している型であればどんな型でも型引数として指定できます。つまり、メソッドFがあれば、スライスでも問題ありません。
そのため、_ = isComparable[P]のPがスライスなど、比較できない型になる可能性があるため、この場合はコンパイルエラーになります。
もし、比較可能であるインタフェースを許可したい場合は、次のような型Tであればコンパイルエラーとなりませんが、このような型は現時点では型制約にしか使えません。
type T interface {
comparable
F()
}
なるほど。とても難しいですね。
おわりに
本記事では、Go1.20で導入される新しいcomparable制約の定義について説明しました。ジェネリクス周りはなかなか難しいことが多いため、理解するのは大変ですが理解できるととても楽しいです。
Gopher塾でも、そのうちジェネリクスを扱いたいなと考えておりますので、開催することが決まったらconnpassやHPで告知します。