The Go Blog

ジェネリクスの使用タイミング

イアン・ランス・テイラー
2022年4月12日

はじめに

この記事は、Google Open Source Liveでの私の講演、

およびGopherCon 2021のブログ記事版です。

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つのことのいずれかを行います。特定の要素型でそれらを記述するか、インターフェース型を使用します。特定の要素型を型パラメータに置き換えることで、プログラムの他の部分や他のプログラムで使用できる、より汎用的なデータ構造を作成できます。インターフェース型を型パラメータに置き換えることで、データをより効率的に保存でき、メモリリソースを節約できます。また、コードで型アサーションを回避し、ビルド時に完全に型チェックされることも可能になります。

例えば、型パラメータを使用した二分木データ構造の一部を以下に示します。

// 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`の例は、別の一般的なガイドラインを示しています。比較関数のようなものが必要な場合、メソッドよりも関数を好むべきです。

`Tree`型を、要素型が`Compare`または`Less`メソッドを持つことを要求するように定義することもできました。これは、メソッドを要求する制約を記述することで行われます。つまり、`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)

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

最後の点を強調しておく価値があります。ジェネリクスを実装する方法はいくつかあり、実装は時間とともに変化し改善されますが、Go 1.18で使用される実装では、多くの場合、型が型パラメータである値を、型がインターフェース型である値と非常によく似た方法で扱います。これは、型パラメータを使用しても、通常はインターフェース型を使用するよりも速くならないことを意味します。したがって、速度のためだけにインターフェース型から型パラメータに変更しないでください。なぜなら、おそらく速度は向上しないからです。

メソッドの実装が異なる場合、型パラメータを使用しないでください

型パラメータを使用するかインターフェース型を使用するかを決定する際には、メソッドの実装を検討してください。以前、メソッドの実装がすべての型で同じである場合は型パラメータを使用すると述べました。逆に、実装が型ごとに異なる場合は、インターフェース型を使用して異なるメソッドの実装を記述し、型パラメータを使用しないでください。

例えば、ファイルからの`Read`の実装は、乱数生成器からの`Read`の実装とは全く異なります。これは、2つの異なる`Read`メソッドを記述し、`io.Reader`のようなインターフェース型を使用すべきであることを意味します。

必要に応じてリフレクションを使用する

Goにはランタイムリフレクションがあります。リフレクションは、あらゆる型で動作するコードを記述できるため、一種のジェネリックプログラミングを可能にします。

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

この例として、encoding/jsonパッケージがあります。エンコードするすべての型に`MarshalJSON`メソッドを要求したくないため、インターフェース型は使用できません。しかし、インターフェース型のエンコードは構造体型のエンコードとは全く異なるため、型パラメータを使用すべきではありません。代わりに、このパッケージはリフレクションを使用しています。コードは単純ではありませんが、機能します。詳細はソースコードを参照してください。

一つの簡単なガイドライン

最後に、ジェネリクスをいつ使用するかについてのこの議論は、一つの簡単なガイドラインに集約できます。

全く同じコードを複数回記述していることに気づき、そのコピー間の唯一の違いが、コードが異なる型を使用していることである場合、型パラメータを使用できるかどうかを検討してください。

これを別の言い方で表現すると、全く同じコードを複数回記述しようとしていることに気づくまで、型パラメータを避けるべきだということです。

次の記事: Go Developer Survey 2021 結果
前の記事: ワークスペースに慣れよう
ブログインデックス