math/rand/v2入門—Goの乱数でSeedが不要になった理由と書き換え

math/rand/v2入門—Goの乱数でSeedが不要になった理由と書き換え | mohablog

rand.Seed(time.Now().UnixNano())。Goで乱数を使うとき、長らくこの1行をmain関数の先頭に貼るのが作法でしたよね。math/rand/v2ではこの行が消えます。書かないどころか、書く関数自体が無くなりました。

Go 1.22で追加されたmath/rand/v2は、標準ライブラリで初めて/v2の付いたパッケージです。現行のGo 1.26系でも同じAPIで動きます。本記事は公式ブログ “Evolving the Go Standard Library with math/rand/v2” とpkg.go.devのリファレンスを一次情報に、何がどう変わったかと既存コードの直し方を整理します。

目次

Seedを書かなくてよくなった

math/randのトップレベル関数は、明示的にシードしないと毎回同じ乱数列を返しました。これを避けるためrand.Seed(time.Now().UnixNano())を貼るのが定番だったわけです。v2のトップレベル関数は自動シード済みで、この準備が要りません。

Go 1.20で布石が打たれていた

この変更はv2でいきなり起きたものではありません。公式ブログの “Seeding Responsibility” によれば、Go 1.20の時点でトップレベルのジェネレータは自動シードされ、rand.Seedは非推奨になっていました。v2はその続きで、トップレベルのSeed関数そのものを削除しています。

package main

import (
	"fmt"
	"math/rand/v2"
)

func main() {
	// Seed不要。いきなり呼べる
	fmt.Println(rand.IntN(100))
	fmt.Println(rand.Float64())
}
$ go run main.go
73
0.4182736451928374

実行のたびに違う値が出ます。rand.Seedを探しても見つからないので、v1から移植したコードはここでまずコンパイルエラーになります。

再現性が要るときはSourceを明示する

トップレベルが自動シードになったぶん、テストやシミュレーションで「毎回同じ列」が欲しいケースの書き方が変わりました。グローバルをシードするのではなく、シード値を渡したSourceを自分で持ちます。

// v1: グローバルを固定していた
rand.Seed(42)
fmt.Println(rand.Intn(100))

// v2: PCGをseed付きで生成し、Randに包む
r := rand.New(rand.NewPCG(42, 1024))
fmt.Println(r.IntN(100))
$ go run main.go
# v2側は seed=(42,1024) が同じなら毎回同じ列を返す
11

rand.NewPCG(seed1, seed2)は2つのuint64を取ります。rand.NewがそのSource*rand.Randに包み、IntNFloat64といったメソッドを生やす形です。グローバル状態を共有しないので、テストごとに独立したシードを渡せる構成になりました。

関数名のリネーム早見表

移植で一番ぶつかるのが名前の変更です。公式ブログは “The 31 and 63 in the names were unnecessarily pedantic and confusing” と述べ、ビット幅を名前から外しました。対応は機械的に置換できます。

v1 (math/rand)v2 (math/rand/v2)備考
Intn(n)IntN(n)nを大文字化
Int31()Int32()ビット幅表記を廃止
Int31n(n)Int32N(n)
Int63()Int64()
Int63n(n)Int64N(n)
rand.Seed(x)削除NewPCGで代替
rand.Read(p)削除ChaCha8側へ移動

大文字のNはGoで2語目を区切る慣習に沿った形です。Readがトップレベルから消えた経緯は “The Read Mistake” のセクションに書かれています。

汎用関数Nで型変換が消える

// v1: 5秒以内のランダムなDurationが欲しい
d := time.Duration(rand.Int63n(int64(5 * time.Second)))

// v2: 型パラメータが効くので変換が要らない
d := rand.N(5 * time.Second)
$ go run main.go
3.214857619s

この書き換えは公式ブログがそのまま載せている例です。Nの追加理由がここに出ています。N[Int intType](n Int) Intはあらゆる整数型で動くため、time.Durationのような独自の整数型でもint64への往復キャストが消えます。半開区間[0, n)を返し、n <= 0ならpanicする挙動はIntNと同じです。

使い分けの目安

具体的な型がintで固定ならIntNtime.Durationや独自のtype ID int64のように型を保ちたいならN。違いはそれだけです。

よく使う乱数ヘルパー

整数1個を返すIntN以外にも、スライスのシャッフルや順列、範囲指定は実務で頻出します。名前は変わっていても、対応する関数はv2にもそのままあります。

ShuffleとPermでスライスを混ぜる

deck := []string{"A", "K", "Q", "J", "10"}
rand.Shuffle(len(deck), func(i, j int) {
	deck[i], deck[j] = deck[j], deck[i]
})
fmt.Println(deck)

// インデックスだけ欲しいなら Perm
fmt.Println(rand.Perm(5))
$ go run main.go
[Q 10 A J K]
[3 0 4 1 2]

rand.Shuffle(n, swap)は要素数nと入れ替え関数を受け取り、Fisher-Yatesでその場で並べ替えます。rand.Perm(n)[0, n)の整数を1つずつ含むランダムな順列を[]intで返します。元のスライスを壊したくないとき、インデックス経由のアクセスに使えます。

範囲を指定して引く

IntNは常に0始まりです。「100から200の間」のように下限がある範囲は、下限を足して作ります。

// [100, 200) の整数
n := 100 + rand.IntN(100)

// [1.0, 6.0) の浮動小数
x := 1.0 + rand.Float64()*5.0

fmt.Println(n, x)
$ go run main.go
147 4.83120559

Float64が返すのは半開区間[0.0, 1.0)なので、幅を掛けて下限を足せば任意の範囲に写せます。上限を含めたいかどうかで+1の要否が変わる点だけ、サイコロのような整数範囲では注意します。

PCGとChaCha8をどう選ぶか

v2はGo 1世代のジェネレータを完全に撤去し、PCGChaCha8の2つに置き換えました。性質が違うので、用途で選びます。

トップレベル関数の中身はChaCha8

rand.IntNのようなトップレベル関数は、内部でChaCha8を使います。Goランタイムのグローバル乱数もChaCha8に切り替わりました。ChaCha8は暗号方式ChaCha由来で、出力列の予測が現実的に困難です。math/randcrypto/randのつもりで誤用してしまった場合の被害を、設計レベルで小さくする狙いがあります。

速度差は実測で2%から25%

暗号強度にはコストが伴いますが、その差は限定的です。128bitのベクトル演算が使えるamd64ではChaCha8はPCGより約25%遅く、arm64では約2%速い、という測定が公式に示されています。乱数生成が律速になるシミュレーションのような場面では、再現性も兼ねてNewPCGを明示的に握るほうが速くなります。

// 速度優先・再現性あり: PCGを直に使う
r := rand.New(rand.NewPCG(1, 2))

// 予測困難さ優先: ChaCha8。トップレベル関数の既定でもある
c := rand.New(rand.NewChaCha8([32]byte{}))

fmt.Println(r.IntN(6), c.IntN(6))
$ go run main.go
4 2
観点PCGChaCha8
速度(amd64)速い(基準)約25%遅い
速度(arm64)基準約2%速い
予測困難さ低い高い(暗号方式由来)
シード型uint64×2[32]byte
トップレベル関数使われないこれが既定
どちらを選んでもセキュリティ用途には足りません。鍵やトークンの生成はcrypto/randを使ってください。リファレンスも “For random numbers suitable for security-sensitive work, see the crypto/rand package” と明記しています。

v1からv2へ書き換える

既存コードの移植は、import文の差し替えと関数名の置換が大半です。importをmath/rand/v2に変え、コンパイラが赤くした箇所を早見表どおりに直していく流れになります。

// Before
import "math/rand"

func pick(items []string) string {
	return items[rand.Intn(len(items))]
}

// After
import "math/rand/v2"

func pick(items []string) string {
	return items[rand.IntN(len(items))]
}
$ go vet ./...
# Intn等が残っていればundefinedで弾かれ、消し残しを検出できる

運用しているバッチでrand.Seedを消したとき、別パッケージのinit内でrand.Seed(1)を呼んで結果を固定していたテストが落ちました。v1ではグローバルが共有されるため、離れた場所のSeedが効いていたわけです。v2でSourceがローカルになり、こういう暗黙の結合が切れます。落ちたのは、離れたパッケージのSeedに暗黙で依存していたテストでした。

段階的に移すなら

巨大なコードベースでは、パッケージ単位でimportを切り替えていけます。v1とv2は別パッケージとして共存できるので、一度に全置換する必要はありません。自動変換ツールは用意されていないため、置換はエディタの一括置換とgo vetでの検出を組み合わせる手作業になります。乱数を多用するパッケージから順に潰していけば、差分も追いやすくなります。

まとめ

math/rand/v2は名前を整理しただけのバージョンではなく、シードの責務とジェネレータの設計をやり直したパッケージです。要点を整理します。

  • トップレベル関数は自動シード済みで、rand.Seedは削除された
  • 再現性が必要ならrand.New(rand.NewPCG(seed1, seed2))でSourceを自前で持つ
  • IntnIntNInt63nInt64Nなど名前が機械的に変わった
  • 汎用関数Ntime.Duration等への往復キャストが消える
  • 速度優先かつ再現性ありならPCG、予測困難さが要るならChaCha8。どちらもセキュリティ用途はcrypto/rand

公式ブログの最終節 “Principles for evolving the Go standard library” は、この一連の変更を標準ライブラリを壊さず進化させる原則の実例として位置づけています。乱数まわりを触る機会があれば、importを/v2に変えるところから始めれば十分です。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次