Go上級 #3 ジェネリクス — type parameter と constraint

#2 sync パッケージの次、今回は別の毛色のツール。ジェネリクス

Go 1.18(2022)で導入されました。それ以前はinterface{}(今のany) + 型アサーションで回避していた場面を — 今では型安全に表現できます。

ジェネリクスが似合う場面 #

すべての型に動作する Min
func Min[T int | float64 | string](a, b T) T {
	if a < b {
		return a
	}
	return b
}

Min(1, 2)         // 1
Min(1.5, 2.5)     // 1.5
Min("a", "b")     // "a"

[T int | float64 | string]型パラメータ。Tは呼び出し時点で決まります。

核心は2つ #

  • type parameter list — 関数名の後ろに[T constraint, U constraint, ...]
  • constraint — Tが満たすべき条件。インターフェースで表現

Constraint — インターフェースの拡張 #

従来のインターフェースはメソッド集合だけを表現しました。ジェネリクスが入って — インターフェースが型集合も表現するようになりました。

型集合としての constraint
type Number interface {
	int | float64
}

func Sum[T Number](nums []T) T {
	var sum T
	for _, n := range nums {
		sum += n
	}
	return sum
}

Numberインターフェースは — intまたはfloat64である型だけを満たす。メソッドがなくてもインターフェースとして使われます。

~トークン — 基底型まで含む #

~ トークン
type Number interface {
	~int | ~float64
}

type Celsius float64
Sum([]Celsius{1, 2, 3})    // OK — Celsius の基底型が float64

~intは — 基底型(underlying type)がintであるすべての型。type ID intのような定義された型まで含みます。

~なしの単なるint | float64なら — Celsiusのような定義された型は使用不可。

comparable — 比較可能な型 #

== !=が可能な型の集合。mapのkeyのような場面で登場。

comparable
func Index[T comparable](s []T, target T) int {
	for i, v := range s {
		if v == target {
			return i
		}
	}
	return -1
}

comparableは — あらかじめ定義されたconstraint。intstring、struct(フィールドがすべてcomparableなら)などをすべて含みますが、slice、map、functionは比較不可なので除外されます。

any — 制約なし #

any constraint
func First[T any](s []T) T {
	return s[0]
}

anyは — interface{}の別名。どんな型でも受け取ります。ただし、型に対して行える演算がほぼないとき限定で有用です(インデクシング、代入程度)。

golang.org/x/exp/constraints — 標準 constraint たち #

よく使われるconstraintは別パッケージにあらかじめ定義されています。

exp/constraints
import "golang.org/x/exp/constraints"

func Max[T constraints.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

constraints.Orderedは — < > <= >=が可能な型(数値 + string)。

ジェネリックなデータ構造 #

最も自然な場面。データ構造は — 入る型と無関係に動作するのが普通です。

ジェネリック Stack
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) {
	if len(s.items) == 0 {
		var zero T
		return zero, false
	}
	last := len(s.items) - 1
	v := s.items[last]
	s.items = s.items[:last]
	return v, true
}

func main() {
	s := Stack[int]{}
	s.Push(1)
	s.Push(2)
	v, _ := s.Pop()    // 2
	_ = v
}

var zero T — ジェネリック型のzero valueを得る標準パターン。

ジェネリック関数の型推論 #

型推論
Sum([]int{1, 2, 3})           // T は int に推論
Sum[int]([]int{1, 2, 3})      // 明示的

stack := Stack[int]{}          // 型引数を明示

関数呼び出しは — 引数の型から自動推論。データ構造やメソッド呼び出しは — 明示が必要な場面が多いです。

標準ライブラリでの活用 — slicesmaps #

Go 1.21で — ジェネリクスベースの標準パッケージが入りました。

slices パッケージ
import "slices"

slices.Contains([]int{1, 2, 3}, 2)         // true
slices.Sort([]string{"b", "a", "c"})        // ソート
slices.Index([]int{1, 2, 3}, 2)            // 1
slices.Reverse([]int{1, 2, 3})              // [3 2 1]

import "maps"
maps.Keys(m)                                 // map のすべての key

以前は手書きで作っていたヘルパーが — 今は標準にすべてあります。

罠 — メソッドは type parameter を持てない #

よく出会う罠
type Box[T any] struct{ v T }

// メソッドは新しい type parameter を定義不可
func (b Box[T]) Map[U any](f func(T) U) Box[U] {    // ✗ コンパイルエラー
	// ...
}

メソッドは — receiverの型パラメータ(T)だけ使用可能で、新しい型パラメータの導入は不可。こういう変換が必要なら関数で書かなければなりません。

関数で
func MapBox[T, U any](b Box[T], f func(T) U) Box[U] {
	return Box[U]{v: f(b.v)}
}

罠 — あまりに広範な constraint #

過剰な一般化
func Process[T any](v T) {
	// any なので v に対してできることがほぼない
}

constraintが広いほど — Tに対して行える演算が減ります。算術が必要ならNumber、比較が必要ならcomparable、ソートが必要ならOrdered — 必要なだけ。

罠 — インターフェースをそのまま使うほうが良い場合も #

ジェネリクス vs インターフェース
// ジェネリクス
func Print[T fmt.Stringer](v T) {
	fmt.Println(v.String())
}

// インターフェース
func Print(v fmt.Stringer) {
	fmt.Println(v.String())
}

この場面では — インターフェースのほうが自然です。動的ディスパッチが必要な場面(複数の異なる型が1つのスライスに混ざる)は — ジェネリクスではなくインターフェース。

単純なガイドライン #

  • 型安全性と性能が重要なデータ構造 → ジェネリクス
  • 多態性が本質(複数の異なる型を1つのコンテナに) → インターフェース
  • 単一関数で複数の型を受け取りたい → どちらも可、可読性で決定

パターン — Map / Filter / Reduce #

Goは意図的に標準には入れませんでしたが、ジェネリクスで自然に書けます。

Map / Filter / Reduce
func Map[T, U any](s []T, f func(T) U) []U {
	out := make([]U, len(s))
	for i, v := range s {
		out[i] = f(v)
	}
	return out
}

func Filter[T any](s []T, pred func(T) bool) []T {
	var out []T
	for _, v := range s {
		if pred(v) {
			out = append(out, v)
		}
	}
	return out
}

func Reduce[T, U any](s []T, init U, f func(U, T) U) U {
	acc := init
	for _, v := range s {
		acc = f(acc, v)
	}
	return acc
}

よく使うならヘルパーパッケージにまとめておくと便利です。ただし — Goの一般的なスタイルはforループを明示的に書くほうです(可読性が高いと見られているため)。

どこまで抽象化するか #

ジェネリクスが入ったからといって — すべてのコードをジェネリクスに変えるのは良くありません。3回以上同じ形が見えてきたら一般化を検討する程度が適切です。

“Don’t be afraid to write a little code.” — Rob Pike

Goが意図的に単純さを保つ言語である以上 — ジェネリクスも同じ精神で。あえて一般化しなくてもよいものを一般化すると、可読性が下がる場合が多いです。

まとめ #

今回の記事で整理した内容:

  • type parameter — 関数/型名の後ろに[T constraint]
  • constraint — メソッド + 型集合を表現するインターフェース
  • ~T — 基底型ベースの型まで含む
  • comparable==比較可能
  • any — 制約なし(= interface{})
  • constraints.Ordered — ソート可能な型
  • データ構造 — ジェネリクスが最もよく似合う場面
  • メソッドは新しい型パラメータの導入不可
  • 多態性が本質なら — インターフェースが自然
  • 抽象化は3回以上の繰り返しが見えるとき

次の記事(#4 reflect パッケージ)では — ランタイムに型を扱うreflect。シリアライゼーションライブラリの内側で見かけるツールを整理します。

X