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 parameter Tを宣言します。Tはconstraint Numberを満たす必要があります。
  • 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つの異なる型に対するテストを含めてください。

golanggenericstype parameterconstraint