Goジェネリクス:ア・ディープダイブ
Grace Collins
Solutions Engineer · Leapcell

1. Goのジェネリクスなし
ジェネリクス導入前は、異なるデータ型をサポートするジェネリック関数を実装するためにいくつかのアプローチがありました。
アプローチ1:データ型ごとに関数を実装する このアプローチは、極めて冗長なコードと高いメンテナンスコストにつながります。変更を加えるには、すべての関数に対して同じ操作を実行する必要があります。さらに、Go言語は同じ名前の関数オーバーロードをサポートしていないため、これらの関数を外部モジュール呼び出しのために公開するのも不便です。
アプローチ2:最も広い範囲のデータ型を使用する
コードの冗長性を避けるため、別のアプローチは、最も広い範囲のデータ型、つまりアプローチ2を使用することです。典型的な例はmath.Max
で、2つの数値のうち大きい方を返します。様々なデータ型のデータを比較できるように、math.Max
はGoの数値型の中で最も広い範囲を持つデータ型であるfloat64
を入力および出力パラメータとして使用し、精度の損失を防ぎます。これにより、コードの冗長性の問題をある程度解決できますが、あらゆる種類のデータを最初にfloat64
型に変換する必要があります。たとえば、int
とint
を比較する場合、型キャストが依然として必要であり、パフォーマンスを低下させるだけでなく、不自然に見えます。
アプローチ3:interface{}
型を使用する
interface{}
型を使用すると、上記の問題を効果的に解決できます。ただし、interface{}
型は、実行時に型アサーションまたは型判定が必要になるため、特定のランタイムオーバーヘッドが発生し、パフォーマンスが低下する可能性があります。さらに、interface{}
型を使用する場合、コンパイラは静的な型チェックを実行できないため、一部の型エラーは実行時にのみ発見される可能性があります。
2. ジェネリクスの利点
Go 1.18では、Go言語がオープンソース化されて以来、大きな変更であるジェネリクスのサポートが導入されました。 ジェネリクスは、プログラミング言語の機能です。プログラマーは、プログラミングで実際の型の代わりにジェネリック型を使用できます。次に、実際の呼び出し中に明示的な受け渡しまたは自動推論を通じて、ジェネリック型が置き換えられ、コードの再利用という目的が達成されます。ジェネリクスを使用するプロセスでは、操作するデータ型がパラメータとして指定されます。このようなパラメータ型は、それぞれクラス、インターフェース、およびメソッドのジェネリッククラス、ジェネリックインターフェース、ジェネリックメソッドと呼ばれます。 ジェネリクスの主な利点は、コードの再利用性と型安全性の向上です。従来の仮パラメータと比較して、ジェネリクスは普遍的なコードの記述をより簡潔かつ柔軟にし、異なる種類のデータを処理する機能を提供し、Go言語の表現力と再利用性をさらに高めます。同時に、ジェネリクスの特定の型はコンパイル時に決定されるため、型チェックを提供でき、型変換エラーを回避できます。
3. ジェネリクスとinterface{}
の違い
Go言語では、interface{}
とジェネリクスはどちらも複数のデータ型を処理するためのツールです。それらの違いについて議論するために、まずinterface{}
とジェネリクスの実装原理を見てみましょう。
3.1 interface{}
の実装原理
interface{}
は、インターフェース型にメソッドがない空のインターフェースです。すべての型はinterface{}
を実装するため、これを使用して、任意の型を受け入れることができる関数、メソッド、またはデータ構造を作成できます。実行時のinterface{}
の基になる構造はeface
として表され、その構造は以下に示すように、主に2つのフィールド、_type
とdata
が含まれています。
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) // Output "hello" } else { fmt.Println("not a string") }
interface{}
は、実行時のボクシングおよびアンボクシング操作を通じて、複数のデータ型に対する操作をサポートすることがわかります。
3.2 ジェネリクスの実装原理
Goコアチームは、Goジェネリクスの実装スキームを評価する際に非常に慎重でした。合計3つの実装スキームが提出されました。
- ステンシルスキーム
- 辞書スキーム
- GCシェイプステンシルスキーム
ステンシルスキームは、C++やRustなどの言語でジェネリクスを実装するために採用されている実装スキームでもあります。その実装原理は、コンパイル時に、ジェネリック関数が呼び出されたときの特定の型パラメータまたは制約内の型要素に応じて、型安全性と最適なパフォーマンスを確保するために、各型引数に対してジェネリック関数の個別の実装を生成することです。ただし、この方法ではコンパイラが遅くなります。呼び出されるデータ型が多い場合、ジェネリック関数はデータ型ごとに独立した関数を生成する必要があるため、コンパイルされたファイルが非常に大きくなる可能性があります。同時に、CPUキャッシュミスや命令分岐予測などの問題により、生成されたコードが効率的に実行されない可能性があります。
辞書スキームは、ジェネリック関数に対して1つの関数ロジックのみを生成しますが、関数に最初のパラメータとしてパラメータdict
を追加します。dict
パラメータは、ジェネリック関数が呼び出されたときの型引数の型関連情報を格納し、関数呼び出し中にAXレジスタ(AMD)を使用して辞書情報を渡します。このスキームの利点は、コンパイルフェーズのオーバーヘッドを削減し、バイナリファイルのサイズを増やさないことです。ただし、ランタイムオーバーヘッドが増加し、コンパイル段階で関数を最適化できず、辞書再帰などの問題が発生します。
type Op interface{ int|float } func Add[T Op](m, n T) T { return m + n } // After generation => const dict = map[type] typeInfo{ int : intInfo{ newFunc, lessFucn, //...... }, float : floatInfo } func Add(dict[T], m, n T) T{}
Goは最終的に上記の2つのスキームを統合し、ジェネリック実装のためにGCシェイプステンシルスキームを提案しました。これは、型のGCシェイプの単位で関数コードを生成します。同じGCシェイプの型は同じコードを再利用します(型のGCシェイプは、Goメモリ割り当てツール/ガベージコレクタでの表現を指します)。すべてのポインタ型は*uint8
型を再利用します。同じGCシェイプの型の場合、共有インスタンス化関数コードが使用されます。このスキームでは、同じGCシェイプを持つ異なる型を区別するために、各インスタンス化関数コードにdict
パラメータも自動的に追加されます。
type V interface{ int|float|*int|*float } func F[T V](m, n T) {} // 1. Generate templates for regular types int/float func F[go.shape.int_0](m, n int){} func F[go.shape.float_0](m, n int){} // 2. Pointer types reuse the same template func F[go.shape.*uint8_0](m, n int){} // 3. Add dictionary passing during the call 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{}
に割り当てたり、interface{}
から取得したりするときに実行されるボクシングおよびアンボクシング操作はコストがかかり、追加のオーバーヘッドが発生します。対照的に、ジェネリクスはボクシングおよびアンボクシング操作を必要としません。また、ジェネリクスによって生成されたコードは特定の型に最適化されており、ランタイムパフォーマンスオーバーヘッドを回避できます。
(2)型安全性:interface{}
型を使用する場合、コンパイラは静的な型チェックを実行できず、実行時に型アサーションのみを実行できます。したがって、一部の型エラーは実行時にのみ発見される可能性があります。対照的に、Goのジェネリックコードはコンパイル時に生成されるため、ジェネリックコードはコンパイル時に型情報を取得でき、型安全性を確保できます。
4. ジェネリクスのシナリオ
4.1 適用可能なシナリオ
- 一般的なデータ構造を実装する場合:ジェネリクスを使用することで、1回コードを作成し、さまざまなデータ型で再利用できます。これにより、コードの重複が減り、コードの保守性と拡張性が向上します。
- Goのネイティブコンテナ型を操作する場合:関数がスライス、マップ、チャネルなどのGo組み込みコンテナ型のパラメータを使用し、関数コードがコンテナ内の要素型について特定の仮定をしていない場合、ジェネリクスを使用すると、コンテナアルゴリズムをコンテナ内の要素型から完全に分離できます。ジェネリクス構文が利用可能になる前は、通常、リフレクションが実装に使用されていましたが、リフレクションはコードの可読性を低下させ、静的な型チェックを実行できず、プログラムのランタイムオーバーヘッドを大幅に増加させます。
- さまざまなデータ型に対して実装されたメソッドのロジックが同じ場合:異なるデータ型のメソッドが同じ関数ロジックを持ち、唯一の違いが入力パラメータのデータ型である場合、ジェネリクスを使用してコードの冗長性を減らすことができます。
4.2 適用できないシナリオ
- インターフェース型を型パラメータに置き換えないでください。インターフェースは、特定の意味でのジェネリックプログラミングをサポートしています。特定の型の変数に対する操作がその型のメソッドのみを呼び出す場合、ジェネリクスを使用せずにインターフェース型を直接使用してください。たとえば、
io.Reader
はインターフェースを使用して、ファイルや乱数ジェネレータからさまざまな型のデータを読み取ります。io.Reader
はコードの観点から読みやすく、効率が高く、関数実行効率にほとんど違いがないため、型パラメータを使用する必要はありません。 - 異なるデータ型のメソッドの実装の詳細が異なる場合:型ごとのメソッドの実装が異なる場合は、ジェネリクスの代わりにインターフェース型を使用する必要があります。
- ランタイムダイナミクスが強いシナリオでは:たとえば、
switch
を使用して型判定を実行するシナリオでは、interface{}
を直接使用すると、より良い結果が得られます。
5. ジェネリクスの落とし穴
5.1 nil
比較
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
メソッドを実装させたくないため、インターフェース型を使用できません。また、型ごとのエンコードロジックが異なるため、ジェネリクスは使用しないでください。
2. T
にポインタ型、スライス、またはマップを表させるのではなく、*T
、[]T
、およびmap[T1]T2
を明確に使用してください。
C++の型パラメータはプレースホルダーであり、実際の型に置き換えられるという事実とは異なり、Goの型パラメータT
の型は型パラメータ自体です。したがって、ポインタ、スライス、マップ、およびその他のデータ型として表すと、以下に示すように、使用中に多くの予期しない状況が発生します。
func Set[T *int|*uint](ptr T) { *ptr = 1 } func main() { i := 0 Set(&i) fmt.Println(i) // Report an error: invalid operation }
上記のコードはエラーを報告します: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: Go Webホスティング、非同期タスク、Redisの高度なプラットフォーム
最後に、Goサービスのデプロイに最適なプラットフォームであるLeapcellを紹介します。
1. 多言語サポート
- JavaScript、Python、Go、またはRustで開発します。
2. 無制限のプロジェクトを無料でデプロイ
- 使用量に対してのみ料金を支払います — リクエストも料金も支払う必要はありません。
3. 無敵のコスト効率
- アイドル料金なしの従量課金制。
- 例:25ドルで平均応答時間60ミリ秒で694万リクエストをサポートします。
4. 合理化された開発者エクスペリエンス
- 簡単なセットアップのための直感的なUI。
- 完全に自動化されたCI/CDパイプラインとGitOps統合。
- 実用的な洞察を得るためのリアルタイムメトリックとロギング。
5. 簡単なスケーラビリティとハイパフォーマンス
- 高い同時実行性を簡単に処理するための自動スケーリング。
- 運用オーバーヘッドはゼロ — 構築に集中するだけです。
詳細については、ドキュメントをご覧ください!
Leapcell Twitter: https://x.com/LeapcellHQ