問題:静的型付け言語におけるコピペ地獄
典型的なGoのコードベースを想像してみよう。整数スライスから最小値を求める関数がある。次に誰かがfloat64でも同じものが必要になる。さらにint64でも必要になる。気づけばMinInt、MinFloat64、MinInt64——型が違うだけでまったく同じことをする3つの関数が出来上がっている。
Go 1.18以前、この問題への対処法はどれも美しくなかった。コードを重複させるか、interface{}(旧来のany)に頼るかのどちらかだ。後者は型安全性を完全に失い、コードベース中に型アサーションが散乱することになる。どちらの道も保守を難しくし、バグを生みやすくする。
根本的な問題は、Goの型システムが明快さと安全性において優れている一方で、「この関数はあらゆる数値型で動く」という表現手段を持っていなかったことだ。2022年3月にGo 1.18で導入されたジェネリクスが、ついにその問題を解決した。
押さえておくべき基本概念
型パラメータ
Goのジェネリクスは型パラメータを使う——角括弧で宣言されたプレースホルダーで、呼び出し時に実際の型で埋められる。
func Min[T int | float64](a, b T) T {
if a < b {
return a
}
return b
}
[T int | float64]の部分は「Tはintかfloat64のどちらかである」という意味だ。Min(3, 5)と呼び出せば、GoはTがintであると推論する。型引数を明示的に指定する必要はない。
制約:Goジェネリクスの核心
制約は、型パラメータとして使用できる型を定義する。最もシンプルなのはany——制限なし、interface{}と同等だ。より実用的な制約はインターフェースから構築する。
Go 1.21ではcmpパッケージとslicesパッケージが標準ライブラリに追加され、すぐに使える制約が提供されている。カスタム制約が必要な場合はインターフェースを定義する:
type Number interface {
int | int8 | int16 | int32 | int64 |
float32 | float64
}
func Sum[T Number](values []T) T {
var total T
for _, v := range values {
total += v
}
return total
}
これでSumはあらゆる数値型に対応する。[]int、[]float64、[]int32を渡しても、そのまま動く。
~チルダ演算子:カスタム型への対応
ここで多くの人が躓く点がある。type Celsius float64のようなカスタム型に対して、制約にfloat64だけを指定しても一致しない。チルダが必要だ:
type Temperature interface {
~float32 | ~float64
}
~float64は「基底型がfloat64であるあらゆる型」を意味する。float64そのものはもちろん、Celsiusのようにその上に定義された名前付き型も対象となる。
複数の型パラメータ
関数には複数の型パラメータを宣言できる。マップのような変換処理を構築する際に便利だ:
func Map[T, U any](slice []T, fn func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
任意の型のスライスを別の任意の型のスライスに変換できる。インターフェースのキャストも、リフレクションも不要だ。
実践:再利用可能なユーティリティライブラリの構築
ジェネリクスが最も威力を発揮するのが社内ユーティリティパッケージだ。各チームが同じスライスヘルパーやコレクション型を再実装する代わりに、一度書いて共有できる。実例として、小さいながら実用的なライブラリを作ってみよう。
汎用フィルター関数
package sliceutil
// Filter は、述語関数がtrueを返す要素のみを含む新しいスライスを返す。
func Filter[T any](slice []T, predicate func(T) bool) []T {
var result []T
for _, v := range slice {
if predicate(v) {
result = append(result, v)
}
}
return result
}
使用例:
evens := sliceutil.Filter([]int{1, 2, 3, 4, 5, 6}, func(n int) bool {
return n%2 == 0
})
// evens = [2, 4, 6]
long := sliceutil.Filter([]string{"go", "rust", "python", "c"}, func(s string) bool {
return len(s) > 2
})
// long = ["rust", "python"]
一つの関数が両方のケースに対応する。型アサーションなし。誤ったキャストによるランタイムパニックもなし。
汎用Set型
ジェネリクス以前、ほとんどのチームはmap[string]struct{}を直接使う(文字列限定)か、型ごとにSetの実装を別々に書く(面倒でコピペだらけ)かのどちらかだった。ジェネリクスなら一度書けば済む:
package collections
type Set[T comparable] struct {
items map[T]struct{}
}
func NewSet[T comparable]() *Set[T] {
return &Set[T]{items: make(map[T]struct{})}
}
func (s *Set[T]) Add(item T) {
s.items[item] = struct{}{}
}
func (s *Set[T]) Contains(item T) bool {
_, ok := s.items[item]
return ok
}
func (s *Set[T]) Remove(item T) {
delete(s.items, item)
}
func (s *Set[T]) Size() int {
return len(s.items)
}
// Union は、両方のSetの全要素を含む新しいSetを返す。
func Union[T comparable](a, b *Set[T]) *Set[T] {
result := NewSet[T]()
for k := range a.items {
result.Add(k)
}
for k := range b.items {
result.Add(k)
}
return result
}
制約comparableはGoに組み込まれており、マップのキーとして必要な==と!=をサポートする型を意味する。比較可能なあらゆる型で動作する:
intSet := collections.NewSet[int]()
intSet.Add(1)
intSet.Add(2)
intSet.Add(1)
fmt.Println(intSet.Size()) // 2
strSet := collections.NewSet[string]()
strSet.Add("go")
strSet.Add("rust")
fmt.Println(strSet.Contains("go")) // true
有効期限付きキャッシュ
さらに発展させて、TTLをサポートする汎用インメモリキャッシュを作ってみよう:
package cache
import (
"sync"
"time"
)
type entry[V any] struct {
value V
expiresAt time.Time
}
type Cache[K comparable, V any] struct {
mu sync.RWMutex
items map[K]entry[V]
}
func New[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{items: make(map[K]entry[V])}
}
func (c *Cache[K, V]) Set(key K, value V, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = entry[V]{value: value, expiresAt: time.Now().Add(ttl)}
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.items[key]
if !ok || time.Now().After(e.expiresAt) {
var zero V
return zero, false
}
return e.value, true
}
完全に型付けされた使用感——キャストなし、interface{}の漏れなし:
// 文字列キーからUser構造体へのキャッシュ
userCache := cache.New[string, User]()
userCache.Set("u:123", user, 5*time.Minute)
if u, ok := userCache.Get("u:123"); ok {
fmt.Println(u.Name)
}
ジェネリクスを使わない方がよいケース
使い方を知ることと同じくらい、使わない判断も重要だ。次のような場合はジェネリクスを避けよう:
- その関数が常に一つの具体的な型にしか使われない場合——その型を直接使えばよい。
- 二番目の関数を書くのを避けるためだけに型パラメータを追加する場合——具体的な関数を二つ書いた方が、一つの汎用関数より明快なこともある。
- 型によって内部ロジックが大きく異なる場合——それはジェネリクスの問題ではなく、インターフェースによるポリモーフィズムで解決すべき問題だ。
実用的な判断基準として:通常のインターフェースとメソッドディスパッチで表現できるなら、そうするべきだ。アルゴリズムの構造が同じで型だけが異なる場合に、ジェネリクスを使おう。
まとめ
コピペしていたユーティリティ関数をいくつかジェネリクス版に移行すると、その差は一目瞭然だ。重複が減る。潜在的なランタイムパニックを隠す型アサーションがなくなる。各チームが自分のパッケージにこっそりコピーする代わりに、本当に再利用される共有ライブラリが生まれる。
小さく始めよう。最も重複している関数を書き直す。型付きコレクションを一つか二つ作ってみる。制約の設計感覚がつかめてくると、コードベース全体に自然な活用機会が見えてくるはずだ。
Goチームはジェネリクスを意図的に保守的な設計にした——C++のテンプレートより単純で、Rustのトレイトシステムよりも魔法がない。それは極端なエッジケースでの表現力が限られることを意味するが、一方で新しいチームメンバーが急な学習曲線なくジェネリクスのコードを読めることも意味する。プロダクションGoコードの大多数において、これはまさに正しいトレードオフだ。

