Go1.18.0でタイムスリップする
はじめに¶
私は Go 1.22(2025年5月)から触り始めました。 カンファレンスや登壇を聞いていると「それ、昔のバージョンで入ったやつですね」みたいな話が当たり前のように出てきます。
そのたびに、「なんとなく知ってる気はするけど、ちゃんと分かってないな」となるのが、ちょっと悔しいなと思っていました。せっかくなので、一回ちゃんと過去を辿ってみようと思います。
Go 1.22 から触り始めて、もうすぐ1年になるので、振り返りも兼ねて Go のリリースノートを少し前から追っていくシリーズにして、できれば Go 1.25 まで進めたいと思っています。
Generics について¶
Generics とは¶
Go 1.18 の一番大きな変更といえば Generics です。 名前はよく聞くけど、ちゃんと説明しろと言われると「あー...」ってなります
ざっくり言うと、型に対してパラメータを持てるようにする仕組み です。
1.17 以前の書き方¶
Generics がなかった頃は、「いろんな型で同じ処理をしたい」と思ったときに、型ごとに同じ関数を書くしかありませんでした。
func MaxInt(a, b int) int {
if a > b {
return a
}
return b
}
func MaxFloat64(a, b float64) float64 {
if a > b {
return a
}
return b
}
https://go.dev/play/p/sOzRbtb2BOf
または interface{} を使って、「とりあえず何でも受け取る」という方法もありました。
package main
import "fmt"
func Print(v interface{}) {
fmt.Println(v)
}
func main() {
Print(42)
Print("hello")
Print(3.14)
}
この書き方は動きはするんですが、いくつかつらさがあります。 - どんな型でも受け付けてしまうので、型安全ではない - キャストや型アサーションが必要になる - 意図しない型を渡しても、コンパイル時には気づけない
1.18 での書き方¶
Generics が入ったことで、型をパラメータとして扱えるようになりました。
1.17 以前の書き方では、メソッドの中で型の異なるパラメーターを受け取ることができなかったですが、この変更で先ほどの実装が以下のように書くことができます
package main
import "fmt"
// int も float64 も受け取ることができる
func Min[T int | float64](a, b T) T {
if a < b {
return a
}
return b
}
func main() {
fmt.Println(Min(3, 5)) // int: 3
fmt.Println(Min(1.2, 3.4)) // float64: 1.2
}
2つのコードは制約の書き方が違います。
[T int | float64] は 「int か float64 のどちらか」 というユニオン型制約です。受け付ける型を自分で列挙しています。string を渡すとコンパイルエラーになります。
constraints.Ordered は 「< > で順序比較できる型ならすべて OK」 という制約です。int, float64, string など複数の型をまとめて表現しています。golang.org/x/exp という外部パッケージで定義されていて、内部では int | int8 | int16 | ... | float32 | float64 | string のようなユニオン型として実装されています。
つまり constraints.Ordered は、よく使うユニオン制約をまとめて名前をつけたものです。「特定の型だけ受け付けたい」ならユニオン直書き、「比較できれば何でもいい」なら constraints.Ordered を使う、という使い分けになります。
なお Go 1.21 からは標準ライブラリの cmp パッケージに cmp.Ordered として取り込まれたので、外部パッケージ不要になっています。
import "golang.org/x/exp/constraints"
func Max[T constraints.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
これで int でも float64 でも string でも、1 つの関数で対応できます。
制約について¶
Generics で [T ...] と書くとき、T に何を入れられるかは 制約 で決まります。
よく出てくるのがこのあたりです。
| 制約 | 意味 |
|---|---|
any |
どんな型でも OK |
comparable |
== で比較できる型だけ |
constraints.Ordered |
< > で順序比較できる型だけ |
any と comparable¶
Generics を触るとすぐ出てくるのがこのあたりです。
any¶
any は Go 1.18 で追加された interface{} の別名です。「どんな型でも受け取れる」という意味になります。
ただし any で受け取った値は、その型に固有の操作が一切できません。足し算も比較もできず、何かしたければ型アサーション(v.(int) など)が必要です。Generics の制約として any を使うのは、「受け取れれば十分で、型に依存した操作はしない」場面に限られます。
comparable¶
comparable は == と != で比較できる型だけを受け付ける制約です。int, string, bool, struct(フィールドがすべて comparable なもの)などが該当します。slice や map は比較できないので該当しません。
Go の map はキーに comparable な型しか使えないというルールがあります。Generics でも同じで、map のキーになり得る型を制約したいときに使います。
package main
import "fmt"
func Contains[T comparable](slice []T, target T) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}
func main() {
fmt.Println(Contains([]int{1, 2, 3}, 2)) // true
fmt.Println(Contains([]string{"a", "b"}, "c")) // false
}
any を使ってしまうと == が使えないのでこの実装は書けません。comparable を使うことで「比較できる型だけを受け付ける」とコンパイラに伝えられます。
Go 1.25 の私から見る any¶
Go の実装の中でもany はかなり普通に出てくる印象があります。
1.25での変更の1つでもあるgo fix を実行すると、古いコードの interface{} が any に置き換割ります
2. Fuzzing の追加¶
Fuzzingテストとは¶
Go 1.18 では、Fuzzing が testing パッケージに入りました。
ファジングとは、
2.1 最低限の形¶
func FuzzReverse(f *testing.F) {
f.Add("hello")
f.Add("世界")
f.Add("")
f.Fuzz(func(t *testing.T, orig string) {
rev := Reverse(orig)
doubleRev := Reverse(rev)
if orig != doubleRev {
t.Fatalf("mismatch: %q", orig)
}
})
}
この形で、まずシードコーパスを f.Add で渡します。そのうえで、Fuzz エンジンがランダム入力を広げてくれます。
2.2 何がうれしいのか¶
Fuzzing のうれしさは、境界値や想定外入力を、人力よりずっとしつこく攻められることです。たとえば次のようなコードと相性がいいです。
- パーサ
- エンコードとデコード
- 文字列変換
- URL、JSON、CSV、バイナリの入出力 「ある操作をして、逆操作をしたら元に戻る」のような性質も書きやすいです。このあたりは、通常のテーブルテストでは抜けやすいです。
2.3 実行コマンド¶
-run はシードだけを確認したいときに便利です。-fuzz は探索を回します。CI に入れるなら -fuzztime を必ず付けるのが実務向きです。
2.4 注意点¶
Fuzzing は無料ではありません。CPU もメモリも食います。失敗ケースやキャッシュも増えます。なので、普段の PR ごとに長時間回すより、時間制限付きで回すか、夜間ジョブに寄せる方が現実的です。
:::details Fuzzing を導入するときの実務メモ
- 最初はパーサや変換処理のような純粋関数から始める
- ネットワークや DB を含むテストは避ける
- 落ちた入力は testdata と一緒に固定化して回帰テストへ戻す
- いきなり全体導入せず、壊れた時の被害が大きい箇所に絞る
:::
3. Workspaces の追加¶
Workspacesとは¶
Go 1.18 のもう 1 つの重要な変更が Workspaces です。go.work によって、複数モジュールをまとめてローカル開発できるようになりました。
3.1 1.17 以前はどうしていたか¶
1.17 以前も、ローカルの別モジュールを参照すること自体はできました。ただし、だいたい replace を go.mod に書く流れでした。
これの何がつらいかというと、開発用の都合が go.mod に混ざることです。うっかりコミットすると、他人の環境を汚します。複数モジュールにまたがると、replace の管理も面倒です。
3.2 go.work の形¶
これで、各モジュールの go.mod を汚さずに、ローカルだけで束ねられます。コマンドも素直です。
3.3 どういう場面で効くか¶
自分がいちばん効くと感じるのは、次のような構成です。
- API サーバーと worker が別モジュール
- 共通ライブラリを自前で持っている
- モノレポ寄りだが、1 リポジトリ 1 モジュールではない
このとき go.work があると、試行錯誤の摩擦がかなり減ります。Go 1.18 は Generics に目が行きがちですが、Workspaces は日々の開発体験に直接効く変更でした。
過去の replace 地獄に戻ってから go.work を見ると、「未来人は便利な道具を持っているな」と素直に思います。
まとめ¶
Go 1.18 を復習して、特に残ったポイントは 5 つです。
- Generics は、Go に抽象化の新しい道具を追加した
- ただし、何でも Generics にすればいいわけではない
- Fuzzing は
go testを「例を確認する道具」から一歩先へ進めた - Workspaces は複数モジュール開発の摩擦を減らした
strings.Cutやnetipのような小さな改善も、長期で効いてくる
Go 1.18 を読むと、いまの Go で当たり前になっている前提が、どこから入ったのかが見えてきます。Generics、Fuzzing、Workspaces は、その後のコードの書き方や読み方に長く影響を残しています。
タイムスリップものとして見るなら、ここは未来の痕跡が一気に増える地点でした。Generics なしの世界線。Fuzzing が標準でない世界線。go.work がないまま replace を抱え続ける世界線です。そこまで想像すると、Go 1.18 が「便利な新機能が入った版」以上の意味を持っていたことが見えてきます。
次は Go 1.19 以降も追って、1.18 で入ったものがどう整備されていったかを見ていきます。できればこのまま Go 1.25 まで、少しずつシリーズとして振り返っていきたいです。
参考資料¶
Go 1.18 全体¶
Generics¶
- An Introduction To Generics
- When To Use Generics
- Go 1.18集中連載 ジェネリクス
- Go言語のジェネリクス入門
- Go言語のジェネリクス入門(2) インスタンス化と型推論
- mattn さんの generics 資料