Goのジェネリクス徹底理解
1. ジェネリクスなしでの開発
ジェネリクスが導入される前に、異なるデータ型をサポートするジェネリック関数を実装するためにいくつかのアプローチがありました。
アプローチ1: 各データ型に対して関数を実装する
このアプローチは、極めて冗長なコードと高い保守コストをもたらします。いかなる変更も、すべての関数に対して同じ操作を行う必要があります。さらに、Go言語は同じ名前の関数のオーバーロードをサポートしていないため、外部モジュールからの呼び出しにこれらの関数を公開するのも不便です。
アプローチ2: 最も広い範囲のデータ型を使用する
コードの冗長性を避けるために、別の方法は最も広い範囲のデータ型を使用することで、つまりアプローチ2です。典型的な例はmath.Max
で、これは2つの数のうち大きい方を返します。様々なデータ型のデータを比較できるように、math.Max
はGoの数値型の中で最も広い範囲を持つfloat64
を入力および出力パラメータとして使用し、精度の損失を避けています。これはある程度コードの冗長性の問題を解決しますが、どんな種類のデータもまずfloat64
型に変換する必要があります。たとえば、int
同士を比較する場合でも、型変換が依然として必要であり、これはパフォーマンスを低下させるだけでなく、不自然に感じられます。
アプローチ3: interface{}
型を使用する
interface{}
型を使用することで、上記の問題は効果的に解決されます。ただし、interface{}
型は、実行時に型アサーションや型判定が必要となるため、一定の実行時オーバーヘッドをもたらし、パフォーマンスの低下につながる可能性があります。さらに、interface{}
型を使用する場合、コンパイラは静的型チェックを行えないため、いくつかの型エラーは実行時にのみ発見されることがあります。
2. ジェネリクスの利点
Go 1.18でジェネリクスのサポートが導入されました。これはGo言語がオープンソース化されて以来の大きな変更です。
ジェネリクスはプログラミング言語の機能の一つです。プログラマーがプログラミング中に実際の型の代わりにジェネリック型を使用できるようにします。そして、実際の呼び出し時に明示的な渡しや自動的な推論を通じて、ジェネリック型が置換され、コードの再利用の目的が達成されます。ジェネリクスを使用するプロセスでは、操作対象のデータ型がパラメータとして指定されます。このようなパラメート型は、それぞれクラス、インターフェイス、およびメソッド内でジェネリッククラス、ジェネリックインターフェイス、ジェネリックメソッドと呼ばれます。
ジェネリクスの主な利点は、コードの再利用性と型安全性の向上です。従来の形式パラメータと比べて、ジェネリクスは汎用的なコードの記述をより簡潔かつ柔軟にし、異なる種類のデータを処理する能力を提供し、Go言語の表現力と再利用性をさらに高めます。同時に、ジェネリクスの具体的な型はコンパイル時に決定されるため、型チェックを提供し、型変換エラーを避けることができます。
interface{}
の違い
3. ジェネリクスとGo言語では、interface{}
とジェネリクスの両方が複数のデータ型を扱うためのツールです。それらの違いを議論するために、まずinterface{}
とジェネリクスの実装原理を見てみましょう。
interface{}
の実装原理
3.1 interface{}
は、インターフェイス型においてメソッドを持たない空のインターフェイスです。すべての型がinterface{}
を実装しているため、任意の型を受け入れることができる関数、メソッド、またはデータ構造を作成するために使用できます。実行時のinterface{}
の基礎構造はeface
として表され、その構造は以下の通りで、主に_type
とdata
の2つのフィールドを含んでいます。
type eface struct {
_type *_type
data unsafe.Pointer
}
type type struct {
Size uintptr
PtrBytes uintptr // number of (prefix) bytes in the type that can contain pointers
Hash uint32 // hash of type; avoids computation in hash tables
TFlag TFlag // extra type information flags
Align_ uint8 // alignment of variable with this type
FieldAlign_ uint8 // alignment of struct field with this type
Kind_ uint8 // enumeration for C
// function for comparing objects of this type
// (ptr to object A, ptr to object B) -> ==?
Equal func(unsafe.Pointer, unsafe.Pointer) bool
// GCData stores the GC type data for the garbage collector.
// If the KindGCProg bit is set in kind, GCData is a GC program.
// Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
GCData *byte
Str NameOff // string form
PtrToThis TypeOff // type for pointer to this type, may be zero
}
_type
は_type
構造体へのポインタで、実際の値のサイズ、種類、ハッシュ関数、文字列表現などの情報を含んでいます。data
は実際のデータへのポインタです。実際のデータのサイズがポインタのサイズ以下の場合、データは直接data
フィールドに格納されます。そうでない場合は、data
フィールドは実際のデータへのポインタを格納します。
特定の型のオブジェクトがinterface{}
型の変数に代入されると、Go言語は暗黙的にeface
のボックス化操作を行い、_type
フィールドを値の型に、data
フィールドを値のデータに設定します。たとえば、var i interface{} = 123
という文が実行されると、Goはeface
構造体を作成し、その_type
フィールドはint
型を表し、data
フィールドは値123を表します。
interface{}
から格納された値を取り出すとき、アンボックス化のプロセスが起こります。つまり、型アサーションまたは型判定です。このプロセスでは、期待する型を明示的に指定する必要があります。interface{}
に格納されている値の型が期待する型と一致する場合、型アサーションは成功し、値を取り出すことができます。そうでない場合は、型アサーションは失敗し、この状況に対して追加の処理が必要です。
var i interface{} = "hello"
s, ok := i.(string)
if ok {
fmt.Println(s) // 出力 "hello"
} else {
fmt.Println("not a string")
}
これから分かるように、interface{}
は実行時のボックス化とアンボックス化操作を通じて、複数のデータ型に対する操作をサポートしています。
3.2 ジェネリクスの実装原理
GoコアチームはGoジェネリクスの実装方式を検討する際、非常に慎重でした。合計3つの実装方式が提出されました:
- ステンシリング方式
- 辞書方式
- GC Shapeステンシリング方式
ステンシリング方式は、C++やRustなどの言語がジェネリクスを実装するために採用している実装方式でもあります。その実装原理は、コンパイル期間中、ジェネリック関数が呼び出されるときの特定の型パラメータまたは制約内の型要素に応じて、各型引数に対してジェネリック関数の個別の実装を生成し、型安全性と最適なパフォーマンスを保証することです。ただし、この方法はコンパイラを遅くすることがあります。呼び出されるデータ型が多い場合、ジェネリック関数は各データ型に対して独立した関数を生成する必要があり、これによりコンパイルされたファイルが非常に大きくなる可能性があります。同時に、CPUキャッシュミスや命令分岐予測などの問題により、生成されたコードが効率的に実行されないこともあります。
辞書方式は、ジェネリック関数に対して1つの関数論理を生成するだけで、関数に最初のパラメートとしてdict
パラメートを追加します。dict
パラメートは、ジェネリック関数が呼び出されるときの型引数の型に関する情報を格納し、関数呼び出し時にAXレジスタ(AMD)を使用して辞書情報を渡します。この方式の利点は、コンパイル段階のオーバーヘッドを減らし、バイナリファイルのサイズを増やさないことです。ただし、実行時オーバーヘッドを増やし、コンパイル段階での関数最適化ができず、辞書再帰などの問題があります。
type Op interface{
int|float
}
func Add[T Op](m, n T) T {
return m + n
}
// 生成後 =>
const dict = map[type] typeInfo{
int : intInfo{
newFunc,
lessFucn,
//......
},
float : floatInfo
}
func Add(dict[T], m, n T) T{}
Goは最後に上記2つの方式を統合し、ジェネリクス実装のためのGC Shapeステンシリング方式を提案しました。これは、型のGC Shape単位で関数コードを生成します。同じGC Shapeを持つ型は同じコードを再利用します(型のGC Shapeとは、Goのメモリ割り当て器/ガベージコレクタ内でのその表現を指します)。すべてのポインタ型は*uint8
型を再利用します。同じGC Shapeを持つ型に対しては、共有されたインスタンス化された関数コードが使用されます。この方式も、各インスタンス化された関数コードに自動的にdict
パラメートを追加し、同じGC Shapeを持つ異なる型を区別します。
type V interface{
int|float|*int|*float
}
func F[T V](m, n T) {}
// 1. 通常の型int/floatのためのテンプレートを生成
func F[go.shape.int_0](m, n int){}
func F[go.shape.float_0](m, n int){}
// 2. ポインタ型は同じテンプレートを再利用
func F[go.shape.*uint8_0](m, n int){}
// 3. 呼び出し時に辞書の渡しを追加
const dict = map[type] typeInfo{
int : intInfo{},
float : floatInfo{}
}
func F[go.shape.int_0](dict[int],m, n int){}
3.3 違い
interface{}
とジェネリクスの基礎となる実装原理から、それらの主な違いは、interface{}
が実行時に異なるデータ型を扱うのに対して、ジェネリクスはコンパイル段階で静的に異なるデータ型を扱うということです。実際の使用では主に以下の違いがあります:
(1) パフォーマンスの違い:異なる型のデータがinterface{}
に代入または取り出されるときに行われるボックス化とアンボックス化操作はコストがかかり、追加のオーバーヘッドをもたらします。対照的に、ジェネリクスはボックス化とアンボックス化操作を必要とせず、ジェネリクスによって生成されるコードは特定の型に最適化されており、実行時のパフォーマンスオーバーヘッドを避けています。
(2) 型安全性:interface{}
型を使用するとき、コンパイラは静的型チェックを行えず、実行時にのみ型アサーションを行うことができます。したがって、いくつかの型エラーは実行時にのみ発見されることがあります。対照的に、Goのジェネリックコードはコンパイル時に生成されるため、コンパイル時に型情報を取得することができ、型安全性が保証されます。
4. ジェネリクスの使用シナリオ
4.1 適用シナリオ
- 汎用的なデータ構造を実装するとき:ジェネリクスを使用することで、コードを一度書くだけで、異なるデータ型で再利用することができます。これによりコードの重複を減らし、コードの保守性と拡張性を向上させます。
- Goのネイティブなコンテナ型を操作するとき:もし関数がGoの組み込みコンテナ型(スライス、マップ、チャネルなど)のパラメータを使用し、その関数コードがコンテナ内の要素型についていかなる特定の仮定も行わない場合、ジェネリクスを使用することで、コンテナアルゴリズムをコンテナ内の要素型から完全に切り離すことができます。ジェネリクス構文が利用可能になる前は、通常はリフレクションを使用して実装していましたが、リフレクションはコードの可読性を低下させ、静的型チェックを行えず、プログラムの実行時オーバーヘッドを大幅に増やします。
- 異なるデータ型に対して実装されるメソッドのロジックが同じ場合:異なるデータ型のメソッドが同じ機能ロジックを持ち、入力パラメートのデータ型だけが違う場合、ジェネリクスを使用してコードの冗長性を減らすことができます。
4.2 非適用シナリオ
- 型パラメータでインターフェイス型を置き換えない:インターフェイスはある種のジェネリックプログラミングをサポートしています。もし特定の型の変数に対する操作がその型のメソッドの呼び出しだけである場合、ジェネリクスを使用することなく、直接インターフェイス型を使用します。たとえば、
io.Reader
はインターフェイスを使用して、ファイルや乱数生成器から様々な型のデータを読み取ります。io.Reader
はコードの見やすさが高く、非常に効率的で、関数実行効率にほとんど違いがありません。したがって、型パラメートを使用する必要はありません。 - 異なるデータ型のメソッドの実装詳細が異なる場合:もし各型に対するメソッドの実装が異なる場合、インターフェイス型を使用するべきで、ジェネリクスは使用しません。
- 強い実行時ダイナミズムのあるシナリオ:たとえば、
switch
を使用して型判定を行うシナリオでは、直接interface{}
を使用する方がより良い結果が得られます。
5. ジェネリクスの落とし穴
nil
比較
5.1 Go言語では、型パラメータは直接nil
と比較することが許されていません。なぜなら型パラメートはコンパイル時に型チェックされるのに対して、nil
は実行時の特殊な値です。コンパイル時に型パラメートの基底型が不明であるため、コンパイル器は型パラメートの基底型がnil
との比較をサポートするかどうかを判断できません。したがって、型安全性を維持し、潜在的な実行時エラーを避けるために、Go言語は型パラメートとnil
の直接比較を許可していません。
// 間違った例
func ZeroValue0[T any](v T) bool {
return v == nil
}
// 正しい例1
func Zero1[T any]() T {
return *new(T)
}
// 正しい例2
func Zero2[T any]() T {
var t T
return t
}
// 正しい例3
func Zero3[T any]() (t T) {
return
}
5.2 無効な基底要素
基底要素の型T
は基本型でなければならず、インターフェイス型であってはいけません。
// 間違った定義!
type MyInt int
type I0 interface {
~MyInt // 間違い!MyIntは基本型ではなく、intは基本型
~error // 間違い!errorはインターフェイス
}
5.3 無効なユニオン型要素
ユニオン型要素は型パラメートであってはいけず、非インターフェイス要素はペアワイズに互いに素でなければならず。要素が複数ある場合、それは非空のメソッドを持つインターフェイス型を含むことができず、comparable
でもあってはいけず、comparable
を埋め込んでもいけません。
func I1[K any, V interface{ K }]() { // 間違い、interface{ K }のKは型パラメート
}
type MyInt int
func I5[K any, V interface{ int | MyInt }]() { // 正しい
}
func I6[K any, V interface{ int | ~MyInt }]() { // 間違い!intと~MyIntの共通部分はint
}
type MyInt2 = int
func I7[K any, V interface{ int | MyInt2 }]() { // 間違い!intとMyInt2は同じ型で、共通部分がある
}
// 間違い!要素が複数あり、comparableであることは許されない
func I13[K comparable | int]() {
}
// 間違い!要素が複数あり、要素はcomparableを埋め込めない
func I14[K interface{ comparable } | int]() {
}
5.4 インターフェイス型の再帰的な埋め込みは不可
// 間違い!自身を埋め込めない
type Node interface {
Node
}
// 間違い!TreeはTreeNodeを通じて自身を埋め込めない
type Tree interface {
TreeNode
}
type TreeNode interface {
Tree
}
6. ベストプラクティス
ジェネリクスを上手く活用するために、使用中に以下の点に注意する必要があります:
- 過度の一般化を避ける。
ジェネリクスはすべてのシナリオに適しているわけではありません。どのシナリオで適切かを慎重に考える必要があります。適切なときはリフレクションを使用できます:Goには実行時のリフレクションがあります。リフレクションメカニズムはある種のジェネリックプログラミングをサポートしています。もし特定の操作が以下のシナリオをサポートする必要がある場合、リフレクションを考えることができます:
(1) メソッドのない型を操作する場合、インターフェイス型は適用できません。
(2) 各型に対する操作ロジックが異なる場合、ジェネリクスは適用できません。encoding/json
パッケージの実装がその例です。エンコード対象の各型がMarshalJson
メソッドを実装することを望まないため、インターフェイス型を使用できません。また、異なる型に対するエンコードロジックが異なるため、ジェネリクスも使用してはいけません。 -
*T
、[]T
およびmap[T1]T2
を明示的に使用し、T
がポインタ型、スライス、またはマップを表すことを避ける。
C++の型パラメータがプレースホルダーで、実際の型で置き換えられるのとは異なり、Goの型パラメータT
の型はその型パラメート自身です。したがって、それをポインタ、スライス、マップなどのデータ型として表現すると、使用中に多くの予期しない状況が発生します。例えば:
func Set[T *int|*uint](ptr T) {
*ptr = 1
}
func main() {
i := 0
Set(&i)
fmt.Println(i) // エラー報告: 無効な操作
}
上記のコードはエラーを報告します:invalid operation: pointers of ptr (variable of type T constrained by *int | *uint) must have identical base types
。このエラーの原因は、T
が型パラメートで、型パラメートはポインタではなく、逆参照操作をサポートしていないことです。これは以下のように定義を変更することで解決できます:
func Set[T int|uint](ptr *T) {
*ptr = 1
}
まとめ
全体的に、ジェネリクスの利点は3つの面でまとめることができます:
- 型はコンパイル時に決定され、型安全性が保証されます。入力したものと同じものが出力されます。
- 可読性が向上します。コード記述段階から実際のデータ型が明示的に分かります。
- ジェネリクスは同じ型の処理コードを統合し、コードの再利用率を高め、プログラムの汎用的な柔軟性を増やします。
ただし、ジェネリクスは一般的なデータ型にとって必須のものではありません。実際の使用状況に応じて、ジェネリクスを使用するかどうかを慎重に考える必要があります。
Leapcell: The Advanced Platform for Go Web Hosting, Async Tasks, and Redis
最後に、Goサービスをデプロイするのに最適なプラットフォームであるLeapcellを紹介します。
1. 多言語サポート
- JavaScript、Python、Go、またはRustで開発できます。
2. 無制限のプロジェクトを無料でデプロイ
- 使用量に応じて支払います — リクエストがなければ、料金はかかりません。
3. 比類なきコスト効率
- 使った分だけ支払い、アイドル時の料金はありません。
- 例えば、25ドルで平均応答時間60msの694万リクエストをサポートします。
4. スムーズな開発者体験
- 直感的なUIで簡単にセットアップできます。
- 完全自動化されたCI/CDパイプラインとGitOps統合。
- アクション可能な洞察を得るためのリアルタイムメトリクスとロギング。
5. 簡単なスケーラビリティと高性能
- 自動スケーリングで高い並列性を簡単に処理できます。
- オペレーションオーバーヘッドはゼロ — ビルドに集中できます。
詳細はDocsで探索してください。
Leapcell Twitter: https://x.com/LeapcellHQ
Discussion