Go ブログ

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

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

はじめに

これは、Google Open Source Live での私の講演のブログ記事版です

そして GopherCon 2021

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

明確にするために、厳格なルールではなく、一般的なガイドラインを提供します。ご自身の判断でご利用ください。しかし、もし確信が持てない場合は、ここで説明するガイドラインを使用することをお勧めします。

コードを書く

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

型パラメータはいつ役に立つのか?

そうは言っても、型パラメータが役に立つケースを見てみましょう。

言語定義のコンテナ型を使用する場合

1つのケースは、言語で定義されている特別なコンテナ型、つまりスライス、マップ、チャネルを操作する関数を記述する場合です。関数がこれらの型のパラメータを持ち、関数コードが要素型について特定の仮定を行わない場合、型パラメータを使用すると便利です。

たとえば、任意の型のマップのすべてのキーのスライスを返す関数です。

// 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
}

このコードはマップキー型について何も想定しておらず、マップ値型はまったく使用していません。どんなマップ型でも動作します。そのため、型パラメータを使用するのに適した候補となります。

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

汎用データ構造

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

今日、そのようなデータ構造を必要とするプログラムは、通常、次の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` のようなメソッド式を比較関数として使用できます。

言い換えれば、メソッドを関数に変換する方が、型にメソッドを追加するよりもはるかに簡単です。そのため、汎用データ型の場合は、メソッドを必要とする制約を記述するのではなく、関数を優先します.

共通メソッドの実装

型パラメータが役立つもう1つのケースは、異なる型がいくつかの共通メソッドを実装する必要があり、異なる型の実装がすべて同じに見える場合です.

たとえば、標準ライブラリの `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.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 で使用される実装は、多くの場合、型が型パラメータである値を、型がインターフェース型である値と同様に扱います。これは、型パラメータを使用しても、一般的にインターフェース型を使用するよりも高速にならないことを意味します。そのため、速度のためだけにインターフェース型から型パラメータに変更しないでください。おそらく実行速度は速くなりません。

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

型パラメータとインターフェース型のどちらを使用するかを決定する際には、メソッドの実装を考慮してください。 wcześniej powiedzieliśmy、że jeśli implementacja metody jest taka sama dla wszystkich typów、należy użyć parametru typu。逆に、実装が型ごとに異なる場合は、インターフェース型を使用して異なるメソッド実装を記述し、型パラメータを使用しないでください.

たとえば、ファイルからの `Read` の実装は、乱数ジェネレーターからの `Read` の実装とはまったく異なります。これは、2つの異なる `Read` メソッドを記述し、`io.Reader` のようなインターフェース型を使用する必要があることを意味します。

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

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

メソッドを持たない型(インターフェース型では対応できない)もサポートする必要がある操作で、かつ型ごとに操作が異なる場合(型パラメータが適切でない場合)は、リフレクションを使用します。

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

シンプルなガイドライン

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

複数の場所に全く同じコードを記述していて、コピー間の唯一の違いが使用されている型だけである場合は、型パラメータを使用できるかどうかを検討してください。

言い換えれば、全く同じコードを複数回記述しようとしていることに気付くまで、型パラメータの使用は避けるべきです。

次の記事:Go 開発者アンケート 2021 結果
前の記事:ワークスペースに慣れる
ブログインデックス