Goのジェネリクス:type parameterを例で学ぶ
1. 概要
Go 1.18以降、type parameterを使用して関数とデータ型を記述できます。これにより、int、float64、stringごとに同じロジックを繰り返すことなく、またinterface{}から型キャストする必要もありません。
この記事では、最初の問題からジェネリック関数、constraint、および~記号について説明します。その後、Stack[T]やCache[K, V]などのジェネリック型、テストの書き方、よくあるエラーについて説明します。読み終わると、genericsをいつ使うべきか、そして通常のinterfaceで十分な場合がいつかを理解できます。
すべての例はGo 1.22以上を使用しています。Package cmpはGo 1.21からあります。最小限のgo.modファイル:
module example.com/shop
go 1.22
外部ライブラリは不要です。この記事のすべての内容は標準ライブラリに含まれています。
2. ジェネリクスが登場する前の問題
商品数(int)と合計金額(float64)を計算する必要があるとしましょう。Go 1.18より前は、ほぼ同じ2つの関数を書くことが一般的でした:
func SumInts(xs []int) int {
var total int
for _, x := range xs {
total += x
}
return total
}
func SumFloats(xs []float64) float64 {
var total float64
for _, x := range xs {
total += x
}
return total
}
別の方法は[]interface{}を受け取ってtype switchを使うことです。この方法は型安全性を失います。エラーはランタイムにのみ発生し、関数の呼び出し箇所すべてで型キャストが必要になります。
ジェネリクスがこの問題を解決します。ロジックを1回書くだけで、コンパイラが型チェックを行います。
3. 最初のジェネリック関数
3.1. type parameterの構文
type parameterは、関数名の直後の角括弧内で宣言されます:
package main
import "fmt"
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
func main() {
quantities := []int{2, 1, 5}
prices := []float64{120.5, 99.9, 15}
fmt.Println(Sum(quantities))
fmt.Println(Sum(prices))
}
各部分の説明:
[T Number]はtype parameterTを宣言します。TはconstraintNumberを満たす必要があります。Numberはtype setを含むinterfaceです:int、int64、float64、およびそれらから派生した型(~の意味は4.3を参照)。var total TはTのゼロ値(つまり0または0.0)を作成します。+=演算子は有効です。なぜなら、type set内のすべての型が加算をサポートしているからです。
結果:
8
235.4
3.2. 型推論
注意してください。Sum[int](quantities)ではなくSum(quantities)と呼び出しているのに気づきましたか?コンパイラは引数からTを自動的に推論します。これが型推論です。
コンパイラが推論に十分な情報を持たない場合(例えば、type parameterが戻り値の型にのみ表れる場合)にのみ、明示的に指定する必要があります:
func Zero[T any]() T {
var z T
return z
}
func main() {
fmt.Println(Zero[string]() == "") // true
}
3.3. 複数のtype parameter:MapとFilter
これは最も一般的な2つのユーティリティ関数です。これらをOrderのリストに適用します:
package main
import "fmt"
type Order struct {
ID string
Customer string
Total float64
}
func Map[T, U any](xs []T, f func(T) U) []U {
out := make([]U, 0, len(xs))
for _, x := range xs {
out = append(out, f(x))
}
return out
}
func Filter[T any](xs []T, keep func(T) bool) []T {
var out []T
for _, x := range xs {
if keep(x) {
out = append(out, x)
}
}
return out
}
func main() {
orders := []Order{
{"A01", "Lan", 250_000},
{"A02", "Minh", 1_200_000},
{"A03", "Lan", 890_000},
}
big := Filter(orders, func(o Order) bool { return o.Total >= 500_000 })
ids := Map(big, func(o Order) string { return o.ID })
fmt.Println(ids)
}
Map[T, U any]は2つのtype parameterを持つため、入力型と出力型が異なる場合があります。anyはinterface{}のエイリアスです。すべての型を許可しますが、割り当てと渡す以外の演算子は呼び出せません。- コンパイラは
func(o Order) stringからT = OrderおよびU = stringを推論します。
結果:
[A02 A03]
4. Constraint:Tが何をできるかを制限する
Constraintは、関数内でT型の値で何ができるかを決定します。
4.1. comparable
comparableは組み込みのconstraintです。==と!=の使用を許可し、またTをmapのキーにするための条件でもあります:
func Unique[T comparable](xs []T) []T {
seen := make(map[T]struct{}, len(xs))
var out []T
for _, x := range xs {
if _, ok := seen[x]; !ok {
seen[x] = struct{}{}
out = append(out, x)
}
}
return out
}
// Unique([]string{"Lan", "Minh", "Lan"}) => [Lan Minh]
上のOrder構造体もcomparableです。すべてのフィールドが比較可能だからです。sliceやmapまたはfuncを含む構造体はcomparableを満たさず、コンパイラはエラーを報告します。
4.2. cmp.Ordered
より大きいまたはより小さいとの比較が必要な場合は、標準ライブラリのcmp.Orderedを使用します:
import "cmp"
func MaxBy[T any, K cmp.Ordered](xs []T, key func(T) K) (T, bool) {
var best T
if len(xs) == 0 {
return best, false
}
best = xs[0]
for _, x := range xs[1:] {
if key(x) > key(best) {
best = x
}
}
return best, true
}
// top, _ := MaxBy(orders, func(o Order) float64 { return o.Total })
// top.ID => "A02"
K cmp.Orderedはキー値に対して>を使用することを許可します。- 関数は
(T, bool)を返し、panicすることなく空のsliceを処理します。
注意: 単純な数値スライスの場合、自分で書く必要はありません。Go 1.21にはビルトイン関数
max/minがあり、slicesパッケージにはslices.Max、slices.Contains、slices.Sortがあります。ジェネリックなユーティリティを自分で書く前に、slicesとmapsを確認してください。
4.3. ~(チルダ)記号
実際のドメインでは、通貨用の独自の型を定義することがよくあります:
type VND int64
func main() {
payments := []VND{150_000, 350_000}
fmt.Println(Sum(payments)) // 500000
}
このコンパイルが成功するのは、Number内の~int64のおかげです。~int64記号は、「underlying typeがint64であるすべての型」を意味します。~なしでint64と書くと、VNDはconstraintを満たさず、コンパイラは報告します:
VND does not satisfy Number (possibly missing ~ for int64 in Number)
簡単なルール:基本型のconstraintを記述するときは、ほぼ常に~を使うべきです。
4.4. メソッドを組み合わせたconstraint
Constraintはメソッドを要求することもできます。通常のinterfaceのように:
type Identifiable interface {
comparable
Key() string
}
Identifiableを満たす型は、比較可能であり、かつメソッドKey() stringを持つ必要があります。
5. ジェネリック型
5.1. Stack[T]
Type parameterは関数だけでなく構造体にも使用できます:
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
}
func (s *Stack[T]) Len() int { return len(s.items) }
使用方法:
var history Stack[Order]
history.Push(Order{ID: "A01"})
history.Push(Order{ID: "A02"})
o, _ := history.Pop()
fmt.Println(o.ID, history.Len()) // A02 1
- Receiverは
*Stack[T]として記述し、[T]を含める必要があります。 - 変数を宣言するときは、型を明示的に指定する必要があります:
Stack[Order]。ここでは推論はありません。 Stack[Order]のゼロ値はすぐに使用できます。appendはnilスライスに対して正常に機能するからです。
5.2. Cache[K, V]:複数のgoroutineに対して安全
より実用的な例:CustomerとOrderの両方で共有される、メモリ内キャッシュです。
package cache
import "sync"
type Cache[K comparable, V any] struct {
mu sync.RWMutex
data map[K]V
}
func New[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{data: make(map[K]V)}
}
func (c *Cache[K, V]) Put(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
type Customer struct {
ID int
Name string
}
customers := cache.New[int, Customer]()
customers.Put(1, Customer{ID: 1, Name: "Lan"})
orders := cache.New[string, Order]()
orders.Put("A01", Order{ID: "A01", Total: 250_000})
c, ok := customers.Get(1)
fmt.Println(c.Name, ok) // Lan true
2つのデータ型に対応する1つの実装です。customers.Getは正確にCustomerを返し、型アサーションが不要です。誤ってcustomersにOrderをPutした場合、コードはコンパイルされません。
6. ジェネリックコードのテストの記述
Goは標準的なtestingパッケージを使用しています。Table-driven testはジェネリック関数でも通常通り機能します。各テーブルは特定の型に対応します:
package main
import "testing"
func TestSum(t *testing.T) {
ints := []struct {
name string
in []int
want int
}{
{"empty", nil, 0},
{"positive", []int{2, 1, 5}, 8},
{"mixed", []int{10, -3}, 7},
}
for _, tc := range ints {
t.Run(tc.name, func(t *testing.T) {
if got := Sum(tc.in); got != tc.want {
t.Errorf("Sum(%v) = %d, want %d", tc.in, got, tc.want)
}
})
}
if got := Sum([]VND{150_000, 350_000}); got != 500_000 {
t.Errorf("Sum VND = %d, want 500000", got)
}
}
func TestStackPopEmpty(t *testing.T) {
var s Stack[string]
v, ok := s.Pop()
if ok || v != "" {
t.Fatalf("Pop() on empty = (%q, %v), want (\"\", false)", v, ok)
}
}
テストを実行:
go test ./... -v
=== RUN TestSum
=== RUN TestSum/empty
=== RUN TestSum/positive
=== RUN TestSum/mixed
--- PASS: TestSum (0.00s)
=== RUN TestStackPopEmpty
--- PASS: TestStackPopEmpty (0.00s)
PASS
VNDのような派生型を少なくとも1つテストしてください。これは~が不足しているconstraintを検出する最速の方法です。
7. よくあるエラーと、使用してはいけない場合
メソッドには独自のtype parameterがない。 以下はコンパイルされません:
// エラー:メソッドはtype parameterを持つことができません
func (s *Stack[T]) Map[U any](f func(T) U) *Stack[U] { ... }
これをトップレベルの関数として記述してください:func MapStack[T, U any](s *Stack[T], f func(T) U) *Stack[U]。
Tに対して直接type switchを使用しないでください。 型によって分岐したい場合は、まずanyに変換する必要があります:switch v := any(x).(type) { ... }。これを頻繁に行う場合、genericsは適切なツールではないかもしれません。
Type setを持つconstraintは変数の型として使用できません。 var n Numberはエラーになります。|または~を含むinterfaceはconstraint位置でのみ使用できます。
comparableとinterfaceはpanicする可能性があります。 Kがインターフェイス型であり、内部の動的値がsliceの場合、比較またはmapのキーとして使用するとランタイムにpanicします。
注意: Genericsは必ずしもinterfaceより高速ではありません。Go コンパイラは「GC shape stenciling」とdictionaryを使用しています。異なるポインタ型は同じコードを共有でき、constraintを通じたメソッド呼び出しにはまだ間接的なコストがあります。パフォーマンスが重要な場合は、推測するのではなくbenchmarkで測定してください。
Genericsを使用してはいけない場合:
- ロジックが型ではなく動作(メソッド)に依存する場合。例えば、
PaymentServiceがSendメソッドを持つNotifierを受け取るなど。Interfaceは通常よりコンパクトで読みやすいです。 - 関数を使用する具体的な型が1つだけの場合。「後で必要な場合に備えて」generics化しないでください。
slices、maps、cmpに必要な関数がすでにある場合。
覚えやすいルール:複数の型に対して同じコード行を書いており、型名が異なるだけの場合、それはgenericsを使う時です。
8. 結論
この記事はGo のgenericsの主要な部分を説明しています:
- type parameter
[T C]を宣言し、コンパイラが型を自動推論させる。 any、comparable、cmp.Orderedのconstraintを使用し、~を使用してVNDのような派生型をサポートするカスタムconstraintを定義する。- 複数のgoroutineに対して安全なジェネリック型
Stack[T]とCache[K, V]を構築する。 - ジェネリック関数のtable-driven testを記述する。
- 制限事項を認識する:メソッドには独自のtype parameterがない、constraintは変数の型ではない、genericsはinterfaceを置き換えません。
あなたのプロジェクトで実際に繰り返されているコードから始めてください。genericsを最小限のレベルで記述し、標準ライブラリを優先し、常に少なくとも2つの異なる型に対するテストを含めてください。