📚 背景知識(読んでから問題へ)
Day 021では、embeddingが「コード再利用の道具」であり「型階層を作る道具」ではないことを学びました。しかし embedding には限界があります。MapやFilterのような「型に関係なく同じロジックを繰り返したい」処理には向きません。[]int用のSumInt、[]float64用のSumFloat64……と型ごとに関数を複製するか、interface{}(any)を使って型安全性を諦めるかの二択しかなかったのが、Go 1.17以前の状況でした。
Go 1.18で導入されたジェネリクス(型パラメータ)は、この問題を「型安全性を保ったままコードを1つにまとめる」形で解決します。
func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}
[T, U any]が型パラメータリストです。関数名・型名の直後に[]で型パラメータを宣言し、その後ろの通常の引数・戻り値で型パラメータを使います。anyはinterface{}のエイリアスで、「どんな型でもよい」という最も緩い制約です。呼び出し側はMap(nums, func(n int) int { return n * 2 })のように書けば、コンパイラが型引数T=int, U=intを型推論するため、明示的にMap[int, int](...)と書く必要は通常ありません。
制約(constraint)はインターフェースとして表現します。anyより厳しい制約が必要な場合、例えば「数値型のみ許可し、+演算子で合計を取りたい」場合は、独自の制約インターフェースを定義します。
type Number interface {
~int | ~int64 | ~float64
}
|は「型の集合(union)」を意味し、「intまたはint64またはfloat64のいずれか」を表します。~intの~(チルダ)は「基底型(underlying type)がintであるすべての型」を意味します。これがないと、type Celsius intのような独自の名前付き型は、基底型がintであってもNumber制約を満たせません(Goの型システムでは名前付き型と元の型は別物として扱われるため)。
ジェネリクスは関数だけでなく、型(構造体)にも使えます。Stack[T any]のように書くと、任意の型Tのスタックを1つの実装で表現できます。これは「型ごとにIntStack, StringStackを書く」「interface{}で受けて呼び出し側でキャストする(実行時パニックのリスクを持つ)」という2つの悪い選択肢を、コンパイル時の型チェック付きで置き換えます。
📝 問題
汎用的なスライス操作ユーティリティと、ジェネリックなスタック型を実装してください。
Part A: MapとFilter
func Map[T, U any](s []T, f func(T) U) []Uを実装すること。sの各要素にfを適用した結果のスライスを返す。func Filter[T any](s []T, pred func(T) bool) []Tを実装すること。predがtrueを返す要素だけを残したスライスを返す。
Part B: 制約付きジェネリクス
Number制約インターフェースを定義すること。int,int64,float64のいずれかの基底型を持つ型を許可する(~を使うこと)。func Sum[T Number](s []T) Tを実装すること。スライスの合計を返す。
Part C: ジェネリックなスタック型
Stack[T any]構造体を定義すること(内部は[]Tのスライスで構わない)。- メソッド
Push(v T)(末尾に追加)、Pop() (T, bool)(末尾を取り出す。空ならTのゼロ値とfalse)、Len() int(要素数)を実装すること。
Part D: 制約の型安全性を実際に確認する
type Celsius float64という独自の名前付き型を定義し、Sum([]Celsius{...})がコンパイルできることを確認すること(~float64により許可される)。- 逆に
Sum([]string{"a", "b"})のような呼び出しはコンパイルエラーになることを、コメントで示すこと(実際にそのコード行はコメントアウトした状態で「これはコンパイルできない」という注記を残すこと。誤ってコンパイルエラーのコードをそのまま含めない)。
すべてgo runまたはgo testでそのまま動作するコードとして提示すること。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
Day 021で見た「embeddingでは型ごとにコードを書き分けるしかない」という限界を思い出してください。ジェネリクスは「型を後から決めるプレースホルダー」を関数・型の定義に埋め込む機能です。まずはany制約(何でもあり)から始めて、MapとFilterを書き、それから「何でもありだと+のような演算子が使えない」という壁にぶつかってからNumber制約に進むと理解が早いです。
ヒント2 — アプローチ
- 型パラメータリストは関数名の直後、丸括弧
()の前に[T any]のように書きます - 複数の型パラメータはカンマ区切り:
[T, U any](TとU両方がany制約という意味) - ジェネリック構造体のメソッドは、レシーバ側にも型パラメータ名を書く必要があります:
func (s *Stack[T]) Push(v T)(メソッド定義時に新しい型パラメータを導入することはできない点に注意) - 制約インターフェースの
~int | ~int64 | ~float64は、通常のインターフェースのメソッドセットではなく「許可する型の集合」を表す特殊な構文です Sumの中で合計を保持する変数はvar total Tのようにゼロ値から始めます(Tがintならゼロ、float64ならゼロ)
ヒント3 — コード骨格
package main
import "fmt"
func Map[T, U any](s []T, f func(T) U) []U {
// TODO
}
func Filter[T any](s []T, pred func(T) bool) []T {
// TODO
}
type Number interface {
// TODO: ~int | ~int64 | ~float64
}
func Sum[T Number](s []T) T {
// TODO
}
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
// TODO
}
func (s *Stack[T]) Pop() (T, bool) {
// TODO
}
func (s *Stack[T]) Len() int {
// TODO
}
type Celsius float64
func main() {
nums := []int{1, 2, 3, 4, 5}
fmt.Println(Map(nums, func(n int) int { return n * 2 }))
fmt.Println(Filter(nums, func(n int) bool { return n%2 == 0 }))
fmt.Println(Sum(nums))
temps := []Celsius{10.5, 20.0, -3.5}
fmt.Println(Sum(temps))
var st Stack[string]
st.Push("a")
st.Push("b")
v, ok := st.Pop()
fmt.Println(v, ok, st.Len())
}
✅ 模範解答
ファイル: main.go
package main
import "fmt"
// ── Part A: Map と Filter ──
// Map は s の各要素に f を適用した結果のスライスを返す。
// T と U は異なる型でもよい(例: []int -> []string)。
func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}
// Filter は pred が true を返す要素だけを残したスライスを返す。
func Filter[T any](s []T, pred func(T) bool) []T {
result := make([]T, 0, len(s))
for _, v := range s {
if pred(v) {
result = append(result, v)
}
}
return result
}
// ── Part B: 制約付きジェネリクス ──
// Number は int/int64/float64、またはそれらを基底型に持つ独自の名前付き型を許可する。
// ~ (チルダ) がないと Celsius のような独自型は満たせない。
type Number interface {
~int | ~int64 | ~float64
}
// Sum はスライスの合計を返す。T は Number 制約を満たす型に限定される。
func Sum[T Number](s []T) T {
var total T // T のゼロ値(int なら 0, float64 なら 0.0)から開始
for _, v := range s {
total += v
}
return total
}
// ── Part C: ジェネリックなスタック型 ──
// Stack は任意の型 T の要素を保持するLIFOスタック。
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
// Pop は末尾の要素を取り出す。空なら T のゼロ値と false を返す。
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
last := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return last, true
}
func (s *Stack[T]) Len() int {
return len(s.items)
}
// ── Part D: 制約の型安全性 ──
// Celsius は基底型が float64 の名前付き型。
// ~float64 のおかげで Number 制約を満たし、Sum([]Celsius{...}) がコンパイルできる。
type Celsius float64
// 【コンパイルできない例】以下はコメントアウトしたまま残す。
// string は Number 制約(~int | ~int64 | ~float64)を満たさないため、
// コメントを外すと「string does not implement Number」という
// コンパイルエラーになる。
//
// func brokenExample() {
// Sum([]string{"a", "b"}) // コンパイルエラー
// }
func main() {
nums := []int{1, 2, 3, 4, 5}
doubled := Map(nums, func(n int) int { return n * 2 })
fmt.Println("doubled:", doubled)
// T=int, U=string の異なる型パラメータの例
labels := Map(nums, func(n int) string { return fmt.Sprintf("n%d", n) })
fmt.Println("labels:", labels)
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
fmt.Println("evens:", evens)
fmt.Println("sum(nums):", Sum(nums))
temps := []Celsius{10.5, 20.0, -3.5}
fmt.Println("sum(temps):", Sum(temps)) // Celsius の基底型は float64 なので Number を満たす
var st Stack[string]
st.Push("a")
st.Push("b")
st.Push("c")
fmt.Println("stack len:", st.Len())
for {
v, ok := st.Pop()
if !ok {
break
}
fmt.Println("popped:", v)
}
}
ファイル: main_test.go
package main
import (
"reflect"
"testing"
)
func TestMap(t *testing.T) {
got := Map([]int{1, 2, 3}, func(n int) int { return n * n })
want := []int{1, 4, 9}
if !reflect.DeepEqual(got, want) {
t.Errorf("Map() = %v, want %v", got, want)
}
}
func TestMapDifferentTypes(t *testing.T) {
got := Map([]int{1, 2}, func(n int) string {
if n == 1 {
return "one"
}
return "two"
})
want := []string{"one", "two"}
if !reflect.DeepEqual(got, want) {
t.Errorf("Map() = %v, want %v", got, want)
}
}
func TestFilter(t *testing.T) {
got := Filter([]int{1, 2, 3, 4, 5, 6}, func(n int) bool { return n%2 == 0 })
want := []int{2, 4, 6}
if !reflect.DeepEqual(got, want) {
t.Errorf("Filter() = %v, want %v", got, want)
}
}
func TestSumInt(t *testing.T) {
if got := Sum([]int{1, 2, 3}); got != 6 {
t.Errorf("Sum() = %d, want 6", got)
}
}
func TestSumFloat64(t *testing.T) {
if got := Sum([]float64{1.5, 2.5}); got != 4.0 {
t.Errorf("Sum() = %v, want 4.0", got)
}
}
func TestSumNamedType(t *testing.T) {
// Celsius の基底型は float64。~float64 制約により Sum に渡せる。
if got := Sum([]Celsius{10, 20, -5}); got != 25 {
t.Errorf("Sum() = %v, want 25", got)
}
}
func TestStackPushPop(t *testing.T) {
var s Stack[int]
s.Push(1)
s.Push(2)
s.Push(3)
if s.Len() != 3 {
t.Fatalf("Len() = %d, want 3", s.Len())
}
v, ok := s.Pop()
if !ok || v != 3 {
t.Errorf("Pop() = (%d, %v), want (3, true)", v, ok)
}
if s.Len() != 2 {
t.Errorf("Len() after Pop = %d, want 2", s.Len())
}
}
func TestStackPopEmpty(t *testing.T) {
var s Stack[string]
v, ok := s.Pop()
if ok || v != "" {
t.Errorf("Pop() on empty stack = (%q, %v), want (\"\", false)", v, ok)
}
}
▶ 実行結果を見る(go run . / go test ./... で検証済み)
$ go run .
doubled: [2 4 6 8 10]
labels: [n1 n2 n3 n4 n5]
evens: [2 4]
sum(nums): 15
sum(temps): 27
stack len: 3
popped: c
popped: b
popped: a
$ go test ./...
ok example.com/day022 0.003s
※ main.go・main_test.goを1つのモジュール(go.modでmodule example.com/day022と宣言、go 1.21以上を指定)として同一パッケージmainに配置しています。ジェネリクスにはGo 1.18以降が必要です。
🪜 Step-by-Step 解説
Mapで型パラメータリストの基本構文を押さえるfunc Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}
[T, U any]が型パラメータの宣言。Tは入力スライスの要素型、Uは変換後の型で、両者は別々の型でよい(Map(nums, func(n int) string {...})のようにT=int, U=stringとなる呼び出しも可能)。呼び出し側は型引数を明示しなくても、渡された引数からコンパイラがT・Uを推論する。
Filterは型パラメータが1つで済むfunc Filter[T any](s []T, pred func(T) bool) []T {
result := make([]T, 0, len(s))
for _, v := range s {
if pred(v) {
result = append(result, v)
}
}
return result
}
入力と出力の要素型が同じなので、型パラメータはTひとつだけでよい。make([]T, 0, len(s))のように容量だけ確保して長さ0で始めるのは、Goのスライス操作の定石(Day 004-005で学んだappendの挙動を踏まえた書き方)。
Number制約で「使える演算子」を絞り込むtype Number interface {
~int | ~int64 | ~float64
}
anyだけではtotal += vのような算術演算ができない(anyは「何のメソッドも演算子も保証しない」制約だから)。Numberのような制約インターフェースを定義することで、「この型パラメータには+が使える」というコンパイラへの約束を書ける。|はunion(型の集合)、~は「その基底型を持つすべての名前付き型を含む」という意味。
Sumはゼロ値からのアキュムレータパターンfunc Sum[T Number](s []T) T {
var total T
for _, v := range s {
total += v
}
return total
}
var total Tはジェネリクス特有の書き方で、「型パラメータTのゼロ値」を作る。intなら0、float64なら0.0になる。具体的な型を知らなくてもゼロ値を得られるのがジェネリクスの利点。
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
構造体側で[T any]と宣言したら、そのメソッドのレシーバにもStack[T]のように型パラメータ名を繰り返す必要がある。ここで新しい型パラメータを導入すること(例えばPushだけに別の型パラメータを足すこと)はできない——メソッドは型が確定した後の操作だから。
~の有無で挙動が変わる)type Celsius float64
// Sum([]Celsius{...}) は ~float64 のおかげでコンパイルできる
もしNumberをint | int64 | float64(~なし)と書いていたら、Celsiusはfloat64そのものではなく別の名前付き型なのでSum([]Celsius{...})はコンパイルエラーになる。~は「基底型が一致する派生型もすべて許可する」という緩和のための記号であり、実務で独自の単位型(type Meters float64など)を扱う場面で必須になる。
💡 設計思想・なぜこう書くのか
interface{}(any)でも同じような汎用関数は書けるが、その場合f(v)のような呼び出しごとに型アサーションや型スイッチが必要になり、実行時パニックのリスクを抱える。ジェネリクスの制約(Numberのような)は、「この型パラメータに対してどんな操作が許されるか」をコンパイル時に確定させる仕組みであり、Goの「明示性を優先する」設計哲学の延長線上にある。anyは最も緩い制約であり、「本当に何もしない(渡すだけ・保持するだけ)」場合にのみ使うべきという住み分けが重要。🌐 他言語との比較
| 観点 | Go(ジェネリクス) | Java(ジェネリクス) | TypeScript(ジェネリクス) | Python(typing.Generic) |
|---|---|---|---|---|
| 型パラメータの宣言位置 | 関数名/型名の直後 [T any] | <T> | <T> | Generic[T]を継承 or def f[T](...) (3.12+) |
| 実行時の型情報 | 保持される(コンパイル時に型ごとに特殊化・一部共有) | 消去される(type erasure)。実行時はObjectとして扱われる | 完全に消去される(コンパイル時のみのチェック、トランスパイル後は消滅) | 実行時は基本的にチェックされない(アノテーションのみ、mypy等の静的チェッカーが検査) |
| 制約の書き方 | 制約インターフェース(~・|によるunion) | <T extends Number>(境界型) | <T extends SomeInterface>(構造的部分型) | TypeVar("T", bound=SomeClass) |
| プリミティブ型の扱い | int, float64などをそのまま型パラメータにできる | プリミティブ型は不可(Integer等のボックス型が必要、オートボクシングのコスト) | プリミティブ(number, string)も型として直接使える | 型そのものが実行時オブジェクトなので区別が薄い |
| 実行時パフォーマンスへの影響 | ほぼ無い(型ごとに特殊化されたコードが生成される) | ボクシング/アンボクシングのコストが生じうる | 実行時には型情報自体が存在しないため影響なし | 型チェックは実行時に行われないため影響なし(動的型付け) |
Javaの型消去に慣れたエンジニアは「Goのジェネリクスも実行時には型情報が消えるのでは」と誤解しがちだが、Goは型ごとにコードを特殊化するため、リフレクションを使わずにreflect.TypeOf相当の情報が実行時にも保持される点が異なる。
🏆 実務での使いどころ
- 汎用スライス操作ライブラリ:
Map/Filter/Reduceのような関数は、Go 1.21で標準ライブラリに追加されたslicesパッケージ(slices.Sort,slices.Contains等)やmapsパッケージのベースになっている考え方そのもの - リポジトリパターンの共通化:
Repository[T any]のようなジェネリックな型で、Repository[User]・Repository[Order]のようにドメインモデルごとのCRUD実装の重複を1つのコードにまとめられる(embeddingでは型ごとに書き分けが必要だった問題の直接的な解決策) - 型安全なキャッシュ・コンテナ:
Cache[K comparable, V any]のように、キーの型にcomparable制約(マップのキーに使える型のみ許可)を課したジェネリックなキャッシュ実装が典型的 - 数値計算・集計ロジックの共通化:
Sum,Average,Min,Maxのような集計関数を、int/float64/独自の数値型(金額型・度数型など)に対して1つの実装で提供できる(Number制約のパターン)
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
anyをどんな場面でも使ってしまい、結局interface{}時代と同じ設計に戻ってしまう | 「型パラメータさえ付ければジェネリクスになる」という表面的な理解 | anyは「本当に型を問わない」場合のみに使うべき。演算子やメソッドが必要な操作には、専用の制約インターフェースを定義するのがジェネリクスの本来の価値 |
独自の名前付き型(type Celsius float64)がint | float64制約を満たすと思い込む | 基底型が一致していれば当然OKだろうという直感 | ~(チルダ)がない場合、制約の型集合には「その型そのもの」しか含まれない。派生した名前付き型を許可するには~float64のようにチルダが必須 |
| ジェネリックメソッドで、レシーバの型パラメータとは別の新しい型パラメータを導入しようとする | 関数と同じ感覚でメソッドを書いてしまう | Goのメソッドはレシーバに紐づく型パラメータ(Stack[T]のT)しか使えず、メソッド自身が独自の型パラメータを追加で持つことはできない(言語仕様上の制約) |
| ジェネリクスを使えば型アサーション・型スイッチが不要になると誤解する | 「型安全=分岐が要らない」という飛躍 | 制約を満たす型の集合の中でも、型ごとに異なる処理をしたい場合は依然として型スイッチが必要になる。ジェネリクスは「同じロジックを型を問わず実行したい」場合の道具であり、「型ごとに違う処理をしたい」場合の道具ではない |
🚀 次のステップ
- 発展:
Reduce[T, U any](s []T, initial U, f func(U, T) U) Uを実装し、Sum・Filterの組み合わせで書けていた処理をReduce一本でどう表現できるか試してみましょう。またcomparable制約を使ってContains[T comparable](s []T, target T) boolも書いてみると、制約の使い分けの感覚が掴めます - 次回予告: Day 023 — testing パッケージ①(テーブル駆動テスト)(実装)。今日書いた
Map/Filter/Sum/Stackのテストコードは素朴な個別関数として書きましたが、次回はGoの定石である「テーブル駆動テスト」のイディオムに書き直し、ケース追加のコストを最小化する設計を学びます