型パラメータを分解する

Deconstructing Type Parameters by Ian Lance Taylor

sliceパッケージの関数シグネチャ

slices.Clone 関数はとてもシンプルです。任意の型のスライスのコピーを作成します。

func Clone[S ~[]E, E any](s S) S {
    return append(s[:0:0], s...)
}

これがうまく動作するのは、容量がゼロのスライスに対してappendを行うと、新しいバッキング配列が確保されるためです。 関数の本体はシグネチャよりも短くなっていますが、これは本体自体が短いことに加えて、シグネチャが長いことにもよります。 この記事では、なぜこのようなシグネチャになっているのかを説明します。

シンプルなClone

まずはシンプルなジェネリックのClone関数を書くところから始めましょう。これはsliceパッケージにあるものではありません。 任意の要素型のスライスを受け取り、新しいスライスを返す関数が欲しいとします。

func Clone1[E any](s []E) []E {
    // 本体は省略
}

このジェネリック関数Clone1は、型パラメータEを1つだけ持っています。引数として型Eのスライスであるsを1つ受け取り、 同じ型のスライスを返します。このシグネチャは、Goのジェネリクスに慣れている人であれば誰でも理解しやすいものです。

しかし、ここには問題があります。名前付きのスライス型はGoではあまり一般的ではありませんが、実際に使われることがあります。

// MySliceは特別なStringメソッドを持つ文字列のスライスです。
type MySlice []string

// Stringは、MySlice値を表示用の文字列に変換して返します。
func (s MySlice) String() string {
    return strings.Join(s, "+")
}

MySliceのコピーを作り、その文字列をソートした状態で表示用の文字列を取得したいとします。

func PrintSorted(ms MySlice) string {
    c := Clone1(ms)
    slices.Sort(c)
    return c.String() // コンパイルエラーになる
}

残念ながら、これはうまくいきません。コンパイラは次のようなエラーを報告します。

c.String undefined (type []string has no field or method String)

型パラメータを型引数に置き換えてClone1を手動でインスタンス化してみると、問題が見えてきます。

func InstantiatedClone1(s []string) []string

Goの代入規則により、型MySliceの値を型[]stringの引数に渡せるので、 Clone1を呼び出すこと自体は問題ありません。しかし、Clone1が返すのは型[]stringの値であって、型MySliceの値ではありません。 型[]stringにはStringメソッドがないため、コンパイラがエラーを報告するのです。

柔軟なClone

この問題を解決するには、引数と同じ型を返すバージョンのCloneを書く必要があります。それができれば、 型MySliceの値でCloneを呼び出したときに、結果として型MySliceの値が返るようになります。

次のような形になるはずだとわかります。

func Clone2[S ?](s S) S // 無効

このClone2関数は、引数と同じ型の値を返します。

ここでは制約を?と書いていますが、これは単なるプレースホルダーです。これを実際に動かすには、関数の本体を書けるような 制約を用意する必要があります。Clone1では要素型の制約として単にanyを使えばよかったのですが、Clone2ではそれでは うまくいきません。sがスライス型であることを要求したいからです。

スライスが欲しいとわかっているので、Sの制約はスライスでなければなりません。スライスの要素型が何であるかは気にしないので、 Clone1のときと同様にEと呼ぶことにしましょう。

func Clone3[S []E](s S) S // 無効

これはまだ無効です。なぜならEを宣言していないからです。Eの型引数はどんな型でもよいので、Eそのものも型パラメータで なければなりません。どんな型でもよいので、その制約はanyになります。

func Clone4[S []E, E any](s S) S

これでだいぶ近づいてきて、少なくともコンパイルは通りますが、まだ完全ではありません。このバージョンをコンパイルして Clone4(ms)を呼び出すと、エラーが発生します。

MySlice does not satisfy []string (possibly missing ~ for []string in []string)

コンパイラは、型パラメータSに型引数MySliceを使えないと言っています。なぜならMySliceは制約[]Eを満たさないからです。 これは、制約としての[]E[]stringのようなスライスの型リテラルのみを許可し、MySliceのような名前付き型を許可しないためです。

基底型の制約

エラーメッセージが示唆しているように、答えは~を追加することです。

func Clone5[S ~[]E, E any](s S) S

繰り返しになりますが、型パラメータと制約を[S []E, E any]のように書くと、Sの型引数は名前のないスライス型であれば 何でもよいものの、スライスリテラルとして定義された名前付き型にはなれません。~を付けて[S ~[]E, E any]のように書くと、 Sの型引数は基底型がスライス型であるどんな型でもよくなります。

名前付き型type T1 T2があるとき、T1の基底型はT2の基底型と同じです。intのような定義済み型や[]stringのような 型リテラルの基底型は、その型自身です。正確な詳細については言語仕様を参照してください。 今回の例では、MySliceの基底型は[]stringです。

MySliceの基底型はスライスなので、型MySliceの引数をClone5に渡せます。お気づきかもしれませんが、Clone5の シグネチャはslices.Cloneのシグネチャと同じです。ついに目指していた形にたどり着きました。

先に進む前に、なぜGoの構文が~を要求するのかについて考えてみましょう。MySliceを渡せるようにするのが常に望ましいので あれば、それをデフォルトの挙動にしてもよいのではないかと思うかもしれません。あるいは、厳密な一致をサポートする必要が あるなら、逆に、制約[]Eのほうが名前付き型を許可し、たとえば=[]Eのような制約がスライスの型リテラルのみを許可する ようにしてもよいのではないか、とも考えられます。

これを説明するために、まず[T ~MySlice]のような型パラメータリストには意味がないことを確認しましょう。というのも、 MySliceは他のどの型の基底型にもなっていないからです。たとえば、type MySlice2 MySliceという定義があった場合、 MySlice2の基底型はMySliceではなく[]stringになります。したがって、[T ~MySlice]はどんな型も許可しないか、 あるいは[T MySlice]と同じ意味になってMySliceだけにマッチするか、のどちらかになってしまいます。どちらにしても、 [T ~MySlice]は有用ではありません。この混乱を避けるため、Goの言語仕様では[T ~MySlice]を禁止しており、 コンパイラは次のようなエラーを出します。

invalid use of ~ (underlying type of MySlice is []string)

もしGoがチルダを要求せず、[S []E]が基底型が[]Eであるどんな型にもマッチするとしたら、[S MySlice]の意味を 定義する必要が出てきます。

[S MySlice]を禁止するか、あるいは[S MySlice]MySliceにしかマッチしないとすることもできますが、どちらの アプローチも定義済み型に関して問題にぶつかります。intのような定義済み型は、それ自身が自分の基底型です。基底型が intであるどんな型引数でも受け付ける制約を書けるようにしたいわけです。現在の言語仕様では、[T ~int]と書くことで それが可能です。チルダを要求しないとすると、「基底型がintであるどんな型」かを表す方法が依然として必要になります。 それを表す自然な書き方は[T int]でしょう。そうなると、[T MySlice][T int]は、見た目はとてもよく似ているのに、 異なる挙動をすることになってしまいます。

[S MySlice]が「基底型がMySliceの基底型と同じであるどんな型にもマッチする」という意味だとすることもできるかも しれませんが、それでは[S MySlice]という書き方自体が不要で紛らわしいものになってしまいます。

私たちは、~を要求し、型そのものではなく基底型にマッチさせているのがどこなのかを明確にするほうがよいと考えています。

型推論

slices.Cloneのシグネチャについて説明したところで、実際にslices.Cloneを使う際に型推論によってどのように 簡潔になるかを見てみましょう。Cloneのシグネチャは次の通りでした。

func Clone[S ~[]E, E any](s S) S

slices.Cloneの呼び出しでは、パラメータsにスライスが渡されます。単純な型推論によって、コンパイラは型パラメータ Sの型引数がCloneに渡されたスライスの型であると推論できます。さらに型推論は、Sに渡された型引数の要素型がEの 型引数であることも見抜けるだけの能力を持っています。

つまり、次のように書けます。

    c := Clone(ms)

次のように書く必要はありません。

    c := Clone[MySlice, string](ms)

Cloneを呼び出さずに参照するだけの場合は、コンパイラが推論に使える情報がないため、Sの型引数を指定する必要が あります。幸い、その場合でも型推論はSの引数からEの型引数を推論できるので、Eを別途指定する必要はありません。

つまり、次のように書けます。

    myClone := Clone[MySlice]

次のように書く必要はありません。

    myClone := Clone[MySlice, string]

型パラメータを分解する

ここまで使ってきた一般的な手法、つまり型パラメータSを別の型パラメータEを使って定義するというやり方は、 ジェネリック関数のシグネチャの中で型を分解する手法です。型を分解することで、その型のあらゆる側面に名前を付け、 制約をかけられるようになります。

たとえば、maps.Cloneのシグネチャは次のようになっています。

func Clone[M ~map[K]V, K comparable, V any](m M) M

slices.Cloneと同様に、パラメータmの型に型パラメータを使い、さらに別の2つの型パラメータKVを使って その型を分解しています。

maps.Cloneでは、マップのキー型に必要とされる通り、Kcomparableに制約しています。構成要素となる型は、 好きなように制約をかけられます。

func WithStrings[S ~[]E, E interface { String() string }](s S) (S, []string)

これは、WithStringsの引数が、要素型がStringメソッドを持つスライス型でなければならないことを表しています。

Goのすべての型は構成要素となる型から組み立てられるため、型パラメータを使ってそれらの型を分解し、好きなように 制約をかけることが常に可能です。

By Ian Lance Taylor