Go ジェネリクス完全解説:柔軟で再利用可能なコードと共有ライブラリの構築

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

問題:静的型付け言語におけるコピペ地獄

典型的なGoのコードベースを想像してみよう。整数スライスから最小値を求める関数がある。次に誰かがfloat64でも同じものが必要になる。さらにint64でも必要になる。気づけばMinIntMinFloat64MinInt64——型が違うだけでまったく同じことをする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はintfloat64のどちらかである」という意味だ。Min(3, 5)と呼び出せば、GoはTintであると推論する。型引数を明示的に指定する必要はない。

制約: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コードの大多数において、これはまさに正しいトレードオフだ。

Share: