Goブログ

型パラメーターの分解

Ian Lance Taylor
2023年9月26日

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

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

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

これは、ゼロ容量のスライスに追加すると、新しいバッキング配列が割り当てられるために機能します。関数本体は関数シグネチャよりも短くなります。これは、本体が短いという理由もありますが、シグネチャが長いという理由もあります。このブログ記事では、シグネチャがなぜこのように書かれているのかを説明します。

シンプルなClone

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

func Clone1[E any](s []E) []E {
    // body omitted
}

ジェネリック関数Clone1には、単一の型パラメーターEがあります。型Eのスライスである単一の引数sを受け取り、同じ型のスライスを返します。このシグネチャは、Goのジェネリクスに詳しい人にとってはわかりやすいものです。

ただし、問題があります。名前付きスライス型はGoでは一般的ではありませんが、使用する人はいます。

// MySlice is a slice of strings with a special String method.
type MySlice []string

// String returns the printable version of a MySlice value.
func (s MySlice) String() string {
    return strings.Join(s, "+")
}

MySliceのコピーを作成し、ソートされた文字列で印刷可能なバージョンを取得するとします。

func PrintSorted(ms MySlice) string {
    c := Clone1(ms)
    slices.Sort(c)
    return c.String() // FAILS TO COMPILE
}

残念ながら、これは機能しません。コンパイラーはエラーを報告します。

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

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

func InstantiatedClone1(s []string) []string

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

柔軟なClone

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

それはこのようなものでなければならないことがわかっています。

func Clone2[S ?](s S) S // INVALID

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

ここでは、制約を?として記述しましたが、これはプレースホルダーです。これを機能させるには、関数本体を記述できる制約を記述する必要があります。Clone1の場合、要素型に対してanyの制約を使用するだけで済みました。Clone2の場合、それは機能しません。sがスライス型であることを要求する必要があります。

スライスが必要なことはわかっているので、Sの制約はスライスである必要があります。スライスの要素型は気にしないので、Clone1で行ったようにEと呼びましょう。

func Clone3[S []E](s S) S // INVALID

これはまだ無効です。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)

コンパイラーは、MySliceが制約[]Eを満たしていないため、型パラメーターSに型引数MySliceを使用できないことを伝えています。それは、制約としての[]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]は役に立ちません。この混乱を避けるために、言語は[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に渡されるスライスの型であると推論できます。次に、型推論は、Eの型引数がSに渡される型引数の要素型であることを認識するのに十分強力です。

これは、次のように記述できることを意味します。

    c := Clone(ms)

次のように記述する必要はありません。

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

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

つまり、次のように記述できます。

    myClone := Clone[MySlice]

次のように記述する必要はありません。

    myClone := Clone[MySlice, string]

型パラメーターの分解

ここで使用した一般的な手法(1つの型パラメーターEを使用して別の型パラメーターSを定義する)は、ジェネリック関数シグネチャで型を分解する方法です。型を分解することで、型のすべての側面を名前を付け、制約することができます。

たとえば、maps.Cloneのシグネチャを次に示します。

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

slices.Cloneと同様に、パラメーターmの型に型パラメーターを使用し、次に他の2つの型パラメーターKVを使用して型を分解します。

maps.Cloneでは、マップキー型に必須であるため、Kを比較可能に制約します。コンポーネント型は好きなように制約できます。

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

これは、WithStringsの引数が、要素型にStringメソッドがあるスライス型である必要があることを示しています。

すべてのGo型はコンポーネント型から構築できるため、常に型パラメーターを使用してそれらの型を分解し、必要に応じて制約することができます。

次の記事: 型推論について知っておきたかったすべてと、もう少し
前の記事: Go 1.22でのForループの修正
ブログインデックス