The Go Blog
ジェネリクスの紹介
はじめに
このブログ記事は、GopherCon 2021での私たちの発表に基づいています。
Go 1.18リリースでは、ジェネリクスがサポートされました。ジェネリクスは、Goが初めてオープンソースとしてリリースされて以来、私たちがGoに加えた最大の変更です。この記事では、新しい言語機能を紹介します。すべての詳細を網羅しようとはしませんが、重要なポイントはすべて触れます。多くの例を含む、より詳細で長い説明については、提案書を参照してください。言語変更のより正確な説明については、更新された言語仕様を参照してください。(実際の1.18実装では、提案書が許可する一部に制限が課されます。仕様は正確であるべきです。将来のリリースで一部の制限が解除される可能性があります)。
ジェネリクスとは、使用される特定の型に依存しないコードを記述する方法です。関数と型は、一連の任意の型を使用するように記述できるようになりました。
ジェネリクスは言語に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]は、型引数stringでTreeをインスタンス化する例です。
型の集合
型パラメータをインスタンス化するために使用できる型引数について、もう少し深く見ていきましょう。
通常の関数は、各値パラメータに型を持ちます。その型は値の集合を定義します。たとえば、上記の非ジェネリック関数Minのようにfloat64型を持つ場合、許容される引数値の集合は、float64型で表現できる浮動小数点値の集合です。
同様に、型パラメータリストは、各型パラメータに型を持ちます。型パラメータ自体が型であるため、型パラメータの型は型の集合を定義します。このメタ型は型制約と呼ばれます。
ジェネリックなGMinでは、型制約はconstraintsパッケージからインポートされます。Ordered制約は、順序付けできる値を持つすべての型の集合、言い換えれば、<演算子 (または<=、>など) で比較できる型を記述します。この制約により、順序付け可能な値を持つ型のみがGMinに渡されることが保証されます。また、GMin関数本体では、その型パラメータの値が<演算子と比較で使用できることも意味します。
Goでは、型制約はインターフェースである必要があります。つまり、インターフェース型は値型として使用できるだけでなく、メタ型としても使用できます。インターフェースはメソッドを定義するため、特定のメソッドが存在することを要求する型制約を表現できることは明らかです。しかし、constraints.Orderedもインターフェース型であり、<演算子はメソッドではありません。
これを機能させるために、インターフェースを新しい方法で見てみましょう。
最近まで、Goの仕様では、インターフェースはメソッドセットを定義するとされていました。これは、インターフェースで列挙されているメソッドの集合とほぼ同じです。それらのメソッドをすべて実装する型は、そのインターフェースを実装します。
しかし、これを別の方法で見てみると、インターフェースは型の集合、つまりそれらのメソッドを実装する型を定義していると言うことができます。この観点から見ると、インターフェースの型の集合の要素である型は、そのインターフェースを実装します。
この2つの見方は同じ結果につながります。各メソッドの集合に対して、それらのメソッドを実装する対応する型の集合を想像でき、それがインターフェースによって定義される型の集合です。
しかし、私たちの目的では、型の集合の視点の方がメソッドの集合の視点よりも利点があります。集合に型を明示的に追加することができ、それによって新しい方法で型の集合を制御できます。
これを機能させるために、インターフェース型の構文を拡張しました。たとえば、interface{ int|string|bool }は、型int、string、およびboolを含む型の集合を定義します。
これを別の言い方をすると、このインターフェースはint、string、またはboolのみで満たされるということです。
では、constraints.Orderedの実際の定義を見てみましょう。
type Ordered interface {
Integer|Float|~string
}
この宣言は、Orderedインターフェースがすべての整数型、浮動小数点型、および文字列型の集合であることを示しています。縦棒は型のユニオン(この場合は型の集合のユニオン)を表します。IntegerとFloatは、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は、通常の非型引数xとyの型を指定するために使用されます。前述のとおり、これは明示的な型引数で呼び出すことができます。
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
これは、引数aとbの型をパラメータxとyの型と照合することで機能します。
このような推論、つまり関数の引数の型から型引数を推論することは、関数引数型推論と呼ばれます。
関数引数型推論は、関数パラメータで使用される型パラメータにのみ機能し、関数結果のみ、または関数本体のみで使用される型パラメータには機能しません。たとえば、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の値を返すことです。基になる型が[]int32であるPoint型の値でScaleを呼び出すと、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関数には、SとEの2つの型パラメータがあります。型引数を渡さないScaleの呼び出しでは、前述の関数引数型推論により、コンパイラはSの型引数がPointであると推論できます。しかし、関数には、乗数cの型である型パラメータEも含まれています。対応する関数引数は2であり、2が型なし定数であるため、関数引数型推論ではEの正しい型を推論できません(せいぜい、2のデフォルトの型であるintを推論するかもしれませんが、これは正しくありません)。代わりに、コンパイラがEの型引数がスライスの要素型であると推論するプロセスは、制約型推論と呼ばれます。
制約型推論は、型パラメータの制約から型引数を推論します。これは、ある型パラメータが別の型パラメータの観点から定義された制約を持つ場合に使用されます。これらの型パラメータのいずれかの型引数が分かっている場合、その制約は他方の型引数を推論するために使用されます。
これが適用される一般的なケースは、ある制約が何らかの型に対して形式~_type_を使用する場合で、その型が他の型パラメータを使用して記述されている場合です。これはScaleの例で見られます。Sは~[]Eであり、これは~の後に別の型パラメータの観点から記述された型[]Eが続きます。Sの型引数が分かっている場合、Eの型引数を推論できます。Sはスライス型であり、Eはそのスライスの要素型です。
これは制約型推論のほんの導入でした。詳細については、提案書または言語仕様を参照してください。
実践における型推論
型推論の動作の正確な詳細は複雑ですが、使用することは複雑ではありません。型推論は成功するか失敗するかのいずれかです。成功すれば、型引数を省略でき、ジェネリック関数の呼び出しは通常の関数の呼び出しと何ら変わりません。型推論が失敗した場合、コンパイラはエラーメッセージを出力し、その場合は必要な型引数を提供することができます。
型推論を言語に追加するにあたり、推論の能力と複雑さのバランスを取ろうとしました。コンパイラが型を推論する際に、それらの型が決して驚くようなものではないことを保証したいと考えています。私たちは、間違った型を推論するのではなく、型を推論できないという側に誤りを犯すように注意を払ってきました。私たちはまだ完全に正しいとは言えないかもしれませんが、将来のリリースで引き続き改良を続ける可能性があります。その結果、より多くのプログラムが明示的な型引数なしで記述できるようになります。今日型引数を必要としないプログラムは、明日も必要としないでしょう。
まとめ
ジェネリクスは、Go 1.18における大きな新しい言語機能です。これらの新しい言語変更には、本番環境で十分なテストを受けていない大量の新しいコードが必要でした。それは、より多くの人々がジェネリックコードを記述し、使用することで初めて実現されます。私たちは、この機能が十分に実装されており、高品質であると信じています。しかし、Goのほとんどの側面とは異なり、この信念を実世界での経験で裏付けることはできません。したがって、ジェネリクスが意味のある場所での使用を奨励しますが、本番環境でジェネリックコードを展開する際には、適切な注意を払ってください。
この注意点はさておき、ジェネリクスが利用可能になったことを嬉しく思います。そして、Goプログラマーの生産性を向上させることを願っています。
次の記事: Goがサプライチェーン攻撃を軽減する方法
前の記事: Go 1.18がリリースされました!
ブログインデックス