ふわっとテック日記

テック系のことをメインに書いていきます。go言語/typescript/javascriptが好きです。たまに趣味(筋トレ)の話や日常系の記事も書きたいな〜と思っています。

Go言語 - ジェネリクスで型引数を分解するというテクニック

Go言語の1.21ではslicesmapsパッケージが追加され、スライスやマップに対する操作が格段にシンプルにできるようになりました。

本記事ではそんなslicesパッケージのClone関数を手本に

その実装の詳細説明と、そこから導けるジェネリクスの型制約実装におけるテクニックの解説をしていきます。


本記事は下記記事の翻訳、要約をしつつ執筆しています。

go.dev




スライスのコピーを作成するslices.Clone関数は、次のような実装になっています。

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

この関数はボディ(return append(s[:0:0], s...))のシンプルさに比べて、シグネチャClone[S ~[]E, E any](s S) S)が複雑な構成になっています。

ここでは、シグネチャがなぜこういった書き方になっているのかの解説と、そこから学べるジェネリクスの型引数定義のテクニックについて解説していきます。

単純なクローン

任意の型のスライスを引数に取り、新しいスライスを返す関数

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

上記は型引数をEの1つのみ持っています。型Eのスライスであるsを引数に取り、同様の型のスライスを返します。

このシグネチャはGoのジェネリックに馴染みのある方ならシンプルで分かりやすいと思います。

しかし、 これには問題が1つあります。

名前付きのスライス型はGoではあまり使われませんが、このような感じで使うことができます

type MySlice []string

func (s MySlice) String() string {
    return strings.Join(s, "+")
}

ここで、MySliceのコピーを作成し、ソートをかけた後にString()関数の結果を返したいとします。

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 Clone1(s []string) []string

Go asssignment rulesで記載されている通り、MySlice型をstring型の引数としてClone1に渡すことはできます。

go.dev

しかし、Clone1の返り値はMySlice型ではなくstringです。それゆえに、[]stringがStringメソッドを持っていないということでこのエラーが起きるわけです。

柔軟なクローン

上記のような問題を解決するためには、引数と全く同様の型を返す必要があります。

MySlice型を引数に指定したなら、MySlice型を返すようにしなければなりません。

そうするには、このようにする必要があるでしょう。

func Clone2[S ?](s S) S // このままだと動作しないので注意

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

型制約の?部分はもちろんこのままではダメで、関数の処理が書けるような制約を定義する必要があります。

Clone1ではanyを指定していましたが、ここではsがスライスになるような制約にしなければいけません。スライスの要素の型はなんでも良いです。

仮にこの要素型をEとし、Clone1のようなシグネチャにしてみましょう。

func Clone3[S []E](s S) S // まだ動作しない

これでもまだ有効ではありません。Eの定義がされていないからです。要素型はなんでも良いので、Eはanyとして定義できます。

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

最終形に近づいてきました。

このClone4をコンパイルして呼び出すと、このようなエラーが発生します。

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

Eという型制約はスライス型のリテラル(string)のみ許可するため、MySliceは[]Eを満たしていないのです。

Underlying type 制約

上記エラーは~(チルダ)を型パラメータに付与することで解決できます。

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

S []E, E any」として定義された制約は、名前の付いていないスライス型ならなんでも適用できます。

しかし、スライスリテラルとして定義された名前付きの型は適用できません。

チルダをつけて「S ~[]E, E any」とすることで、SはUnderlying typeがスライス型のものはなんでも適用できる、という意味になります。

MySliceのUnderlying typeは[]stringなので、この型制約に合致するということになります。


こういった理屈により、冒頭のClone関数のシグネチャは動作しています。


2022に執筆した私の記事でも、Underlying typeについて軽く解説しているのでご参照ください。

rrioh.hatenablog.com



そもそも、なぜチルダをわざわざつけないといけないのでしょうか?

Underlying typeをデフォルトで受け付けるようにしても良いんじゃないかという声も聞こえてきそうです。


これを掘り下げていくには、まず「[T ~MySlice]」のような制約がなんの意味もなさないことを確認するところから始めます。

例えばtype MySlice2 MySliceのような型を定義したとしても、MySlice2のUnderlying typeはMySliceではなく、[]stringになります。

MySliceがUnderlying typeの型は存在し得ないため、[T ~MySlice]は、一切の型を受け付けません。

これを避けるため、コンパイラはこのようなエラーを出力します。

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

もしチルダをなくして[S []E]がUnderlying typeが[]Eのもの全てを受け付けるようになれば、[S MySlice]のような表記の意味を別に定義しなくてはいけません。

[S MySlice]を禁止したり、[S MySlice]の場合はMySliceのみ受け付ける、などといった仕様策定も考えられますが、どちらにしても事前定義された型との兼ね合いで混乱が生じます。

なぜならintのような事前定義された型は、Underlying typeが自分自身だからです。

デフォルトでUnderlying typeが全て許可されているとしたら、Underlying typeがintの制約は[T int]となるでしょう。

しかし、この場合は上述の[S MySlice]と形がほぼ同じなのに別の挙動をすることになり、混乱極まってしまいますね。

こういった事情により、Underlying typeの許可はデフォルトではなく、チルダの使用が必要なのです。

型推定

ここでは、slices.Cloneの実行が型推定によってどのように単純化されているのかをみていきます。

シグネチャ: func Clone[S ~[]E, E any](s S) S

Cloneの呼び出しで、パラメータsにスライスが渡されます。シンプルな型推定で、コンパイラは型パラメータSに当たる型引数の型が、Cloneに渡されたスライスの型であると推定します。

そして、Eの型が、Sに渡された引数の要素の型であると推定します。これはつまり、

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

このような書き方をしなくても

 c := Clone(ms)

こう書くだけで型推定が働き、コードが動作するということです。


Cloneに言及するだけで実行はしない場合は、コンパイラにとって型推定に使う材料が存在しないことになります。

そのため、Sにあたる型引数を指定する必要があります。

この場合でも、EはSから推定されるため、指定する必要はありません。つまり、

myClone := Clone[MySlice, string]

こう書く必要はなく、

myClone := Clone[MySlice]

これで良いということです。

型引数の分解

上記で見てきたテクニックは、型引数(S)を別の型引数(E)を使って定義するというものであり、ジェネリック関数のシグネチャ内で型を分解する方法の1つです。

型を分解することによって、型の命名や制約が余すところなくできます。

例えばこれはmaps.Cloneシグネチャです。

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

slices.Cloneと同様に、引数mの型に型引数を使用しています。そしてそれをK、Vという別の型引数に分解しています。


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

この関数では、引数がスライスである必要があり、その要素はStringメソッドを持つ必要があります。




ジェネリクスの型制約は、少し複雑な定義をしようとするとたちまち複雑化してしまったりします。

型引数を別の型引数に分解するという手法は、そんな複雑な型制約をシンプルにしてくれるとても優れたテクニックだと思います。

Goのコーディングの際にはぜひ頭の片隅に入れておきたい技ですね。