はじめに
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で告知します。