🔎

やっと分かった!Goジェネリクス入門 : 初心者でもスッキリ理解できるガイド

に公開

はじめに : そもそもジェネリクスって何?

1-1. いろんな型を“汎用的”に扱いたい!

プログラムを書いていると、しばしば「複数の型に対して同じ処理をしたい」という場面に遭遇します。たとえば、以下のようなケースを考えてみましょう。

  1. 整数でも浮動小数点数でも、同じ計算ロジックを使いたい
    • 例 : スライスの要素を全部足し算したいとき、 []int 用と []float64 用で同じような関数を二つ書くのは面倒。
  2. さまざまな型に対応する“共通ライブラリ”が欲しい
    • 例 : ソート関数や検索関数など、どんな型でも同じアルゴリズムが使えるはずなのに、型ごとに別々の実装を用意するのは大変。

従来のGo ( ジェネリクスが導入される前 ) では、こうした汎用的な処理を interface{} やリフレクションで実現することが多かったのですが、 可読性や性能面で課題 がありました。ここで登場したのがジェネリクスです。

1-2. ジェネリクスとは?

**ジェネリクス ( Generics ) ** とは、「型パラメータ」を使って、 複数の型をまとめて扱うコード を書く仕組みです。コンパイル時に具体的な型ごとに展開されるため、次のようなメリットがあります。

  • 型安全
    • interface{} やリフレクションのように「実行時にパニックが起きるかも?」という不安が軽減される。
    • 呼び出し側もコンパイル時にエラーがわかるので、バグを早期発見しやすい。
  • パフォーマンス改善
    • 実行時の型判定をせず、コンパイラが最適化しやすい。
    • 大規模ループなどでリフレクションを使うケースよりも高速に動作する可能性が高い。

1-3. いつから使えるの?

Go言語のジェネリクスは、 Go 1.18 で正式に導入されました。それ以前は公式には存在せず、長らく「Goはジェネリクスを採用しないのか?」という議論が絶えなかった歴史があります。

  • Go 1.18 以降
    • func MyFunc[T any](x T) { ... } のような構文で型パラメータを定義できる。
    • 制約 ( constraints ) を使って、「T は数値型だけ」「T は比較可能な型だけ」などと指定も可能になった。

1-4. 初学者にとってのメリット

  1. コード量が減る
    • 同じロジックを型別に複数書かなくてもよくなる。
  2. タイプミスや変換ミスを減らせる
    • interface{} を使っていたときに起きやすかった変換エラー ( v.(int) など ) がコンパイル時に防がれる。
  3. 学習コストはあるが、長期的に便利
    • ジェネリクスの文法に最初は戸惑うかもしれないが、一度慣れると「型を自由に組み合わせる」イメージがつかめる。

2. ジェネリクス導入のメリットと特徴

2-1. 同じロジックを複数の型で使い回せる

「コードの重複を減らせる」という恩恵

Go 1.18以前は、たとえば「整数スライスの合計」「浮動小数点スライスの合計」という2種類の処理を書きたいとき、それぞれ別の関数を用意しなければならない ( あるいは interface{} やリフレクションを使う ) ケースが多々ありました。ジェネリクスなら、 同じ関数 に型パラメータを持たせるだけで解決できます。

func SumAll[T int | float64](nums []T) T {
    var total T
    for _, v := range nums {
        total += v
    }
    return total
}
  • T は「int か float64」であるとコンパイラに伝えることで、それぞれの型に合ったバイナリコードが生成され、 重複コードを省きつつ 型安全性を保てるわけです。

「保守性が高まる」メリット

  1. 同じロジックを1つに集約
    • 後から仕様変更があっても、ジェネリクスの関数を1か所直すだけで両方の型に反映される。
  2. テストの手間が減る
    • int 用・ float64 用それぞれに対してのコードが複数存在していた場合、2倍テストが必要だった。ジェネリクス関数なら1つの実装をテストすればOK ( 型ごとの実行確認はあっても大幅に負担減 ) 。

2-2. 型安全性が向上する

以前の方法 : interface{} やリフレクション

  • interface{}
    • 何でも受け取れるが、メソッド呼び出しや演算をするときに毎回キャストが必要。
    • キャストミスやパニックが起こる可能性があり、実行時エラーになりやすい。
  • リフレクション
    • 実行時に reflect.ValueOf(...) を使って型を判定し、演算やメソッド呼び出しをする。
    • 柔軟だが、パフォーマンス低下や可読性の低下を招きやすい。

ジェネリクスならコンパイル時に型をチェック

  • 型パラメータによる制約
    • 「T は数値型である」「T はこのインターフェイスを実装している」などをコンパイル時に宣言可能。
    • 実行時エラーのリスクが下がり、 コンパイル時エラー として早期に問題を発見しやすい。
  • 安心して演算やメソッド呼び出しができる
    • T が数値型と分かっていれば +- といった演算もコンパイル時に有効だと判断される。
    • interface{} のような「とりあえず何でもOK、でも演算のたびにキャスト」の煩雑さがなくなる。

2-3. リフレクションより高速な可能性

Go 1.18以降、ジェネリクスはコンパイラによって 実際の型に合わせたコードが展開される ため、リフレクションを多用するよりも原則的にパフォーマンスが良いと言われています。

  • リフレクションの動的コスト
    • reflect.ValueOf(...)val.Kind() の呼び出しがループ内で頻繁に行われると、何倍ものオーバーヘッドがかかるケースがある。
  • ジェネリクスではコンパイル時の展開
    • 実行時に動的な型判定をしなくて済む分、最適化が効きやすい。
    • 特に数百万回呼び出すような箇所では数倍の差が出ることも。

2-4. コンパイル時に型を“制限”できる

Goには constraints パッケージ ( golang.org/x/exp/constraints ) や、開発者自身が定義したインターフェイスで、型パラメータに条件を課す仕組みがあります。これにより、ジェネリクス関数で「数値だけ」「比較可能な型だけ」など、より厳格な型指定が可能です。

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

func MaxOf[T constraints.Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}
  • constraints.Ordered
    • 比較演算子 (<, >, == など) が使える型 ( 数値、文字列など ) 。
    • 「a > b」がコンパイルエラーにならないよう、T を制約している。
  • 自由度の確保と安全性の両立
    • まったく制限なしに any とすると、メソッドを呼び出す時点でエラーになりがち。
    • 適切に制約を書くことで可読性も高まり、コンパイル時エラーのヒントが増える。

2-5. まとめ : Goジェネリクスならではのメリット

  1. 重複コードを削減
    • 同じ処理を型別に書かなくてもよくなるので、保守コストが下がる。
  2. 型安全性が高い
    • interface{} と違って、誤った型を渡すとコンパイルエラーになるため実行時パニックが減る。
  3. パフォーマンス面でも優位
    • 実行時に動的判定をしないため、大規模ループなどでの速度低下が抑えられる。
  4. 制限機能 ( constraints )
    • 「数値型だけ」「比較可能な型だけ」など、細かなコントロールがコンパイル時に行える。

これらの特長から、Go 1.18以降は「まずジェネリクスで書けるか検討してみる」のが定番の流れになりつつあります。一方で、後述するように リフレクション ほど動的な操作ができない部分もあるので、適材適所で使い分けるのがベストです。

3. 簡単なコード例 : Goでジェネリクスをどう書く?

3-1. まずはジェネリクス構文を見てみよう

package main

import (
	"fmt"

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

// AddAll: スライス要素をまとめて加算し、合計を返す
// [T constraints.Integer | constraints.Float] で T は整数型または浮動小数点型
func AddAll[T constraints.Integer | constraints.Float](nums []T) T {
	var sum T
	for _, n := range nums {
		sum += n
	}

	return sum
}

func main() {
	// int のスライスに使う
	intResult := AddAll([]int{1, 2, 3, 4})
	// float64 のスライスに使う
	floatResult := AddAll([]float64{1.1, 2.2, 3.3})
	// 出力
	fmt.Println(intResult, floatResult)
}

ポイント解説

  1. 型パラメータの定義 : [T constraints.Integer | constraints.Float]

    • Tconstraints.Integer (int, int8, int32, など) か constraints.Float (float32, float64) のどちらかである、とコンパイラに教えている。
    • これにより、sum += n の箇所で + 演算が有効だとコンパイル時に判断される。
  2. AddAll が複数の型に対応できる

  • []int{1,2,3,4} として呼べば、コンパイラが「T は int」用に展開する。
  • []float64{1.1, 2.2, 3.3} として呼べば、「T は float64」として展開される。
  • それぞれ最適化され、実行時の型変換やリフレクションは不要。
  1. 呼び出し時に型パラメータを明示しなくてもOK
  • 例えば AddAll[int]([]int{1,2,3,4}) と書くこともできるが、Goコンパイラは引数の型から推論できるため、省略が可能。
  • 呼び出しがシンプルになる利点がある。

3-2. なんで constraints.Integer | constraints.Float を使うの?

  • 別の書き方 : constraints.Ordered
    • constraints.Ordered<, >, <=, >= が使える型 ( 数字や文字列など ) をまとめた制約です。
    • + 演算を使う場合には、 constraints.Integer | constraints.Float のように「算術演算が可能な型」を定義することで、より明確なコードにできる。
  • 自作のインターフェイス制約もOK
    • より複雑なルールが必要なら、自分で type Numeric interface { ~int | ~float64 } のようなインターフェイスを定義し、型パラメータに指定する。
    • 柔軟性が高く、複数の型にまたがるロジックをまとめやすい。

3-3. 実行してみる

% go run main.go

出力例 :

10 6.6
  • intResultint スライス [1,2,3,4] の合計 → 10
  • floatResultfloat64 スライス [1.1, 2.2, 3.3] の合計 → 6.6

3-4. この例から学べること

  1. ジェネリクスを使えば“同じロジック”を一度書くだけで複数の型をサポート
    • 以前は SumIntsSumFloats のように関数を分けて書く必要があったが、いまは AddAll 一つで済む。
  2. 型安全性が保たれる
    • constraints.Integer | constraints.Float で、誤って stringbool を渡そうとするとコンパイルエラーになる。
  3. パフォーマンスにも好影響
    • リフレクションを使わずに済むため、大規模な繰り返し処理でも速度低下が抑えられやすい。

4. 初心者がつまずきやすいポイント

4-1. 型パラメータの書き方が分かりにくい

Goのジェネリクスでは、関数名の後ろに [...] という形で型パラメータの宣言を記述します。たとえば :

func MyFunc[T any](v T) {
    // ...
}
  • [T any] って何?」
    • T は「型変数」を示し、 any は「どんな型でもいい」という制約を表します。
    • any はかつての interface{} とほぼ同義です。
  • 慣れないと一瞬 “難しそう” に感じる
    • 慣れると「括弧の中身で T の制約を指定しているんだな」という程度に読み解けますが、初見では「なぜ [] があるの?」と戸惑いがち。

対処法

  • 他の言語 ( C++/Javaなど ) のジェネリクスを少しでも知っていると理解しやすいかもしれません。
  • 最初は“書き写してみる” だけでも、徐々にパターンがつかめてきます。

4-2. コンパイラエラーのメッセージが分かりづらい

Goのジェネリクス実装は比較的新しく、エラーメッセージが直感的ではない場合があります。たとえば、制約に反した型を渡すと以下のようなエラーが出ることがあります。

cannot use myString (variable of type string) as type T in argument to MyFunc:
string does not implement T (constraint ... <some complicated text> ...)
  • このメッセージは何を意味している?
    • myStringT (指定した型制約) を満たしていない」という意味です。
    • 制約が数値型だけの場合、 string を渡そうとしたらコンパイル時に弾かれる。
  • どこを直せばよいか把握する
    • 制約をゆるめる/引数の型を変えるなど、どちらかが意図と合っていない。エラーメッセージは長いですが、「does not implement T」付近を読んで解決策を探すとよい。

対処法

  • エラーメッセージを逐行で分解して読む
    • 最後の行付近に「コンパイラが期待している型」と「実際に渡した型」の違いが書かれていることが多い。
  • 型制約 ( constraints ) を再確認
    • constraints.Integer だけにしているのに浮動小数点を渡そうとしていないか、など。

4-3. ジェネリクスを使いすぎるとコードが複雑化

ジェネリクスは強力ですが、コードが大規模になると型パラメータの嵐になり、かえって可読性を落とす恐れがあります。たとえば、複数の型パラメータを持った関数をネストすると、以下のように見づらいコードが生まれがちです。

func MergeMaps[K comparable, V1, V2 any](m1 map[K]V1, m2 map[K]V2) map[K]struct{ Val1 V1; Val2 V2 } {
    // ...
}
  • 何が起きる?
    • パラメータが増えるほど、「関数名の後ろに並ぶ制約リスト」や「複数の型パラメータ」が大変な見た目に。
  • 従来のインターフェイスや具体的型で十分な場合も
    • ジェネリクスで抽象化しすぎると、現場の要件が実はもっと限定的だった…ということも。
    • 過剰なジェネリクスは「いったい何の型が来るのかわかりにくい」「調整が難しい」という声もある。

対処法

  • 本当に汎用化が必要な箇所だけジェネリクスに
    • あまり複雑な制約やパラメータが必要なら、逆にインターフェイス分けのほうが素直なこともある。
  • 命名とコメントを充実させる
    • 型パラメータに K, V1, V2 のような略称だけでなく、コメントなどで「何を表す型なのか」を書いて可読性を保つ。

4-4. constraints って何? どこで使うの?

Go公式ではまだ標準ライブラリに広範囲のジェネリクス向け制約が用意されていません。その代わり golang.org/x/exp/constraints という実験的パッケージが存在し、以下のような制約を定義しています。

  • constraints.Integer
    • int, int8, uint8, など、あらゆる整数型を示す。
  • constraints.Float
    • float32, float64 など、浮動小数点型を示す。
  • constraints.Ordered
    • 比較演算子 (<, >, <=, >=) が使える型 ( 数値と文字列 ) を表す。

学習時の注意

  • 「標準ライブラリにないの?」

    • 現状、正式には exp パッケージ扱いで「実験的」に提供されているが、多くの開発現場で使われ始めている。
    • 今後のGoバージョンアップで、より標準的な形で整備される可能性がある。
  • 自作インターフェイスも使える

    • 自分のプロジェクトで「この型だけ許容したい」ときは、以下のようにインターフェイスを定義するのもアリ。
    type MyNumeric interface {
        ~int | ~float64
    }
    

4-5. まとめ : つまずきを防ぐためのポイント

  1. 最初はシンプルな例から
    • まずは func Foo[T any](x T) {} のように書いてみて、ジェネリクスの基礎文法に慣れる。
  2. エラーメッセージは焦らず読む
    • 「コンパイラがどの制約と合わないと言っているのか?」を丁寧に確認し、制約を再点検する。
  3. 必要以上に複雑な型パラメータを使わない
    • ジェネリクスはあくまで道具の一つ。インターフェイスや具体的な型で十分ならそちらを検討する。
  4. constraints パッケージや自作インターフェイスで制約を明示する
    • “何の型を想定しているか” が明確になると、可読性が上がり、エラー対応もしやすい。

これらの点を押さえておけば、ジェネリクスを使い始めても大きく迷うことは減るはずです。次のセクションでは、リフレクションや interface{} との比較など、ジェネリクスならではの利点をさらに掘り下げていきます。

5. 過去の書き方 ( interface{} ) やリフレクションとの比較

5-1. interface{} を使って型の違いを吸収していた時代

Go 1.18 以前、複数の型 ( たとえば intfloat64 ) をひとまとめに扱うためには、以下のように interface{} を使うしかありませんでした。

package main

import "fmt"

func SumAllAny(nums []any) any {
	var sum float64
	for _, v := range nums {
		// ここで型アサーションが必要
		switch vv := v.(type) {
		case int:
			sum += float64(vv)
		case float64:
			sum += vv
		// 他の数値型があるなら case を追加
		default:
			// 数値じゃないと困る
		}
	}

	return sum
}

func main() {
	// 使う際に []any を作るのが手間
	data := []any{1, 2.2, 3}
	result := SumAllAny(data)
	// result は any なので型アサーションが必要
	switch r := result.(type) {
	case float64:
		fmt.Println(r)
	default:
		// 数値じゃないと困る
	}
}

ここでの問題点

  1. 煩雑な型アサーション
    • v.(type) で各ケースを細かく分岐しないと処理が書けない。
    • 型を追加するたびに case を増やす必要があるため、メンテナンス性が悪い。
  2. 安全性が低い
    • もし string が混ざっていても、コンパイル時には検出できず、実行時になって panic する可能性がある。
    • 関数の戻り値も any なので、呼び出し側で「実際は float64 なんだっけ?」と改めて型アサーションが必要。
  3. パフォーマンス
    • 型アサーションは実行時に動的チェックを行うため、大規模ループだと速度低下を起こしがち。

5-2. リフレクションを使った高度な ( しかし大変な ) 方法

一方、「複数の型に対応した汎用的な処理」を書くもう1つの方法がリフレクションです。

package main

import (
	"fmt"
	"reflect"
)

func SumAllReflection(slice any) float64 {
	rv := reflect.ValueOf(slice)
	if rv.Kind() != reflect.Slice {
		panic("not a slice")
	}

	var sum float64
	for i := 0; i < rv.Len(); i++ {
		elem := rv.Index(i)
		switch elem.Kind() {
		case reflect.Int:
			sum += float64(elem.Int())
		case reflect.Float64:
			sum += elem.Float()
		default:
			// 他の数値型が来たらどうするのかを考えなくてはいけない
		}
	}

	return sum
}

func main() {
	slice := []int{1, 2, 3, 4, 5}
	sum := SumAllReflection(slice)
	fmt.Println(sum)
}

ここでの問題点

  1. 動的チェックがさらに増える
    • スライスかどうか、要素が int か float64 か、などを reflect.Value で細かく確認。
    • 間違っている場合はすべて実行時エラーが発生したり、無視したりという挙動になってしまう。
  2. パフォーマンスのオーバーヘッド
    • リフレクションは内部的に型情報やメモリアドレスを逐次調べるため、大規模ループでは顕著に遅くなる場合がある。
  3. コードが分かりにくい
    • フィールドアクセスや要素操作が reflect.Value 経由になり、初心者にはハードルが高い。
    • コンパイル時の型チェックが効かないため、可読性や保守性に大きなコストがかかる。

5-3. ジェネリクスならどう変わる?

package main

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

func SumAll[T constraints.Integer | constraints.Float](nums []T) T {
	var sum T
	for _, n := range nums {
		sum += n
	}

	return sum
}

func main() {
	nums := []int{1, 2, 3}
	println(SumAll(nums))
}

  1. コンパイル時に型が決定
    • Tint であれば int 用の加算コードとしてコンパイルされ、 float64 なら float64 用になる。
    • 実行時の動的チェック ( 型アサーション・リフレクション ) は不要。
  2. 型安全
    • 間違って stringbool を渡そうとしてもコンパイル時エラーになる。
    • 呼び出し側も戻り値が T ( 具体的には intfloat64 ) になるので、型アサーション不要。
  3. パフォーマンスが期待できる
    • 大規模ループでもリフレクションほどのオーバーヘッドはかからない。
    • 実際のプロジェクトで数値を計算するような箇所では、数倍~数十倍の速度差につながるケースもある。

5-4. 結果 : ジェネリクスが推奨される場面が増えた

Go 1.18 以降、

  • 「型が事前に推定可能な汎用処理」
    • 例 : 複数の数値型 ( int, float64 ) で同じロジックを回したい。
    • ジェネリクス が最適解となることが多い ( コードが短く、可読性と性能を両立 ) 。
  • 「実行時に型が本当に分からない処理」「未知の構造体のフィールド操作」
    • 例 : 動的にJSONタグを読み取る、ユーザ定義型を走査するなど。
    • リフレクションが必要になる場面も依然として残る。ジェネリクスだけでは代替しにくい「動的操作」の分野。

どの方法を選ぶか?

  1. ジェネリクス
    • 型が明確・複数の型に対応した共通処理が必要 → まず検討。可読性と性能が良好。
  2. インターフェイス ( interface{} )
    • ある特定の振る舞いを定義したい → インターフェイスが適切。
    • しかし「何でも受け取れる interface{}」はほぼ廃止推奨に近い立ち位置へ ( any やジェネリクスで代替 ) 。
  3. リフレクション
    • 「構造体タグの解析」「ランタイムの動的型情報を利用した操作」など → 未知の型を扱う場面で力を発揮。
    • 代わりにパフォーマンスや可読性に注意が必要。

5-5. まとめ : ジェネリクスならではの恩恵を活かそう

  • 以前のGoでも書けたが、煩雑&非型安全だった
    • interface{} やリフレクションでの実装は、実行時エラーやパフォーマンス低下がネックに。
  • ジェネリクス導入で大幅に改善
    • 同じロジックの使い回し、コンパイル時の型チェック、実行速度の向上が期待できる。
  • 目的に応じて使い分ける
    • 汎用計算・同様の処理を複数の型で走らせたい → ジェネリクス。
    • 実行時の完全な動的操作 → リフレクション。
    • 従来のインターフェイスは特定のメソッドセットを定義するのに使いつつ、“何でも受け取る”用途はジェネリクスへ移行する流れが主流に。

ここまでで、ジェネリクス導入による変化や利点が一層明確になったかと思います。もちろん、実際のプロジェクトでどれだけ恩恵を受けられるかはケースバイケースですが、 「同じコードを型安全に複数の型へ展開したい」 と感じたら、ジェネリクスを第一候補に検討するのがおすすめです。

6. まとめ : まずは簡単な関数から試してみよう

ジェネリクスは、Go 1.18以降で利用できる強力な機能です。最初は文法がやや独特に見えますが、 型安全性・コードの重複削減・パフォーマンス面 など、得られるメリットは非常に大きいです。

  1. 小さな実験から始める
    • 例 : 数値スライスの合計、最大値を求める関数など、短く書けるジェネリクス関数を作ってみる。
  2. エラー時は制約や型推論を再確認
    • 「どんな型が来る想定か」「実際に渡す型は何か」を意識すれば、エラーメッセージの読み方も自然と分かる。
  3. 使いすぎに注意
    • スコープを絞って利用しないと、コードが複雑化してかえって読みづらくなることも。

これらのポイントを踏まえれば、ジェネリクスは初学者でも十分使いこなせます。 まずは簡単な関数で“型パラメータ”の使い方に慣れ 、より複雑な場面へステップアップしていってみてください。

Discussion