Goのジェネリクス
.NET (C#) の経験がある開発者向けに、Go のジェネリクス (Go 1.18 で導入) の型パラメータ、制約、comparable、C# ジェネリクスとの違いを解説します。サンプルコードは Go 1.21 以降を前提としています (cmp パッケージや slices パッケージを使用します)。
1. このドキュメントで扱うこと
- 型パラメータと制約の書き方 (
func F[T any](...)、type Stack[T any] struct{...}) cmp.Ordered/comparable/anyの使い分け~intやint | stringのような型セット (type set) による制約- C# のジェネリクスとの設計・実装上の違い
- ジェネリクスを使うべき場面・避けるべき場面
2. .NET との対応表
| .NET (C#) | Go |
|---|---|
List<T> where T : IComparable | func F[T cmp.Ordered](...) |
where 制約 | 型制約 (constraint) インターフェース |
where T : class / struct | 対応する制約なし (必要なら型セットで列挙) |
where T : new() | 対応なし (var zero T でゼロ値を得る) |
default(T) | var zero T |
T に対する演算子 (.NET 7 以降は INumber<T> など) | 型セットを持つ制約 (~int | ~float64 など) で + や < を直接使える |
Dictionary<TKey, TValue> のキー | map[K]V の K は comparable が必要 |
| 実行時に reified (型情報が実行時に保持される) | コンパイル時に GC shape ごとに単相化し、辞書で型情報を渡す (GC shape stenciling) |
共変・反変 (IEnumerable<out T>) | なし (不変) |
ジェネリックメソッド (void M<T>()) | 関数は可。ただしメソッドには型パラメータを追加できない |
LINQ (Select / Where) | slices / maps パッケージ、自前の Map / Filter |
3. C# 開発者がつまずきやすいポイント
制約はインターフェースで書く
C# では where T : IComparable<T> のように制約を列挙しますが、Go では制約そのものがインターフェース型です。メソッドだけでなく、使用できる型の集合 (型セット) も定義できます。
// メソッドによる制約 (C# の where T : IStringer に近い)
type Stringer interface {
String() string
}
// 型セットによる制約 (C# にはない書き方)
type Number interface {
~int | ~int64 | ~float64
}
~int は「基底型が int である全ての型」を意味します。type UserID int のような独自型も ~int を満たします。~ なしの int だと int そのものしか受け付けません。
any / comparable / cmp.Ordered の違い
| 制約 | 許可される操作 | 例 |
|---|---|---|
any | 代入・引数渡しなど。演算子は使えない | コンテナの要素型 |
comparable | == / !=、map のキー | Set[T]、重複検出 |
cmp.Ordered | < <= > >= (加えて ==) | ソート、最大・最小 |
any の型パラメータに対して == や < は使えません。C# では object に対して Equals が呼べますが、Go では制約で許可していない操作はコンパイルエラーになります。
メソッドに型パラメータは追加できない
型パラメータを持てるのは「関数」と「型」だけです。ジェネリック型のメソッドは、型のレシーバで宣言された型パラメータしか使えません。
type Stack[T any] struct{ items []T }
// コンパイルエラー: メソッドは独自の型パラメータを持てない
// func (s *Stack[T]) Map[U any](f func(T) U) []U { ... }
// 代わりにトップレベルの関数として書く
func MapStack[T, U any](s *Stack[T], f func(T) U) []U { /* ... */ return nil }
C# の拡張メソッドや Select<TSource, TResult> のような書き方は、Go では関数にするのが基本です。
new T() や default(T) の代わりに var zero T
func Zero[T any]() T {
var zero T // int なら 0、string なら ""、ポインタなら nil
return zero
}
new T() のような制約 (where T : new()) はありません。インスタンスの生成が必要な場合は、ファクトリ関数を引数で受け取ります。
共変性・反変性がない
[]int は []any に代入できません。C# の IEnumerable<Derived> を IEnumerable<Base> として扱うような変換もありません。必要なら要素を 1 つずつ変換します。
実行時の型情報はそのままでは使えない
C# では typeof(T) で実行時に T を取得できますが、Go では型パラメータを値として扱えません。型で分岐したい場合は any に変換して型スイッチを使います。ただし多用するとジェネリクスにする意味が薄れます。
func Describe[T any](v T) string {
switch x := any(v).(type) {
case int:
return fmt.Sprintf("int: %d", x)
case string:
return "string: " + x
default:
return fmt.Sprintf("other: %v", x)
}
}
型推論と明示的な指定
関数の引数から推論できる場合、型引数は省略できます。戻り値にしか T が現れない場合などは明示が必要です。
Max(1, 2) // T は int と推論される
Zero[int]() // 推論できないので明示する
実装方式の違いとパフォーマンス
Go は型ごとに完全に単相化するのではなく、メモリ上の表現 (GC shape) が同じ型でコードを共有し、辞書 (dictionary) 経由で型固有の情報を渡します。そのため、「ジェネリクスにすれば常にインターフェースより速い」とは限りません。性能が重要な箇所はベンチマークで確認してください。
4. コード例
最大値を返す関数 (cmp.Ordered)
C#:
static T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) > 0 ? a : b;
var m = Max(3, 5); // 5
Go:
import "cmp"
func Max[T cmp.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
m := Max(3, 5) // 5
s := Max("apple", "go") // "go"
Go 1.21 以降は組み込みの max / min 関数もあるため、単純な比較ならそちらで足ります。
Map / Filter (LINQ の Select / Where 相当)
C#:
var squares = numbers.Select(n => n * n).ToList();
var evens = numbers.Where(n => n % 2 == 0).ToList();
Go:
func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, 0, len(s))
for _, v := range s {
result = append(result, f(v))
}
return result
}
func Filter[T any](s []T, pred func(T) bool) []T {
var result []T
for _, v := range s {
if pred(v) {
result = append(result, v)
}
}
return result
}
numbers := []int{1, 2, 3, 4, 5}
squares := Map(numbers, func(n int) int { return n * n })
evens := Filter(numbers, func(n int) bool { return n%2 == 0 })
標準ライブラリの slices パッケージにも、slices.Contains、slices.Index、slices.Sort、slices.SortFunc、slices.Max などが用意されています。まず標準ライブラリにないか確認してください。
ジェネリック型 (Stack<T>)
C#:
public class Stack<T>
{
private readonly List<T> _items = new();
public void Push(T item) => _items.Add(item);
public bool TryPop(out T? item)
{
if (_items.Count == 0) { item = default; return false; }
item = _items[^1];
_items.RemoveAt(_items.Count - 1);
return true;
}
}
Go:
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
last := len(s.items) - 1
v := s.items[last]
s.items = s.items[:last]
return v, true
}
var st Stack[string]
st.Push("a")
v, ok := st.Pop() // "a", true
メソッドのレシーバでは Stack[T] のように型パラメータを再度宣言します (制約は型定義側のものが使われます)。
comparable を使った Set (C# の HashSet<T> 相当)
C#:
var set = new HashSet<string>();
set.Add("go");
bool has = set.Contains("go");
Go:
type Set[T comparable] map[T]struct{}
func (s Set[T]) Add(v T) { s[v] = struct{}{} }
func (s Set[T]) Has(v T) bool { _, ok := s[v]; return ok }
set := Set[string]{}
set.Add("go")
has := set.Has("go") // true
map のキーには comparable が必要なため、Set[T comparable] と制約を付けます。T any のままだとコンパイルエラーになります。
型セット制約 (~) を使った合計
C# (.NET 7 以降の generic math):
static T Sum<T>(IEnumerable<T> xs) where T : INumber<T>
=> xs.Aggregate(T.Zero, (acc, x) => acc + x);
Go:
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
type Price int
Sum([]int{1, 2, 3}) // 6
Sum([]float64{1.5, 2.5}) // 4
Sum([]Price{100, 200}) // 300 (~int により独自型も受け付ける)
5. まとめ
- Go のジェネリクスは、関数と型に型パラメータを追加する仕組みです。制約はインターフェースで表現し、メソッドだけでなく型セット (
~int | ~float64) も指定できます。 anyは何も要求せず、comparableは==とmapのキーを許可し、cmp.Orderedは大小比較を許可します。必要最小限の制約を選びます。- C# との主な違いは次のとおりです。
- 実行時に reified されず、
typeof(T)やnew T()は使えない - メソッドに型パラメータを追加できない
- 共変・反変がない
default(T)の代わりにvar zero Tを使う
- 実行時に reified されず、
- 使うべき場面
- スライス・マップ・チャネルに対する汎用アルゴリズム (Map / Filter / Reduce、ソート、検索)
- 要素型だけが異なるコンテナ (Stack、Set、Queue、キャッシュなど)
- 複数の数値型・順序型に対して同じ処理を書く場合
- 避けるべき場面
- インターフェースで十分に表現できる場合 (
io.Readerのように振る舞いで抽象化できるなら、インターフェースのほうが Go らしい) - 型が 1 つしか使われない場合の先回りした抽象化
- 型ごとに実装が大きく異なる場合 (型スイッチだらけになるなら、個別の関数にする)
- 「ジェネリクスなら速いはず」という理由だけで使う場合
- インターフェースで十分に表現できる場合 (
まずは具体的な型でコードを書き、同じ処理が複数の型で重複してきた時点でジェネリクスに置き換えるのが、Go では無理のない進め方です。