これは、Google Open Source Liveでの私の講演をブログ記事にしたものです:
https://youtu.be/nr8EpUO9jhw?si=jlWTapr6NM6isLgt&embedable=true
そしてGopherCon 2021での講演:
https://youtu.be/Pae9EeCdy8?si=M-87Eisb2nU1qmJ&embedable=true
Go 1.18リリースでは、ジェネリックプログラミングのサポートという主要な新言語機能が追加されました。この記事では、ジェネリクスとは何か、またはその使い方については説明しません。この記事は、Goコードでジェネリクスをいつ使うべきか、そしていつ使うべきでないかについてです。
明確にしておきますが、厳格なルールではなく、一般的なガイドラインを提供します。自分の判断を使ってください。しかし、確信が持てない場合は、ここで説明するガイドラインを使用することをお勧めします。
Goプログラミングの一般的なガイドラインから始めましょう:型を定義するのではなく、コードを書くことでGoプログラムを書きましょう。ジェネリクスに関しては、型パラメータの制約を定義することからプログラムを書き始めると、おそらく間違った道を進んでいます。関数を書くことから始めましょう。型パラメータが役立つことが明らかになったら、後で簡単に追加できます。
それでは、型パラメータが役立つケースを見てみましょう。
一つのケースは、言語によって定義された特殊なコンテナ型(スライス、マップ、チャネル)を操作する関数を書く場合です。関数がこれらの型のパラメータを持ち、関数コードが要素型について特定の仮定をしない場合、型パラメータを使用すると便利かもしれません。
例えば、任意の型のマップ内のすべてのキーのスライスを返す関数は次のようになります:
// MapKeys returns a slice of all the keys in m. // The keys are not returned in any particular order. func MapKeys[Key comparable, Val any](m map[Key]Val) []Key { s := make([]Key, 0, len(m)) for k := range m { s = append(s, k) } return s }
このコードはマップのキー型について何も仮定せず、マップの値型をまったく使用しません。任意のマップ型で動作します。これにより、型パラメータを使用するのに適した候補となります。
この種の関数に対する型パラメータの代替手段は通常リフレクションを使用することですが、それはより扱いにくいプログラミングモデルであり、ビルド時に静的型チェックされず、実行時にしばしば遅くなります。
型パラメータが役立つもう一つのケースは、汎用データ構造のためです。汎用データ構造とは、スライスやマップのようなものですが、言語に組み込まれていないもの、例えばリンクリストやバイナリツリーなどです。
今日、そのようなデータ構造を必要とするプログラムは通常、次の2つのうちの1つを行います:特定の要素型で書くか、インターフェース型を使用するかです。特定の要素型を型パラメータに置き換えることで、プログラムの他の部分や他のプログラムで使用できるより一般的なデータ構造を作成できます。インターフェース型を型パラメータに置き換えることで、データをより効率的に保存し、メモリリソースを節約することができます。また、型アサーションを避け、ビルド時に完全に型チェックされるコードを可能にします。
例えば、型パラメータを使用したバイナリツリーデータ構造の一部は次のようになります:
// Tree is a binary tree. type Tree[T any] struct { cmp func(T, T) int root *node[T] } // A node in a Tree. type node[T any] struct { left, right *node[T] val T } // find returns a pointer to the node containing val, // or, if val is not present, a pointer to where it // would be placed if added. func (bt *Tree[T]) find(val T) **node[T] { pl := &bt.root for *pl != nil { switch cmp := bt.cmp(val, (*pl).val); { case cmp < 0: pl = &(*pl).left case cmp > 0: pl = &(*pl).right default: return pl } } return pl } // Insert inserts val into bt if not already there, // and reports whether it was inserted. func (bt *Tree[T]) Insert(val T) bool { pl := bt.find(val) if *pl != nil { return false } *pl = &node[T]{val: val} return true }
ツリーの各ノードには、型パラメータTの値が含まれています。ツリーが特定の型引数でインスタンス化されると、その型の値がノードに直接格納されます。インターフェース型として格納されることはありません。
これは型パラメータの合理的な使用法です。なぜなら、Treeデータ構造(メソッド内のコードを含む)は、要素型Tとは大部分独立しているからです。
Treeデータ構造は、要素型Tの値を比較する方法を知る必要があります。そのために渡された比較関数を使用します。これはfindメソッドの4行目、bt.cmpの呼び出しで確認できます。それ以外では、型パラメータはまったく重要ではありません。
Treeの例は、もう一つの一般的なガイドラインを示しています:比較関数のようなものが必要な場合、メソッドよりも関数を優先してください。
要素型がCompareまたはLessメソッドを持つことを要求するようにTree型を定義することもできました。これはメソッドを要求する制約を書くことで行われ、Tree型をインスタンス化するために使用される任意の型引数がそのメソッドを持つ必要があることを意味します。
その結果、intのような単純なデータ型でTreeを使用したい人は、独自の整数型を定義し、独自の比較メソッドを書く必要があります。上記のコードのように、Treeが比較関数を取るように定義すれば、望みの関数を簡単に渡すことができます。比較関数を書くのは、メソッドを書くのと同じくらい簡単です。
Treeの要素型がすでにCompareメソッドを持っている場合、ElementType.Compareのようなメソッド式を比較関数として使用するだけです。
別の言い方をすれば、メソッドを関数に変えるほうが、型にメソッドを追加するよりもはるかに簡単です。したがって、汎用データ型には、メソッドを要求する制約を書くよりも、関数を優先してください。
型パラメータが役立つもう一つのケースは、異なる型が何らかの共通メソッドを実装する必要があり、異なる型の実装がすべて同じように見える場合です。
例えば、標準ライブラリのsort.Interfaceを考えてみましょう。これは型が3つのメソッド:Len、Swap、Lessを実装することを要求します。
以下は、任意のスライス型に対してsort.Interfaceを実装するジェネリック型SliceFnの例です:
// SliceFn implements sort.Interface for a slice of T. type SliceFn[T any] struct { s []T less func(T, T) bool } func (s SliceFn[T]) Len() int { return len(s.s) } func (s SliceFn[T]) Swap(i, j int) { s.s[i], s.s[j] = s.s[j], s.s[i] } func (s SliceFn[T]) Less(i, j int) bool { return s.less(s.s[i], s.s[j]) }
任意のスライス型に対して、LenとSwapメソッドはまったく同じです。Lessメソッドは比較を必要とし、これが名前SliceFnのFn部分です。先ほどのTreeの例と同様に、SliceFnを作成するときに関数を渡します。
以下は、比較関数を使用して任意のスライスをソートするためにSliceFnを使用する方法です:
// SortFn sorts s in place using a comparison function. func SortFn[T any](s []T, less func(T, T) bool) { sort.Sort(SliceFn[T]{s, less}) }
これは標準ライブラリの関数sort.Sliceに似ていますが、比較関数はスライスのインデックスではなく値を使用して書かれています。
このような種類のコードに型パラメータを使用することは、メソッドがすべてのスライス型で全く同じように見えるため適切です。
(Go 1.19(1.18ではない)には、比較関数を使用してスライスをソートするジェネリック関数が含まれる可能性が高く、そのジェネリック関数はおそらくsort.Interfaceを使用しないでしょう。提案#47619を参照してください。しかし、この特定の例がおそらく役に立たないとしても、一般的なポイントはまだ真実です:関連するすべての型に対して同じように見えるメソッドを実装する必要がある場合、型パラメータを使用することは合理的です。)
では、質問の反対側について話しましょう:型パラメータをいつ使用しないかです。
ご存知のように、Goにはインターフェース型があります。インターフェース型は一種のジェネリックプログラミングを可能にします。
例えば、広く使用されているio.Readerインターフェースは、情報を含む任意の値(例えばファイル)または情報を生成する値(例えば乱数生成器)からデータを読み取るためのジェネリックなメカニズムを提供します。ある型の値で行う必要があるのがその値でメソッドを呼び出すことだけなら、型パラメータではなくインターフェース型を使用してください。io.Readerは読みやすく、効率的で、効果的です。Readメソッドを呼び出して値からデータを読み取るために型パラメータを使用する必要はありません。
例えば、インターフェース型だけを使用する最初の関数シグネチャを、型パラメータを使用する2番目のバージョンに変更したくなるかもしれません。
func ReadSome(r io.Reader) ([]byte, error) func ReadSome[T io.Reader](r T) ([]byte, error)
そのような変更はしないでください。型パラメータを省略すると、関数は書きやすく、読みやすくなり、実行時間もおそらく同じになります。
最後のポイントを強調する価値があります。ジェネリクスをいくつかの異なる

