ジェネリクスをいつ使うべきか

When To Use Generics by Ian Lance Taylor

はじめに

これは、私がGoogle Open Source LiveGopherCon 2021で行った講演をブログ記事にしたものです。

Go 1.18のリリースでは、ジェネリックプログラミングのサポートという大きな新しい言語機能が追加されました。この記事では、ジェネリクスとは何か、どう使うのかについては説明しません。この記事のテーマは、Goのコードでジェネリクスをいつ使うべきか、そしていつ使うべきでないかです。

念のため断っておくと、ここで示すのは厳密なルールではなく、一般的なガイドラインです。最終的には自分自身の判断に従ってください。ですが、判断に迷うときには、ここで説明するガイドラインに従うことをお勧めします。

コードを書く

まずはGoプログラミング全般に通じるガイドラインから始めましょう。それは、型を定義するのではなく、コードを書くことでGoのプログラムを作るということです。ジェネリクスに関して言えば、型パラメータの制約を定義することからプログラムを書き始めているとしたら、おそらく間違った道を進んでいます。まずは関数を書くことから始めましょう。型パラメータが役立つとはっきりわかった時点で、後から追加するのは簡単です。

型パラメータが役立つとき

とはいえ、型パラメータが役立つケースについても見ていきましょう。

言語定義のコンテナ型を使うとき

1つ目のケースは、言語自身が定義している特別なコンテナ型、つまりスライス、マップ、チャネルを扱う関数を書くときです。関数がこれらの型の引数を持ち、その関数のコードが要素の型について特に何も仮定していないのであれば、型パラメータを使うのが有用かもしれません。

たとえば、次の関数は、任意の型のマップに含まれるすべてのキーをスライスとして返します。

// MapKeys はmに含まれるすべてのキーのスライスを返す。
// キーは特定の順序では返されない。
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
}

このコードはマップのキーの型について何も仮定しておらず、マップの値の型もまったく使っていません。どんなマップ型に対しても動作します。これは型パラメータを使うのに適した候補と言えます。

この種の関数において型パラメータの代替となるのは、通常はリフレクションを使う方法です。しかしリフレクションはより扱いにくいプログラミングモデルであり、ビルド時に静的型チェックされず、実行時にも遅くなることがよくあります。

汎用のデータ構造

型パラメータが役立つもう1つのケースは、汎用のデータ構造です。汎用のデータ構造とは、スライスやマップのようなものですが、言語に組み込まれていないもの、たとえば連結リストや二分木のようなものを指します。

現在、そのようなデータ構造を必要とするプログラムは、通常次のどちらかを行っています。特定の要素型を使って書くか、インターフェース型を使うかです。特定の要素型を型パラメータに置き換えると、プログラムの他の部分や他のプログラムでも使えるような、より汎用的なデータ構造を作れます。インターフェース型を型パラメータに置き換えると、データをより効率的に保存できるようになりメモリ資源を節約できます。またコードが型アサーションを避けられるようになり、ビルド時に完全に型チェックされるようにもなります。

たとえば、型パラメータを使った二分木のデータ構造は次のようになるでしょう(一部を抜粋)。

// Tree は二分木。
type Tree[T any] struct {
    cmp  func(T, T) int
    root *node[T]
}

// node はTree内のノード。
type node[T any] struct {
    left, right  *node[T]
    val          T
}

// find はvalを含むノードへのポインタを返す。
// valが存在しない場合は、valを追加するとしたら配置される
// 場所へのポインタを返す。
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 はvalがまだbtに存在しなければvalを挿入し、
// 挿入したかどうかを報告する。
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 の例は、もう1つの一般的なガイドラインを示しています。比較関数のようなものが必要なときは、メソッドよりも関数を優先するということです。

Tree 型を、要素の型に Compare メソッドや Less メソッドを持つことを要求するように定義することもできたでしょう。これはそのメソッドを要求する制約を書くことで実現できます。つまり、 Tree 型をインスタンス化するために使われるどんな型引数も、そのメソッドを持つ必要があるということです。

その結果として、 int のような単純なデータ型で Tree を使いたい人は誰でも、独自の整数型を定義し、独自の比較メソッドを書かなければならなくなります。上のコードのように Tree を比較関数を受け取るよう定義しておけば、望みの関数を渡すのは簡単です。その比較関数を書くのは、メソッドを書くのと同じくらい簡単です。

もし Tree の要素の型がたまたますでに Compare メソッドを持っているのであれば、 ElementType.Compare のようなメソッド式を比較関数としてそのまま使えます。

言い換えれば、型にメソッドを追加するよりも、メソッドを関数に変換する方がずっと簡単だということです。ですから、汎用的なデータ型に対しては、メソッドを要求する制約を書くよりも、関数を優先してください。

共通のメソッドを実装する

型パラメータが役立つもう1つのケースは、異なる複数の型が何らかの共通のメソッドを実装する必要があり、その実装がどの型でも同じに見える場合です。

たとえば、標準ライブラリの sort.Interface を考えてみましょう。これは型が LenSwapLess という3つのメソッドを実装することを要求します。

次の例は、任意のスライス型に対して sort.Interface を実装するジェネリックな型 SliceFn です。

// SliceFn はT型のスライスに対してsort.Interfaceを実装する。
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 は比較関数を使ってsをその場でソートする。
func SortFn[T any](s []T, less func(T, T) bool) {
    sort.Sort(SliceFn[T]{s, less})
}

これは標準ライブラリの sort.Slice 関数に似ていますが、比較関数がスライスのインデックスではなく値を使って書かれている点が異なります。

この種のコードに型パラメータを使うのが適切なのは、どのスライス型でもメソッドがまったく同じに見えるからです。

(補足しておくと、Go 1.18ではなくGo 1.19では、比較関数を使ってスライスをソートするジェネリックな関数がおそらく追加される見込みで、そのジェネリック関数はおそらく 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)

そのような変更はしないでください。型パラメータを省くことで、関数は書きやすく読みやすくなりますし、実行時間もおそらく変わりません。

最後の点は強調しておく価値があります。ジェネリクスの実装方法にはいくつかの選択肢がありますし、実装は今後時間をかけて変化し改良されていくでしょう。ですが、Go 1.18で使われている実装では、多くの場合、型パラメータである型の値は、インターフェース型である値とほぼ同じように扱われます。つまり、型パラメータを使うことは、一般にインターフェース型を使うより速くなるわけではないということです。ですから、単に速度のためにインターフェース型を型パラメータに変更するのはやめてください。おそらく実行速度は変わらないからです。

メソッド実装が異なる場合

型パラメータとインターフェース型のどちらを使うか決めるときは、メソッドの実装がどうなっているかを考えてください。先ほど、メソッドの実装がすべての型で同じであれば、型パラメータを使うべきだと述べました。逆に、実装が型ごとに異なるのであれば、インターフェース型を使って型ごとに異なるメソッドの実装を書くべきで、型パラメータは使うべきではありません。

たとえば、ファイルからの Read の実装は、乱数生成器からの Read の実装とはまったく似ていません。つまり、2つの異なる Read メソッドを書き、 io.Reader のようなインターフェース型を使うべきだということです。

リフレクションを使う場面

Goには実行時のリフレクションがあります。リフレクションは、任意の型に対して動作するコードを書けるという点で、一種のジェネリックプログラミングを可能にします。

ある操作が、メソッドを持たない型さえもサポートする必要があり(そのためインターフェース型では役に立たず)、かつその操作が型ごとに異なる場合(そのため型パラメータが適切ではない場合)には、リフレクションを使ってください。

この例としてはencoding/jsonパッケージが挙げられます。エンコードするすべての型が MarshalJSON メソッドを持つことを要求したくはないので、インターフェース型は使えません。しかしインターフェース型のエンコードは構造体型のエンコードとはまったく似ていないため、型パラメータも使うべきではありません。そこでこのパッケージはリフレクションを使っています。そのコードは単純ではありませんが、うまく機能しています。詳しくはソースコードを参照してください。

単純なガイドライン

最後に、ジェネリクスをいつ使うべきかについてのこの議論は、たった1つの単純なガイドラインにまとめられます。

まったく同じコードを、使っている型だけが違う形で何度も書いていることに気づいたら、型パラメータを使えないか検討してください。

別の言い方をすれば、まったく同じコードを何度も書こうとしていると気づくまでは、型パラメータの使用を避けるべきだということです。

By Ian Lance Taylor