📚 背景知識(読んでから問題へ)
Day 022ではfunc Map[T, U any](s []T, f func(T) U) []Uのような、型パラメータの基本文法を扱いました。any(interface{}のエイリアス)は「どんな型でも受け入れる」制約であり、関数内部では受け取った値に対して代入・引き回し以外の操作(比較・加算など)はできません。今回のテーマは、その先にある「型のどんな性質を要求するか」を自分で設計するステップです。
Go 1.18で導入されたジェネリクスの核心的なアイデアは、制約として使うインターフェースは「メソッドの集合」ではなく「型の集合(type set)」を表す、という再定義です。
type Numeric interface {
~int | ~int64 | ~float32 | ~float64
}
このNumericは「int型そのもの、またはintを基底型とするすべての型、あるいはint64型そのもの、または...」という型の集合を表します。ポイントは~(チルダ、"approximation element")です。
type Meters float64 // 基底型はfloat64
var m Meters = 3.0
~を付けずにinterface { float64 }と書いた場合、この型集合にはfloat64という厳密にその型のみが含まれ、Meters(基底型はfloat64だが別の名前付き型)は含まれません。~float64と書くことで「基底型がfloat64であるすべての型」を型集合に含められます。実務で独自の数値型(type Yen int64、type Celsius float64など)を扱うジェネリック関数を書く場合、この~がないと自分で定義した型を渡せない、という落とし穴にすぐ遭遇します。
さらにGoの制約インターフェースは、型要素(type element)とメソッド要素(method element)を組み合わせることもできます。「特定の型集合に属し、かつ特定のメソッドを持つ」という複合的な制約を1つのインターフェースで表現できるのが、Javaの型消去(type erasure)ベースのジェネリクスや、単純な「メソッドを持っているか」だけを見るGoの通常のインターフェースとは異なる、type setというモデル独自の表現力です。
cmpパッケージにcmp.Orderedという順序付け可能な型の制約と、組み込み関数としてmin・maxが追加されました。実務では「順序比較したいだけ」ならこれらの標準機能を使うのが第一選択です。しかし本日の問題では、type setの仕組み自体を手を動かして理解するために、あえて自分で制約を設計します。📝 問題
以下の5つの問いに答えてください。(2)(3)はgo run main.goでそのまま実行できる完全なコードを提示すること。(Go 1.21以降を前提とします。go versionで確認してください)
- 「型集合(type set)」とは何か、そして
~(チルダ)が型集合に与える意味の違いを、type Meters float64を例に説明してください。 - 整数系・浮動小数点系のすべての基本型(
~付き)を含む独自の制約Numericを定義し、Sum[T Numeric](nums []T) T(合計値を返す)を実装してください。intのスライスと、独自定義したtype Yen int64のスライスの両方で正しく動作することをmain関数で確認してください。 Numericを使ってMax[T Numeric](nums []T) T(最大値を返す。空スライスはpanicでよい)を実装してください。実装の中で比較演算子>がジェネリック関数の中でどのように扱われているか(Goの仕様上、型集合に含まれるすべての型が順序付け可能な組み込み型である場合にのみ<>などの演算子がジェネリック関数内で使える、という制約)に触れてください。~を付けずにtype StrictNumeric interface { int | float64 }と定義した場合、type Score intという独自型のスライスをSum[T StrictNumeric]のような関数に渡すとどうなるか、実際に何が起きるか(コンパイルエラーの内容)を説明してください(このケースは実際にコンパイルが通らないため、コメントアウトしたコードとエラー内容の記述で示してください)。- Go 1.21の標準ライブラリ
cmp.Ordered・組み込みmin/maxと、今回自作したNumeric/Maxを比較し、実務でどちらを使うべきか(あるいは自作の制約がなお必要になる場面はどこか)を設計判断として述べてください。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
Go言語仕様の「Interface types」の章では、インターフェース型は「メソッド要素」と「型要素(type term)」の両方を持てると定義されています。型要素だけからなるインターフェースは、通常の値としては使えず(型として実体化できない)、型パラメータの制約としてのみ使用できます。~Tという記法は「基底型(underlying type)がTであるすべての型」を意味します。
ヒント2 — アプローチ
Numericの型集合には、int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, uintptr, float32, float64をすべて~付きの|区切りで並べます(実務では用途に応じて整数系だけ、浮動小数点系だけに絞ることも多いですが、今回は網羅的に定義してみましょう)。SumとMaxはどちらもfor rangeでループしながらT型の変数に対して+や>を使うだけで実装できます。ジェネリック関数内でこれらの演算子が使えるのは、型集合内のすべての型が組み込み型として+や>をサポートしているためです(型集合にメソッドしか持たない型や演算子非対応の型が混ざっていると、コンパイラは演算子の使用を許しません)。
ヒント3 — コード骨格
package main
import "fmt"
type Numeric interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64
}
func Sum[T Numeric](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
type Yen int64
func main() {
ints := []int{1, 2, 3}
fmt.Println(Sum(ints))
yens := []Yen{100, 200, 300}
fmt.Println(Sum(yens))
}
✅ 模範解答
package main
import "fmt"
// Numeric はすべての整数系・浮動小数点系の組み込み型(およびそれらを基底型とする
// 独自の名前付き型)を表す型集合。~ を付けることで、type Yen int64 のような
// 独自定義型もこの制約を満たせるようになる。
type Numeric interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64
}
// Sum は Numeric 制約を満たす任意の型のスライスの合計を返す。
func Sum[T Numeric](nums []T) T {
var total T
for _, n := range nums {
total += n // T が Numeric に属することをコンパイラが保証しているため + が使える
}
return total
}
// Max は Numeric 制約を満たす任意の型のスライスから最大値を返す。
// 空スライスは呼び出し側の誤りとみなし panic する。
func Max[T Numeric](nums []T) T {
if len(nums) == 0 {
panic("Max: empty slice")
}
max := nums[0]
for _, n := range nums[1:] {
if n > max { // > も Numeric に含まれるすべての型が組み込みの順序付けをサポートするため使える
max = n
}
}
return max
}
// Yen は独自の通貨型。基底型は int64。
type Yen int64
// Celsius は独自の温度型。基底型は float64。
type Celsius float64
func main() {
fmt.Println("--- Sum: 組み込み型 ---")
ints := []int{1, 2, 3, 4, 5}
fmt.Println("Sum(ints) =", Sum(ints))
fmt.Println("\n--- Sum: 独自の名前付き型(~ がなければコンパイルできない) ---")
yens := []Yen{1000, 2500, 300}
fmt.Println("Sum(yens) =", Sum(yens))
fmt.Println("\n--- Max: 独自の名前付き型 ---")
temps := []Celsius{18.5, 22.0, 15.3, 30.1, 9.9}
fmt.Println("Max(temps) =", Max(temps))
fmt.Println("\n--- Max: 組み込み型 ---")
scores := []int{72, 95, 60, 88}
fmt.Println("Max(scores) =", Max(scores))
}
実行結果(実測。go1.21以降で確認済み):
--- Sum: 組み込み型 ---
Sum(ints) = 15
--- Sum: 独自の名前付き型(~ がなければコンパイルできない) ---
Sum(yens) = 3800
--- Max: 独自の名前付き型 ---
Max(temps) = 30.1
--- Max: 組み込み型 ---
Max(scores) = 95
(4) ~を外すとコンパイルできない例:
// この定義は「型要素のみからなるインターフェース」で、型パラメータの制約としてのみ使用可能。
type StrictNumeric interface {
int | float64 // ~ を付けていない = int, float64 という「型そのもの」しか型集合に含まない
}
func StrictSum[T StrictNumeric](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
type Score int // 基底型は int だが、Score という別の名前付き型
func main() {
scores := []Score{80, 90, 70}
fmt.Println(StrictSum(scores)) // ← ここでコンパイルエラー
}
このコードをgo buildすると、以下のようなエラーになります(Goのバージョンによって文言は多少異なります)。
./main.go:XX:20: Score does not implement StrictNumeric (possibly missing ~ for int in constraint StrictNumeric)
コンパイラのエラーメッセージ自体が「~を付け忘れていませんか」と示唆してくれる点は、Goのツールが誤りに気づきやすいようにする設計の表れです。Scoreの基底型は確かにintですが、StrictNumericの型集合にはintという型そのものしか含まれておらず、Scoreという別名の型はその集合の要素ではないため、型パラメータTをScoreとして実体化する際の制約チェックに失敗します。
🪜 Step-by-Step 解説
Go 1.18以前、インターフェースは常に「メソッドの集合」でした。ジェネリクス導入後は、インターフェースはより一般的に「型の集合(type set)」として定義され、メソッドの集合はその特殊ケース(「そのメソッド群を実装するすべての型」という型集合)と位置づけられました。
Numericのように型要素(~intなど)を|で並べたインターフェースは、通常の変数の型としては使えず、型パラメータの制約としてのみ意味を持ちます。
~intは「基底型がintであるすべての型」の集合を表します。int自身はもちろん、type Score intのような独自定義型もこの集合に含まれます。~を付けないintは「intという型そのもの」だけを指す、より狭い集合です。したがって独自の名前付き型を許容したい制約には、基本的に~を付けるのが定石です。
total += nやn > maxのような演算子がジェネリック関数の中で使えるのは、型パラメータの制約となる型集合に含まれる「すべての」型がその演算子を組み込みでサポートしているとコンパイラが判断できる場合に限られます。Numericの型集合はすべて数値型なので+も>も安全に使えますが、もし型集合にstringを混ぜていた場合、+(文字列結合)は使えても意味が数値の合計とは変わってしまいますし、複数の異なる演算セマンティクスを持つ型を1つの制約に混在させると、関数の中で「その演算が本当に意図通りか」の判断が難しくなります。
Step 4の
StrictNumericの例のように、~を付け忘れた制約に独自定義型を渡すと、コンパイル時にエラーとして検出されます。実行時に気づくのではなくコンパイル時に、しかもコンパイラ自身が「~が足りないのでは」と示唆する形で検出される点は、Goの「エラーは早く・分かりやすく検出する」という設計哲学の延長線上にあります。
Go 1.21の
cmp.Orderedは「比較演算子<が使える型」という一点に特化した制約であり、min/maxという組み込み関数と組み合わせれば、単純な最大値・最小値取得は自作の制約なしで完結します。しかし今回のNumericのように「加算もできる」「特定の演算のみ許可したい」「複数のドメイン固有型(Yen, Celsiusなど)を横断して扱いたい」といった、標準ライブラリがカバーしない独自の意味的なグルーピングが必要な場面では、自分で制約を設計する価値があります。
💡 設計思想・なぜこう書くのか
anyで何でも受け取れるようにするのではなく、関数が本当に必要とする性質(加算できる・比較できる、など)だけを型集合として明示することで、呼び出し側にとっても「この関数にどんな型を渡せるか」がシグネチャを読むだけで分かるようになります。List<Integer>もList<String>も単なるListになります。Goのジェネリクスは型消去ではなく、コンパイラが型パラメータごとに(あるいは類似のメモリレイアウトを持つ型をまとめて)具体的なコードを生成する方式(monomorphization寄りの実装)を採ります。この違いにより、Goでは型パラメータに対して組み込み演算子を直接使えるという、Javaでは実現できない表現力が生まれています。🌐 他言語との比較
| 言語 | 同等の概念 | Goとの違い |
|---|---|---|
| Java | 境界型パラメータ(<T extends Number>) | Javaの境界はクラス階層・インターフェース実装に基づく「継承関係」の制約であり、型消去により実行時には型情報が失われる。Goのtype setは継承関係を前提とせず、「このリテラル型の集合に属するか」という構造的な制約で、コンパイル時に型ごとの具体的なコードが生成される |
| TypeScript | ジェネリック制約(<T extends string | number>) | TypeScriptのユニオン型制約は概念的にGoの型集合に近いが、TypeScriptは構造的部分型付け言語であり実行時には型情報自体が消える(コンパイル時のみの静的解析)。Goは型パラメータの制約チェックも生成コードもコンパイル時に確定する点は近いが、~のような基底型ベースの近似は他言語にあまり見られない独自の概念 |
| C++ | Concepts(C++20, requires) | C++のConceptsはテンプレートパラメータが満たすべき式・操作の集合を宣言的に書ける点でGoのtype setに近い思想だが、C++はテンプレートのインスタンス化ごとに完全に別のコードを生成する(真のmonomorphization)。Goはコンパイラの裁量でコード生成を最適化でき、C++ほど極端なバイナリサイズの増大は起きにくい |
| Rust | トレイト境界(T: Add + PartialOrd) | Rustのトレイト境界は「このトレイトを実装しているか」というメソッドベースの制約が中心で、+のような演算子もすべてstd::ops::Addなどのトレイトとして表現される。Goのtype setは演算子を型の集合に対して直接組み込みで許可する方式で、Rustほど演算子オーバーロードの仕組みを明示的にトレイトとして書く必要がない分、独自の演算子的振る舞いを型に持たせる自由度はRustより低い |
🏆 実務での使いどころ
- 金額計算ユーティリティ:
Yen,USDのようなドメイン固有の数値型(type Yen int64など、単位の取り違えをコンパイラに検出させるための独自型)を横断して使えるSum・Average関数を1つの実装で提供できる - ジェネリックなキャッシュ・リポジトリ層:
type ID interface { ~int | ~int64 | ~string }のような制約で、複数のエンティティ型(数値IDのものも文字列IDのものも)に対応する汎用リポジトリのキーの型を制約できる - 数値集計・統計処理ライブラリ: 標準ライブラリの
cmp.Orderedだけでは表現できない「加算可能」「ゼロ値が意味を持つ」といった要件を、独自のNumeric系制約として社内共通ライブラリに切り出す
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
int | float64のように~を付けずに定義し、独自の名前付き型を渡そうとしてコンパイルエラーに悩む | ジェネリクス学習の初期は「型がその型かどうか」で判断すると考えがち | 独自の名前付き型(type Yen int64等)を許容したい場合は、基本的に基底型ベースの~Tを使うのが定石 |
Numericのような制約にstringを混ぜて、+が文字列結合として動いてしまうことに気づかない | Goの+演算子が数値加算と文字列結合の両方で使えることを忘れる | 型集合に異なる演算セマンティクスを持つ型を混在させると、関数内の演算の意味が曖昧になる。制約は意味的にまとまりのある型のグループに絞る |
何でもNumericのような広い制約を自作し、標準ライブラリのcmp.Orderedや組み込みmin/max(Go 1.21+)の存在を見落とす | ジェネリクス学習中は「自分で制約を書く」こと自体が目的化しやすい | 単純な順序比較だけなら標準ライブラリで十分。自作の制約は、標準ライブラリがカバーしない独自の意味的グルーピングが必要な場合にのみ検討する |
🚀 次のステップ
- 発展:
Numeric制約に~complex64 | ~complex128を追加した場合、Max関数の>演算子がどうなるか実際に試してみましょう(複素数は順序付けできないため、コンパイルエラーになります)。制約に含まれる型のうち一部でも特定の演算子をサポートしない型が混ざると、コンパイラがどう反応するかを体感できます - 次回予告: gRPC/Protocol Buffers実装 — サービス定義から生成したコードを使い、実際にクライアント・サーバー間の通信を実装する問題を扱います