Zenn
🐷

Goのconstraintsパッケージで型制約を極める!ジェネリクスをもっと便利に使おう

2

3秒まとめ

  • Goのジェネリクスは型安全なコードを書くための強力な機能
  • constraintsパッケージを使うと型制約をより細かく設定できる
  • 自作の型制約インターフェースでコードの再利用性が格段にアップする
  • Go 1.18から使えるようになった機能だけど、まだまだ知られていない便利な使い方がある

どんな人向けの記事?

  • Goでジェネリクスを使っているけど、もっと便利に使いたい方
  • 型制約について詳しく知りたい方
  • コードの再利用性を高めたいGoエンジニア
  • constraintsパッケージの存在は知っているけど、使いこなせていない方

Goのジェネリクスと型制約の基本

Go 1.18で導入されたジェネリクスは、型安全性を保ちながらコードの再利用性を高める素晴らしい機能です。ぼくは最初、「Goってシンプルな言語だから、ジェネリクスとか要らないんじゃない?」と思っていました。でも実際に使ってみると、**「なんでこれ今まで無かったんだ!」**と感動するくらい便利なんですよね。

ジェネリクスの基本的な使い方はこんな感じです。

func Max[T int | float64](a, b T) T {
    if a > b {
        return a
    }
    return b
}

これでint型とfloat64型の両方に対応したMax関数が作れます。でも、「比較可能な型」全般に対応させたい場合はどうすればいいでしょうか?

そこで登場するのが型制約です。型制約を使うと、特定の操作ができる型だけを受け入れるようにできます。

func Max[T comparable](a, b T) T {
    if a > b { // コンパイルエラー!
        return a
    }
    return b
}

でも、これだとコンパイルエラーになります。なぜならcomparableは等値比較(==!=)はできますが、大小比較(><)はできないからです。

「じゃあどうすればいいの?」と思いますよね。そこで登場するのがconstraintsパッケージです!

constraintsパッケージとは?

constraintsパッケージは、Go 1.18で導入された標準ライブラリの一部で、ジェネリクスで使える便利な型制約を提供しています。

https://pkg.go.dev/golang.org/x/exp/constraints

このパッケージには、以下のような便利な型制約が定義されています:

  • Ordered: 大小比較可能な型(<, <=, >, >=が使える型)
  • Integer: すべての整数型
  • Float: すべての浮動小数点型
  • Complex: すべての複素数型
  • Signed: 符号付き整数型
  • Unsigned: 符号なし整数型

これらを使うと、先ほどのMax関数はこう書けます:

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

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

func main() {
	println(Max(1, 2))     // 2
	println(Max("a", "b")) // b
}

これでintfloat64stringなど、大小比較可能なすべての型に対応したMax関数が作れました!めちゃくちゃ便利ですね。

constraintsパッケージを使った実践的な例

例1: 数値型に対応したジェネリック関数

数値型(整数と浮動小数点数)だけを受け付ける関数を作りたい場合、constraints.Integer | constraints.Floatのように組み合わせて使えます。

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

// 数値型だけを受け付けるSum関数
func Sum[T constraints.Integer | constraints.Float](values []T) T {
    var sum T
    for _, v := range values {
        sum += v
    }
    return sum
}

これで整数型と浮動小数点型の両方に対応したSum関数が作れました。

例2: 独自の型制約インターフェースを定義する

constraintsパッケージの型制約を組み合わせて、独自の型制約インターフェースを定義することもできます。

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

// 数値型の制約
type Number interface {
    constraints.Integer | constraints.Float
}

// 平均を計算する関数
func Average[T Number](values []T) float64 {
    var sum T
    for _, v := range values {
        sum += v
    }
    return float64(sum) / float64(len(values))
}

これでAverage関数は整数型と浮動小数点型の両方に対応できます。

例3: マップのキーと値の型を制約する

マップの操作を行うジェネリック関数を作る場合、キーと値の型に制約を設けることができます。

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

// マップの値を合計する関数
func SumMapValues[K comparable, V Number](m map[K]V) V {
    var sum V
    for _, v := range m {
        sum += v
    }
    return sum
}

ここでは、キーの型Kcomparable(等値比較可能)、値の型Vは先ほど定義したNumber(整数または浮動小数点数)という制約を設けています。

実際のプロジェクトでの活用例

例えば、以下のようなpackageを非常に簡潔に書くことが出来ます。

例: データ分析用の統計関数ライブラリ

package stats

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

// 数値型の制約
type Number interface {
    constraints.Integer | constraints.Float
}

// 平均値を計算
func Mean[T Number](data []T) float64 {
    return float64(Sum(data)) / float64(len(data))
}

// 合計を計算
func Sum[T Number](data []T) T {
    var sum T
    for _, v := range data {
        sum += v
    }
    return sum
}

// 最大値を取得
func Max[T constraints.Ordered](data []T) T {
    if len(data) == 0 {
        var zero T
        return zero
    }
    
    max := data[0]
    for _, v := range data[1:] {
        if v > max {
            max = v
        }
    }
    return max
}

// 最小値を取得
func Min[T constraints.Ordered](data []T) T {
    if len(data) == 0 {
        var zero T
        return zero
    }
    
    min := data[0]
    for _, v := range data[1:] {
        if v < min {
            min = v
        }
    }
    return min
}

// 中央値を計算
func Median[T Number](data []T) float64 {
    // データのコピーを作成してソート
    sorted := make([]T, len(data))
    copy(sorted, data)
    Sort(sorted)
    
    // 中央値を計算
    n := len(sorted)
    if n%2 == 0 {
        return (float64(sorted[n/2-1]) + float64(sorted[n/2])) / 2
    }
    return float64(sorted[n/2])
}

// ソート関数(簡易実装)
func Sort[T constraints.Ordered](data []T) {
    // 簡単のため、バブルソートを使用
    n := len(data)
    for i := 0; i < n-1; i++ {
        for j := 0; j < n-i-1; j++ {
            if data[j] > data[j+1] {
                data[j], data[j+1] = data[j+1], data[j]
            }
        }
    }
}

このライブラリを使うと、整数型や浮動小数点型のデータに対して統計計算が簡単にできます:

package main

import (
    "fmt"
    
    "yourmodule/stats"
)

func main() {
    // 整数データ
    intData := []int{5, 2, 9, 1, 5, 6}
    fmt.Printf("整数データの平均値: %.2f\n", stats.Mean(intData))
    fmt.Printf("整数データの最大値: %d\n", stats.Max(intData))
    fmt.Printf("整数データの中央値: %.2f\n", stats.Median(intData))
    
    // 浮動小数点データ
    floatData := []float64{3.14, 2.71, 1.41, 1.73, 2.0}
    fmt.Printf("浮動小数点データの平均値: %.2f\n", stats.Mean(floatData))
    fmt.Printf("浮動小数点データの最大値: %.2f\n", stats.Max(floatData))
    fmt.Printf("浮動小数点データの中央値: %.2f\n", stats.Median(floatData))
}

このような統計値の実装は既存のライブラリを使うことが多いですが、
ジェネリクスとconstraintsパッケージを使うことで、型ごとに別々の関数を実装する必要がなくなり、コードの重複を大幅に減らすことができます。

自作の型制約インターフェースを作る

constraintsパッケージの型制約を組み合わせて、プロジェクト固有の型制約インターフェースを作ることもできます。これにより、コードの意図がより明確になり、再利用性も高まります。

例えば、JSONにシリアライズ可能な型を表す制約インターフェースを作ってみましょう:

import (
    "encoding/json"
    "time"
)

// JSONSerializable は、JSONにシリアライズ可能な基本型を表す制約インターフェース
type JSONSerializable interface {
    ~string | ~int | ~int8 | ~int16 | ~int32 | ~int64 |
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
    ~float32 | ~float64 | ~bool | ~time.Time
}

// JSONMarshaler は、json.Marshaler インターフェースを実装している型
type JSONMarshaler interface {
    MarshalJSON() ([]byte, error)
}

// ToJSON は、値をJSON文字列に変換する関数
func ToJSON[T JSONSerializable | JSONMarshaler](value T) (string, error) {
    bytes, err := json.Marshal(value)
    if err != nil {
        return "", err
    }
    return string(bytes), nil
}

実際には、Goのjson.Marshalは構造体やマップ、スライスなど様々な型をシリアライズできます。上記の例は基本的な型のみを示していますが、実際のアプリケーションではより複雑な型も扱えるようにインターフェースを設計するとよいでしょう。

ここで使われている~演算子は、基底型が指定した型であるすべての型を表します。例えば、~stringstring型だけでなく、type MyString stringのようなユーザー定義型も含みます。

型制約の組み合わせテクニック

型制約は、論理演算子のように組み合わせることができます:

  • | (OR): 複数の型制約のいずれかを満たす型
  • & (AND): 複数の型制約のすべてを満たす型(インターフェースの埋め込みで表現)

例えば、「文字列化可能で、かつ大小比較可能な型」という制約を作りたい場合:

import (
    "fmt"
    "golang.org/x/exp/constraints"
)

// Stringer は、文字列化可能なオブジェクトを表すインターフェース
type Stringer interface {
    String() string
}

// OrderedStringer は、文字列化可能で大小比較可能な型を表す制約
type OrderedStringer interface {
    Stringer
    constraints.Ordered
}

// 文字列化して比較する関数
func CompareAsString[T OrderedStringer](a, b T) int {
    if a < b {
        return -1
    }
    if a > b {
        return 1
    }
    return 0
}

// OrderedStringerを実装したカスタム型の例
type Version string

func (v Version) String() string {
    return string(v)
}

func main() {
    v1 := Version("v1.0.0")
    v2 := Version("v2.0.0")
    
    // CompareAsStringを使って比較
    result := CompareAsString(v1, v2)
    
    switch result {
    case -1:
        fmt.Printf("%s は %s より小さい\n", v1, v2) // v1.0.0 は v2.0.0 より小さい
    case 0:
        fmt.Printf("%s と %s は等しい\n", v1, v2)
    case 1:
        fmt.Printf("%s は %s より大きい\n", v1, v2)
    }
}

この例では、Versionという文字列をラップしたカスタム型を定義し、String()メソッドを実装することでStringerインターフェースを満たしています。また、基底型がstringなのでconstraints.Orderedも満たしており、結果としてOrderedStringerインターフェースを実装していることになります。

CompareAsString関数は、このような型に対して汎用的に使えるため、バージョン比較やソートなどの機能を簡単に実装できます。また、intstringなどの標準型もOrderedStringerを満たすため、同じ関数でさまざまな型を扱えます。

パフォーマンスへの影響は?

ジェネリクスを使うと、型ごとに別々の関数を実装する必要がなくなりますが、パフォーマンスへの影響が気になりますよね。

実際、Goのジェネリクスはコンパイル時に型ごとの具体的な実装を生成する方式(モノモーフィゼーション)を採用しているため、実行時のオーバーヘッドはほとんどありません。つまり、手動で型ごとに別々の関数を書いた場合と同等のパフォーマンスが期待できます。

ただし、コンパイル時間やバイナリサイズは増加する可能性があります。特に多くの型パラメータを持つジェネリック関数を多用すると、その影響は大きくなります。

ベンチマークで比較してみた

実際にジェネリック版と非ジェネリック版の関数のパフォーマンスを比較してみました:

package main

import (
    "testing"
    
    "golang.org/x/exp/constraints"
)

// ジェネリック版のSum関数
func SumGeneric[T constraints.Integer | constraints.Float](values []T) T {
    var sum T
    for _, v := range values {
        sum += v
    }
    return sum
}

// 非ジェネリック版のSum関数(int用)
func SumInt(values []int) int {
    var sum int
    for _, v := range values {
        sum += v
    }
    return sum
}

// 非ジェネリック版のSum関数(float64用)
func SumFloat64(values []float64) float64 {
    var sum float64
    for _, v := range values {
        sum += v
    }
    return sum
}

func BenchmarkSumGenericInt(b *testing.B) {
    data := make([]int, 1000)
    for i := range data {
        data[i] = i
    }
    
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        SumGeneric(data)
    }
}

func BenchmarkSumInt(b *testing.B) {
    data := make([]int, 1000)
    for i := range data {
        data[i] = i
    }
    
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        SumInt(data)
    }
}

func BenchmarkSumGenericFloat64(b *testing.B) {
    data := make([]float64, 1000)
    for i := range data {
        data[i] = float64(i)
    }
    
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        SumGeneric(data)
    }
}

func BenchmarkSumFloat64(b *testing.B) {
    data := make([]float64, 1000)
    for i := range data {
        data[i] = float64(i)
    }
    
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        SumFloat64(data)
    }
}

ベンチマーク結果:

BenchmarkSumGenericInt-12                4511761               263.7 ns/op
BenchmarkSumInt-12                       4453472               266.9 ns/op
BenchmarkSumGenericFloat64-12            4443202               266.2 ns/op
BenchmarkSumFloat64-12                   4557516               264.4 ns/op
PASS
ok      hagakun/cmd/constraints_bench   5.859s

予想通り、ジェネリック版と非ジェネリック版でパフォーマンスの差はほとんどありませんでした。これは、Goのジェネリクスの実装方式(モノモーフィゼーション)のおかげです。

constraintsパッケージを使う際の注意点

constraintsパッケージを使う際には、いくつか注意点があります:

  1. 実験的なパッケージである:現在はexperimentalパッケージ(golang.org/x/exp/constraints)として提供されているため、将来的に変更される可能性があります。

  2. 型制約の過剰な複雑化を避ける:型制約は必要以上に複雑にしないほうが良いです。複雑な型制約はコードの可読性を下げ、コンパイル時間を増加させる可能性があります。

  3. インターフェースの埋め込みに注意:型制約インターフェースに他のインターフェースを埋め込む場合、その関係性を明確に理解しておく必要があります。

  4. ~演算子の使用:基底型に対する制約を表す~演算子は強力ですが、使いすぎるとコードの意図が不明確になる可能性があります。

まとめ

Goのconstraintsパッケージは、ジェネリクスをより便利に使うための強力なツールです。型安全性を保ちながらコードの再利用性を高めることができ、特に数値計算や汎用的なデータ処理ライブラリの実装に役立ちます。

ぼくがGoを使う理由の一つは、そのシンプルさと実用性です。ジェネリクスとconstraintsパッケージは、そのシンプルさを損なうことなく、より表現力豊かなコードを書けるようにしてくれます。

まだGoのジェネリクスを使ったことがない方は、ぜひ試してみてください。特に、複数の型に対して同じロジックを適用する場合や、型安全な汎用ライブラリを作る場合に、その威力を実感できるはずです。

おまけ

Likeください!!!!!!!!

最近の休日はClineやClaudeと戯れながらFlutterやGoの開発を楽しんでいます。
実はこの記事もClineと協力してほとんど書いてもらったのは秘密です。

最近はAIのスピードとアウトプット量が大量なので、むしろ判断の数が増えてきて仕事の密度が上がってきている気がします。

X:
https://x.com/hagakun_yakuzai

少し春の息吹が感じられるかと思っていたら、もう1年の25%が終了?時間が経つのは早いですね。

引き続き、LLM, GoやFlutter周りの気になることを調査していきます。
良かったらZennやTwitterのフォローをお願いします!

2
株式会社マインディア テックブログ

Discussion

ログインするとコメントできます