Goジェネリクスはいつ使うべきか—interfaceとの使い分けを実測で判断

Goジェネリクスはいつ使うべきか—interfaceとの使い分けを実測で判断 | mohablog

int 用と float64 用で、中身がほぼ同じ集計関数を2つ書く。Go 1.18 以前はこれが日常でした。型パラメータはこの「型だけ違うコピペ」を1つにまとめる仕組みですが、interface で足りる場面との線引きが要ります。本記事は Go 1.26.4 で動作確認しています。

目次

型を変えただけの関数を1つにまとめる

合計を求める関数を intfloat64 で別々に書くと、ループ本体は完全に同じになります。違うのは型注釈だけ。ここを型パラメータでくくり出します。

type Number interface {
	~int | ~int64 | ~float64
}

func SumGeneric[T Number](s []T) T {
	var total T
	for _, v := range s {
		total += v
	}
	return total
}

func main() {
	fmt.Println(SumGeneric([]int{1, 2, 3}))
	fmt.Println(SumGeneric([]float64{1.5, 2.5}))
}

実行結果。

6
4

型パラメータと型引数を分けて読む

[T Number]T が型パラメータ、Number が型制約です。呼び出し側が渡す intfloat64 は型引数と呼びます。公式チュートリアル “Getting started with generics” でも、宣言側の type parameters と呼び出し側の type arguments を別の語で区別しています。

型推論で呼び出し側は型を書かない

上の SumGeneric([]int{1, 2, 3}) には [int] を書いていません。引数 []int からコンパイラが T = int を推論するためです。チュートリアルの “Remove type arguments when calling the generic function” が、この型推論で SumGeneric[int](...) の角括弧を省ける条件を説明しています。推論が効かない場面だけ明示する、と覚えておけば十分。

any 制約は interface{} と同じ意味

制約に any を書くと、あらゆる型を受け取れます。anyinterface{} の別名。意味は変わりません。ただし型制約の any は「中身に何も仮定しない」という宣言であって、値を interface{} に箱詰めするわけではない点が後半のコスト差につながります。

型制約をどう設計するか

制約は interface として書きます。メソッドの集合だけでなく、型そのものの集合も書けるのがジェネリクス用の拡張です。

union 型で「数値だけ」を表す

int64 | float64 のように | で型を並べると、そのどれかであることを要求できます。チュートリアルの “Declare a type constraint” は、まさにこの int64 | float64Number インターフェースに切り出す例で構成されています。

~(チルダ)で独自型も受け取る

~int は「基底型が int の全型」を意味します。type MyID int のような独自型まで受けたいときは ~ が要る。付け忘れると MyID がはじかれます。

type MyID int

type IntLike interface {
	~int | ~int64
}

func Double[T IntLike](v T) T {
	return v * 2
}

// 呼び出し
fmt.Println(Double(MyID(21)))

実行結果。~int | int64 に変えると MyID does not satisfy IntLike でコンパイルが止まります。

42

comparable と cmp.Ordered を使い分ける

等価比較だけなら comparable、大小比較が要るなら cmp.Ordered。後者は Go 1.21 で標準入りした制約で、自前で union を書く手間が消えます。

import "cmp"

func Min[T cmp.Ordered](a, b T) T {
	if a < b {
		return a
	}
	return b
}

fmt.Println(Min(3, 5))
fmt.Println(Min("b", "a"))

数値も文字列も同じ Min で通ります。実行結果。

3
a

interface とジェネリクスの使い分け

型パラメータを覚えると、何でもジェネリクスで書きたくなる。公式ブログ "When To Use Generics" は使うべき場面と使うべきでない場面を別の節に分けて整理しており、判断軸はそこに尽きています。

メソッドを呼ぶだけなら interface

値に対してメソッドを呼ぶだけなら、型パラメータは不要です。"When are type parameters not useful?" の節は「メソッドを呼ぶだけなら interface 型を使え」と明言します。引用すると、型パラメータを省いたほうが 書きやすく、読みやすく、実行時間もおそらく変わらないio.Reader を受け取る関数をジェネリクスにする理由はありません。

型ごとに実装が変わるなら interface

型によって処理の中身が違うなら、ジェネリクスは向きません。型パラメータは「どの型でも同じコードが動く」ことが前提だからです。型ごとに分岐したい処理は、interface とその実装に分けるほうが素直に収まります。

公式の one simple guideline

"When To Use Generics" は最後を1つの指針で締めます。原文はこうです。

If you find yourself writing the exact same code multiple times, where the only difference between the copies is that the code uses different types, consider whether you can use a type parameter.

「型だけが違う同一コードを何度も書いているか」。この問いに Yes なら型パラメータ、メソッド呼び出しが主役なら interface。判断を表にまとめます。

状況選ぶもの理由
型だけ違う同一処理を複数回書いている型パラメータコピペを1つに集約できる
値のメソッドを呼ぶだけinterface記述が短く実行時間も同等
型ごとに処理の中身が違うinterface + 実装ジェネリクスは同一コード前提
slice / map / channel を要素型に依存せず操作型パラメータ要素型へ仮定を置かず型検査も効く
メソッドを持たない型もあり実装も型ごとに違うreflection上記2つで届かない領域

ジェネリックなデータ構造を書く

"When To Use Generics" が型パラメータの主用途に挙げるのが、言語に組み込まれていない汎用データ構造です。連結リストやスタックを interface{} で持つと、取り出すたびに型アサーションが要る。型パラメータなら中身の型が保たれます。

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
	}
	v := s.items[len(s.items)-1]
	s.items = s.items[:len(s.items)-1]
	return v, true
}

func main() {
	st := &Stack[string]{}
	st.Push("a")
	v, ok := st.Pop()
	fmt.Println(v, ok)
}

実行結果。Stack[string] から取り出した vstring として扱え、アサーションは要りません。

a true

ゼロ値は var zero T で取り出す

空のときに返す値に注意が要ります。T が何かは書く時点で不明なので、nil0"" も直接書けない。var zero T で型に応じたゼロ値を作り、それを返します。これはジェネリック関数で頻出のイディオムです。

メソッドより関数を渡す

データ構造に比較や変換の補助操作を持たせたいとき、型パラメータ側にメソッドを要求したくなる。"When To Use Generics" の "For type parameters, prefer functions to methods" は、その代わりに関数を引数で渡せと勧めます。実装側に特定メソッドの実装を強制するより、関数1つを渡すほうが呼び出し側の制約が緩むためです。

func MaxBy[T any](s []T, less func(a, b T) bool) T {
	m := s[0]
	for _, v := range s[1:] {
		if less(m, v) {
			m = v
		}
	}
	return m
}

fmt.Println(MaxBy([]int{3, 1, 4, 1, 5}, func(a, b int) bool { return a < b }))

実行結果。TLess メソッドを要求せず、比較ロジックを呼び出し側が持ち込めます。

5

実測で見る interface{} とのコスト差

「型安全」は分かるとして、速度はどうか。同じ集計を型パラメータ版と []any 版で測りました。[]any 版は要素を interface{} に箱詰めし、取り出しで型アサーションを通します。

func SumAny(s []any) any {
	var total int
	for _, v := range s {
		total += v.(int)
	}
	return total
}

func BenchmarkSumGeneric(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = SumGeneric(intSlice) // []int, len 1000
	}
}

func BenchmarkSumAny(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = SumAny(anySlice) // []any, len 1000
	}
}

go test -bench=. -benchmem -benchtime=2s -count=3 を Apple M5 / Go 1.26.4 で回した結果。

BenchmarkSumGeneric-10    10676955    225.0 ns/op    0 B/op    0 allocs/op
BenchmarkSumAny-10         8452326    283.8 ns/op    8 B/op    1 allocs/op

要素1000件の合計で、型パラメータ版が 225.0 ns/op[]any 版が 283.8 ns/op。差は約20%です。アロケーションは型パラメータ版が 0 allocs/op[]any 版が 1 allocs/opinterface{} の箱詰めと型アサーションが、この差を生みます。要素型が固定なら型パラメータのほうが速く、ヒープも汚しません。

アロケーションが出る理由は、interface{} が「型情報へのポインタ」と「値へのポインタ」の組で値を持つためです。int のような値型を any に入れると、その値をヒープに退避してアドレスを保持する箱詰めが要ります。型パラメータ版は int をそのまま int として扱うので、この箱詰めが発生しません。20%という差自体は小さく、ループ1回あたりでは数十ナノ秒。速度のためにジェネリクスを選ぶ必然性は薄く、効くのは型安全とアロケーション削減のほうです。実際、公式が型パラメータを勧める第一の理由も速度ではなく、ビルド時の型検査が効く点にあります。

踏みやすい2つの制約

ジェネリクスには言語仕様上の制限があり、コンパイラのエラーで気づくことが多い。よく踏むものから潰します。

メソッドに型パラメータは付けられない

型のメソッドに、レシーバとは別の型パラメータを足すことはできません。次は Box[T] のメソッドに U を持たせようとした例です。

func (b Box[T]) Map[U any](f func(T) U) U {
	return f(b.v)
}

コンパイル結果。回避するには、メソッドではなくトップレベルの関数 func Map[T, U any](b Box[T], f func(T) U) U にします。

syntax error: method must have no type parameters

comparable を満たさない型を渡す

キーに comparable を要求する関数へ、関数型やスライスをキーに持つ map を渡すと止まります。func() やスライスは == で比較できないためです。

func() does not satisfy comparable
invalid map key type func()

map のキーに使える型は元から comparable に収まるので、map 由来のキーを扱う限りこのエラーは出ません。自前で comparable を要求するときだけ意識すれば足ります。

まとめ

  • 型パラメータは「型だけ違う同一コード」を1つに集約する仕組み。Go 1.18 で導入
  • 制約は interface で書く。union 型・~comparablecmp.Ordered を場面で選ぶ
  • 値のメソッドを呼ぶだけなら interface。型ごとに実装が違うなら interface + 実装
  • 判断軸は公式 "When To Use Generics" の「型だけが違う同一コードを何度も書いているか」
  • 要素型が固定の集計では型パラメータ版が約20%速く、アロケーションは 0 allocs/op
  • メソッドに独自の型パラメータは付けられない。補助操作は関数を引数で渡す
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次