Go上級 #3 ジェネリクス — type parameter と constraint
#2 sync パッケージの次、今回は別の毛色のツール。ジェネリクス。
Go 1.18(2022)で導入されました。それ以前はinterface{}(今のany) + 型アサーションで回避していた場面を — 今では型安全に表現できます。
ジェネリクスが似合う場面 #
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 — インターフェースの拡張 #
従来のインターフェースはメソッド集合だけを表現しました。ジェネリクスが入って — インターフェースが型集合も表現するようになりました。
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のような場面で登場。
func Index[T comparable](s []T, target T) int {
for i, v := range s {
if v == target {
return i
}
}
return -1
}comparableは — あらかじめ定義されたconstraint。int、string、struct(フィールドがすべてcomparableなら)などをすべて含みますが、slice、map、functionは比較不可なので除外されます。
any — 制約なし
#
func First[T any](s []T) T {
return s[0]
}anyは — interface{}の別名。どんな型でも受け取ります。ただし、型に対して行える演算がほぼないとき限定で有用です(インデクシング、代入程度)。
golang.org/x/exp/constraints — 標準 constraint たち
#
よく使われるconstraintは別パッケージにあらかじめ定義されています。
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)。
ジェネリックなデータ構造 #
最も自然な場面。データ構造は — 入る型と無関係に動作するのが普通です。
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]{} // 型引数を明示関数呼び出しは — 引数の型から自動推論。データ構造やメソッド呼び出しは — 明示が必要な場面が多いです。
標準ライブラリでの活用 — slices、maps
#
Go 1.21で — ジェネリクスベースの標準パッケージが入りました。
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 — 必要なだけ。
罠 — インターフェースをそのまま使うほうが良い場合も #
// ジェネリクス
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は意図的に標準には入れませんでしたが、ジェネリクスで自然に書けます。
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。シリアライゼーションライブラリの内側で見かけるツールを整理します。