Goブログ

ジェネリクス入門

Robert Griesemer and Ian Lance Taylor
2022年3月22日

はじめに

このブログ記事は、GopherCon 2021での私たちの講演に基づいています

Go 1.18リリースでは、ジェネリクスのサポートが追加されました。ジェネリクスは、最初のオープンソースリリース以来、Goに加えた最大の変更です。この記事では、新しい言語機能を紹介します。すべての詳細を網羅するつもりはありませんが、重要なポイントはすべて押さえます。多くの例を含む、より詳細で長い説明については、提案ドキュメントを参照してください。言語の変更の詳細な説明については、更新された言語仕様を参照してください。(実際の1.18の実装では、提案ドキュメントで許可されている内容にいくつかの制限が課せられていることに注意してください。仕様は正確である必要があります。今後のリリースでは、一部の制限が解除される可能性があります。)

ジェネリクスは、使用されている特定の型に依存しないコードを記述する方法です。関数と型は、型のセットを使用するように記述できるようになりました。

ジェネリクスは、言語に3つの大きな新しいものを追加します

  1. 関数と型の型パラメータ。
  2. メソッドを持たない型を含む、型のセットとしてのインターフェース型の定義。
  3. 関数を呼び出す際に多くの場合に型引数を省略できる型推論。

型パラメータ

関数と型は、型パラメータを持つことが許可されるようになりました。型パラメータリストは、括弧の代わりに角括弧を使用することを除いて、通常のパラメータリストのように見えます。

これがどのように機能するかを示すために、浮動小数点値の基本的な非ジェネリックMin関数から始めましょう

func Min(x, y float64) float64 {
    if x < y {
        return x
    }
    return y
}

型パラメータリストを追加することで、この関数をジェネリックにすることができます。つまり、異なる型で機能するようにすることができます。この例では、単一の型パラメータTを持つ型パラメータリストを追加し、float64の使用箇所をTに置き換えます。

import "golang.org/x/exp/constraints"

func GMin[T constraints.Ordered](x, y T) T {
    if x < y {
        return x
    }
    return y
}

次のような呼び出しを記述することで、型引数を指定してこの関数を呼び出すことができるようになりました

x := GMin[int](2, 3)

この場合、GMinに型引数intを提供することをインスタンス化と呼びます。インスタンス化は2つのステップで発生します。まず、コンパイラは、ジェネリック関数または型全体で、それぞれの型パラメータに対してすべての型引数を代入します。次に、コンパイラは、各型引数がそれぞれの制約を満たしていることを検証します。その意味については後ほど説明しますが、2番目のステップが失敗した場合、インスタンス化は失敗し、プログラムは無効になります。

インスタンス化が成功すると、他の関数とまったく同じように呼び出すことができる非ジェネリック関数が得られます。たとえば、次のようなコードでは

fmin := GMin[float64]
m := fmin(2.71, 3.14)

インスタンス化GMin[float64]は、事実上元の浮動小数点Min関数を生成し、それを関数呼び出しで使用できます。

型パラメータは型でも使用できます。

type Tree[T interface{}] struct {
    left, right *Tree[T]
    value       T
}

func (t *Tree[T]) Lookup(x T) *Tree[T] { ... }

var stringTree Tree[string]

ここでは、ジェネリック型Treeは型パラメータTの値を格納します。ジェネリック型には、この例のLookupのようなメソッドを含めることができます。ジェネリック型を使用するには、インスタンス化する必要があります。Tree[string]は、型引数stringTreeをインスタンス化する例です。

型セット

型パラメータをインスタンス化するために使用できる型引数について少し詳しく見てみましょう。

通常の関数には、各値パラメータの型があります。その型は値のセットを定義します。たとえば、上記の非ジェネリック関数Minのようにfloat64型がある場合、許可される引数値のセットは、float64型で表現できる浮動小数点値のセットです。

同様に、型パラメータリストには、各型パラメータの型があります。型パラメータ自体が型であるため、型パラメータの型は型のセットを定義します。このメタ型は、型制約と呼ばれます。

ジェネリックGMinでは、型制約はconstraintsパッケージからインポートされます。Ordered制約は、順序付けできる値を持つすべての型のセットを記述します。つまり、<演算子(または<=、>など)で比較できる型のセットを記述します。制約は、順序付け可能な値を持つ型のみがGMinに渡せるようにします。また、GMin関数本体で、その型パラメータの値が<演算子との比較で使用できることを意味します。

Goでは、型制約はインターフェースである必要があります。つまり、インターフェース型は値型として使用でき、メタ型としても使用できます。インターフェースはメソッドを定義するため、特定のメソッドの存在を要求する型制約を表現できることは明らかです。しかし、constraints.Orderedもインターフェース型であり、<演算子はメソッドではありません。

これを機能させるために、インターフェースを新しい方法で見ていきます。

最近まで、Go仕様では、インターフェースはメソッドセットを定義するとされていました。これは、大まかに言って、インターフェースに列挙されたメソッドのセットです。これらのメソッドをすべて実装する型は、そのインターフェースを実装します。

しかし、別の見方をすると、インターフェースは型のセット、つまりこれらのメソッドを実装する型を定義すると言うことができます。この観点からすると、インターフェースの型セットの要素である任意の型は、インターフェースを実装します。

2つの見方は同じ結果につながります。メソッドの各セットに対して、それらのメソッドを実装する対応する型のセットを想像することができ、それがインターフェースによって定義された型のセットになります。

ただし、私たちの目的では、型セットビューにはメソッドセットビューよりも利点があります。型を明示的にセットに追加し、新しい方法で型セットを制御できます。

これを機能させるために、インターフェース型の構文を拡張しました。たとえば、interface{ int|string|bool }は、型intstring、およびboolを含む型セットを定義します。

これを別の言い方をすると、このインターフェースはintstring、またはboolのみによって満たされるということです。

次に、constraints.Orderedの実際の定義を見てみましょう

type Ordered interface {
    Integer|Float|~string
}

この宣言は、Orderedインターフェースが、すべての整数型、浮動小数点型、および文字列型のセットであることを示しています。縦線は、型の結合(または、この場合は型のセット)を表します。IntegerFloatは、constraintsパッケージで同様に定義されているインターフェース型です。Orderedインターフェースによって定義されたメソッドがないことに注意してください。

型制約の場合、通常はstringのような特定の型を気にしません。私たちはすべての文字列型に関心があります。それが、トークンが使用される理由です。式〜stringは、基本型がstringであるすべての型のセットを意味します。これには、型string自体と、type MyString stringなどの定義で宣言されたすべての型が含まれます。

もちろん、インターフェースにメソッドを指定したいですし、後方互換性も維持したいと考えています。Go 1.18では、インターフェースには以前と同様にメソッドと埋め込みインターフェースを含めることができますが、非インターフェース型、結合、および基本型のセットも埋め込むことができます。

型制約として使用する場合、インターフェースによって定義された型セットは、それぞれの型パラメータの型引数として許可される型を正確に指定します。ジェネリック関数本体内では、オペランドの型が制約Cを持つ型パラメータPである場合、Cの型セット内のすべての型で許可されていれば、操作は許可されます(現在、ここにいくつかの実装上の制限がありますが、通常のコードではそれらに遭遇する可能性は低いでしょう)。

制約として使用されるインターフェースには、名前(Orderedなど)を付けることも、型パラメータリストにインライン化されたリテラルインターフェースにすることもできます。たとえば

[S interface{~[]E}, E interface{}]

ここで、Sは、要素型が任意の型である可能性のあるスライス型である必要があります。

これは一般的なケースであるため、制約位置のインターフェースでは外側のinterface{}を省略でき、単に次のように記述できます

[S ~[]E, E interface{}]

空のインターフェースは型パラメータリストで一般的であり、それについては通常のGoコードでも一般的であるため、Go 1.18では、空のインターフェース型のエイリアスとして新しい定義済みの識別子anyが導入されています。これにより、このイディオムコードに到達します

[S ~[]E, E any]

型セットとしてのインターフェースは、強力な新しいメカニズムであり、Goで型制約を機能させるための鍵です。今のところ、新しい構文形式を使用するインターフェースは、制約としてのみ使用できます。ただし、明示的に型制約されたインターフェースが一般的にどのように役立つかを想像するのは難しくありません。

型推論

最後の主要な言語の新機能は型推論です。ある意味では、これは言語への最も複雑な変更ですが、人々がジェネリック関数を呼び出すコードを書く際に自然なスタイルを使用できるようにするため、重要です。

関数の引数型推論

型パラメータを扱うには、型引数を渡す必要があり、コードが冗長になる可能性があります。汎用的なGMin関数に戻りましょう。

func GMin[T constraints.Ordered](x, y T) T { ... }

型パラメータTは、通常の非型引数xyの型を指定するために使用されます。前に見たように、これは明示的な型引数を使って呼び出すことができます。

var a, b, m float64

m = GMin[float64](a, b) // explicit type argument

多くの場合、コンパイラは通常の引数からTの型引数を推論できます。これにより、コードは短くなりながら、明確さを保つことができます。

var a, b, m float64

m = GMin(a, b) // no type argument

これは、引数abの型を、パラメータxyの型と照合することによって機能します。

この種の推論、つまり関数の引数の型から型引数を推論するものは、関数の引数型推論と呼ばれます。

関数の引数型推論は、関数パラメータで使用されている型パラメータに対してのみ機能し、関数の結果や関数本体でのみ使用される型パラメータには機能しません。たとえば、MakeT[T any]() Tのような、結果にのみTを使用する関数には適用されません。

制約型推論

この言語は、別の種類の型推論である制約型推論をサポートしています。これを説明するために、整数のスライスをスケーリングするこの例から始めましょう。

// Scale returns a copy of s with each element multiplied by c.
// This implementation has a problem, as we will see.
func Scale[E constraints.Integer](s []E, c E) []E {
    r := make([]E, len(s))
    for i, v := range s {
        r[i] = v * c
    }
    return r
}

これは、任意の整数型のスライスに対して機能する汎用関数です。

次に、多次元のPoint型があると仮定します。各Pointは、単に点の座標を示す整数のリストです。当然、この型にはいくつかのメソッドがあります。

type Point []int32

func (p Point) String() string {
    // Details not important.
}

Pointをスケーリングしたい場合があります。Pointは単なる整数のスライスであるため、前に書いたScale関数を使用できます。

// ScaleAndPrint doubles a Point and prints it.
func ScaleAndPrint(p Point) {
    r := Scale(p, 2)
    fmt.Println(r.String()) // DOES NOT COMPILE
}

残念ながら、これはコンパイルされず、r.String undefined (type []int32 has no field or method String)のようなエラーで失敗します。

問題は、Scale関数が型[]Eの値を返すことです。ここで、Eは引数スライスの要素型です。ScalePoint型の値で呼び出すと、基になる型が[]int32であるため、型Pointではなく、型[]int32の値が返されます。これは、ジェネリックコードの記述方法によるものですが、私たちが望むものではありません。

これを修正するために、Scale関数を、スライス型の型パラメータを使用するように変更する必要があります。

// Scale returns a copy of s with each element multiplied by c.
func Scale[S ~[]E, E constraints.Integer](s S, c E) S {
    r := make(S, len(s))
    for i, v := range s {
        r[i] = v * c
    }
    return r
}

新しい型パラメータSを導入しました。これはスライス引数の型です。基になる型は[]EではなくSであり、結果の型はSになるように制約しました。Eは整数に制約されているため、効果は以前と同じです。最初の引数は、何らかの整数型のスライスである必要があります。関数の本体に対する唯一の変更は、makeを呼び出すときに、[]EではなくSを渡すようになったことです。

新しい関数は、プレーンなスライスで呼び出すと以前と同じように動作しますが、型Pointで呼び出すと、型Pointの値が返されます。それが私たちが望むものです。このバージョンのScaleを使用すると、以前のScaleAndPrint関数がコンパイルされ、期待どおりに実行されます。

しかし、型引数を明示的に渡さずにScaleの呼び出しを記述しても大丈夫なのはなぜでしょうか?つまり、Scale[Point, int32](p, 2)と記述するのではなく、型引数なしでScale(p, 2)と記述できるのはなぜでしょうか?新しいScale関数には、SEの2つの型パラメータがあります。型引数を渡さないScaleの呼び出しでは、上記で説明した関数の引数型推論により、コンパイラはSの型引数がPointであると推論できます。しかし、関数には、乗算係数cの型である型パラメータEもあります。対応する関数引数は2であり、2型なし定数であるため、関数の引数型推論はEの正しい型を推論できません(せいぜい、2のデフォルト型であるintを推論する可能性があります。これは正しくありません)。代わりに、コンパイラがEの型引数をスライスの要素型であると推論するプロセスは、制約型推論と呼ばれます。

制約型推論は、型パラメータの制約から型引数を推論します。これは、ある型パラメータが別の型パラメータに関して定義された制約を持つ場合に使用されます。これらの型パラメータの1つの型引数がわかっている場合、制約を使用して他の型引数を推論します。

これが適用される通常のケースは、ある制約が、ある型に対して~typeの形式を使用する場合です。その型は、他の型パラメータを使用して記述されます。これはScaleの例で確認できます。S~[]Eであり、これは~の後に別の型パラメータに関して記述された型[]Eが続きます。Sの型引数がわかっていれば、Eの型引数を推論できます。Sはスライス型であり、Eはそのスライスの要素型です。

これは、制約型推論の入門に過ぎません。詳細については、提案ドキュメントまたは言語仕様を参照してください。

実践における型推論

型推論がどのように機能するかの正確な詳細は複雑ですが、使用することはそうではありません。型推論は成功するか失敗するかのどちらかです。成功した場合、型引数は省略でき、ジェネリック関数の呼び出しは通常の関数の呼び出しと違いがありません。型推論が失敗した場合、コンパイラはエラーメッセージを表示し、そのような場合は必要な型引数を指定できます。

言語に型推論を追加する際に、推論能力と複雑さのバランスを取るように努めました。コンパイラが型を推論するとき、それらの型が決して驚くべきものではないようにしたいと考えています。誤った型を推論するのではなく、型の推論に失敗する側で慎重になるように努めてきました。おそらく完全に正しくはないでしょうし、将来のリリースで改良を続ける可能性があります。効果として、より多くのプログラムを明示的な型引数なしで記述できるようになります。今日型引数を必要としないプログラムは、明日も型引数を必要としないでしょう。

結論

ジェネリクスは、1.18における大きな新しい言語機能です。これらの新しい言語変更には、本番環境で大規模なテストが行われていない大量の新しいコードが必要でした。それは、より多くの人がジェネリックコードを記述し、使用するにつれてのみ実現します。この機能は十分に実装されており、高品質であると信じています。ただし、Goのほとんどの側面とは異なり、現実世界での経験でその信念を裏付けることはできません。したがって、ジェネリクスが理にかなう場所での使用を推奨しますが、本番環境でジェネリックコードをデプロイする場合は、適切な注意を払ってください。

注意はさておき、ジェネリクスが利用可能になったことに興奮しており、Goプログラマーの生産性が向上することを願っています。

次の記事: Goがサプライチェーン攻撃を緩和する方法
前の記事: Go 1.18 がリリースされました!
ブログインデックス