Random\Randomizerでランダム処理を差し替え可能にする

Random\Randomizer は PHP 8.2 で追加された乱数のクラスAPIです。エンジンを差し替えられるので、本番は安全な乱数、テストは固定シードで再現、という使い分けができます。

ランダムが絡むコードは、テストがいちばん書きにくい部類だと思います。抽選、シャッフル、確認コード。仕様としては「ランダムに選ぶ」で正しいのに、テストからは結果が押さえられない。

私も昔、抽選ロジックのテストを「10回まわして全部同じ値でなければOK」みたいな書き方で誤魔化したことがあります。あれは動作を検証しているというより、祈っているだけでした。Randomizer を知ってからは、そこがずいぶん素直に書けるようになったという実感があります。

shuffle() や array_rand() は何が困るのか

従来のランダム系の関数は、どれもグローバルな乱数源に直接ぶら下がっています。

<?php
$winner = $candidates[array_rand($candidates)];
shuffle($questions);
$code = bin2hex(random_bytes(4));

書き味は軽いのですが、呼び出し側から乱数源を差し替える隙間がありません。テストで結果を固定したければ mt_srand() でグローバルな種を撒くくらいしか手がない。array_rand()shuffle() はグローバルなMt19937を共有しているのでそれで一応固定はできますが、プロセス全体の乱数を巻き込む乱暴な手ですし、random_int()random_bytes() は別系統なので効きません。「ランダムであること」と「どこからランダムを取るか」がくっついてしまっている、というのが根っこの問題です。

Randomizerの基本形

Randomizer は、乱数の供給源(エンジン)と、そこから使いやすい値を作るメソッド群を分けたクラスです。エンジンを省略すると、暗号論的に安全な Random\Engine\Secure が使われます。

<?php
use Random\Randomizer;

$randomizer = new Randomizer(); // エンジン省略時は Random\Engine\Secure

$dice     = $randomizer->getInt(1, 6);              // 1〜6 の整数
$bytes    = $randomizer->getBytes(16);              // 16バイトのランダム列
$shuffled = $randomizer->shuffleArray([1, 2, 3, 4, 5]);

デフォルトのままなら random_int()random_bytes() と同じ強度です。つまり「安全性を落とさずに、差し替えられる形に変えられる」。ここが乗り換える理由になります。

テストで再現させるにはどうする?

Randomizer をコンストラクタで受け取る形にしておくと、テスト側から乱数の中身を決められます。エンジンを Random\Engine\Mt19937 にして、シードを固定するだけです。

<?php
use Random\Randomizer;

final class Lottery
{
    public function __construct(private readonly Randomizer $randomizer) {}

    /** @return list<string> */
    public function draw(array $entries, int $count): array
    {
        $keys = $this->randomizer->pickArrayKeys($entries, $count);

        return array_map(static fn ($key) => $entries[$key], $keys);
    }
}

テストではこう書きます。

<?php
use Random\Engine\Mt19937;
use Random\Randomizer;

$entries = ['a' => '田中', 'b' => '佐藤', 'c' => '鈴木', 'd' => '高橋'];

$first  = (new Lottery(new Randomizer(new Mt19937(1234))))->draw($entries, 2);
$second = (new Lottery(new Randomizer(new Mt19937(1234))))->draw($entries, 2);

// 同じシード・同じ入力なら、同じ結果になる
var_dump($first === $second); // bool(true)

本番コードは new Randomizer() を渡すだけで安全な乱数のまま。テストだけが決定的になります。モックを書かずに済むのが地味にありがたいところです。

shuffleArrayはキーを捨てる

ここは shuffle() と挙動が違うので注意が要ります。shuffleArray() は引数を書き換えず、シャッフル済みの新しい配列を返します。ただし返るのは値だけのリストで、元のキーは残りません。

<?php
$scores = ['taro' => 80, 'hanako' => 92, 'jiro' => 75];

$shuffled = $randomizer->shuffleArray($scores);
// [92, 75, 80] のような値だけのリスト。'taro' などのキーは消える
// $scores 自体は書き換わらない(shuffle()と違って参照渡しではない)

キーごと混ぜたいときは、キーの配列をシャッフルして引き直します。

<?php
$keys = $randomizer->shuffleArray(array_keys($scores));

$result = [];
foreach ($keys as $key) {
    $result[$key] = $scores[$key];
}

この「引数を壊さない」という性質は、値オブジェクトを扱っているときにけっこう効きます。元の配列をあとで使いたいのに shuffle() で潰していた、という事故が起きません。

pickArrayKeysの戻り値はキーのリスト

pickArrayKeys() は名前のとおり、値ではなくキーを返します。array_rand() の置き換えとして使うことが多いですが、戻り値は常に配列(リスト)なので、1件のときも添字で取り出す必要があります。

<?php
$fruits = ['red' => 'apple', 'green' => 'kiwi', 'yellow' => 'banana'];

$keys = $randomizer->pickArrayKeys($fruits, 2);
// ['green', 'yellow'] のようなキーのリスト(値ではない)

$picked = array_map(static fn ($k) => $fruits[$k], $keys);

// 1件だけ欲しいときも配列で返る
$one = $fruits[$randomizer->pickArrayKeys($fruits, 1)[0]];

第2引数は 1 以上、要素数以下でなければ ValueError が飛びます。array_rand() が 1件のときはキーそのものを返していたので、そこだけ書き換えると型が合わなくなります。移植のときに引っかかりやすい箇所です。

確認コードの生成はgetBytesFromStringが早い

PHP 8.3 で getBytesFromString() が入りました。使ってよい文字を渡すと、そこから指定長ぶんランダムに選んで文字列にしてくれます。

<?php
$randomizer = new Randomizer(); // Secure エンジン

// 0/O, 1/I のような紛らわしい文字をあらかじめ外しておく
$alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';

$code = $randomizer->getBytesFromString($alphabet, 8);
// 例: "7K2QMV9X"

自分で random_int() を回して文字を拾うループを書かなくてよくなります。ユーザーが電話越しに読み上げるコードなど、文字種を絞りたい場面で素直に書けるのがいいところです。

注意点が二つ。ひとつは、選ばれる文字は重複しうること(サンプリングは復元あり)。もうひとつは、名前のとおりバイト単位なので、マルチバイト文字を渡すと途中で切れて壊れることです。渡すのはASCIIの範囲だけにしておくのが安全だと思います。空文字列を渡した場合や長さが 1 未満の場合は ValueError になります。

ハマりどころ:固定シードでも「永久に同じ」ではない

再現できるからといって、シード固定の結果をテストの期待値としてハードコードするのは勧めません。マニュアルにも、pickArrayKeys() の選択結果は入力配列の内部構造に依存するという注意書きがあります。見た目が等しい二つの配列でも、作られ方が違えば違うキーが返りうる、ということです。

ですので、テストで固定シードを使うときは「同じ条件なら二回とも同じ結果になる」「件数が正しい」「重複していない」といった性質を確認する形に寄せておくほうが安全です。「['b', 'd'] が返るはず」と書いてしまうと、PHPのバージョンを上げたときに理由の分からない失敗をします。

もうひとつ。Mt19937 は速いですが予測可能なので、トークンやパスワードのような秘密の生成には使わないでください。あくまで再現性が欲しいテストや、シード付きのゲーム生成のような用途向けです。再現性のあるシードで衝突を避けたいなら、Random\Engine\Xoshiro256StarStarRandom\Engine\PcgOneseq128XslRr64 のほうが広いシード空間を持っています。

まとめ

Randomizer の値打ちは、新しい乱数アルゴリズムが増えたことではなく、乱数を「注入できるもの」に格下げしたことだと思っています。ランダムがグローバル関数のままだと、テストの都合でロジックを歪めるか、テストを諦めるかの二択になりがちでした。コンストラクタで受け取るようにしておけば、本番は安全な乱数のまま、テストだけ手綱を握れる。既存コードを一気に書き換える必要はなくて、次にランダムが絡むクラスを書くときから Randomizer を引数に足しておく、くらいの入り方でちょうどいい気がしています。

よくある質問

Q. random_int() はもう使わないほうがいいですか?
A. そんなことはありません。デフォルトの Randomizer と同じく安全な乱数源を使っています。差し替えたい、テストで固定したい、という要求が出たときに Randomizer を検討する、で十分だと思います。

Q. Mt19937 を本番で使ってもいいですか?
A. 用途次第です。予測できてしまうので、トークンやパスワードリセットのキーなど、当てられて困るものには使わないでください。シードから同じ地形を再生成したいといった用途なら適しています。

Q. Randomizer のメソッドは例外を投げますか?
A. 引数がおかしい場合は ValueError、乱数源からの取得に失敗した場合は Random\RandomException が投げられます。Secure エンジンで後者が起きるのは稀ですが、握りつぶさずに上へ通すのが無難です。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です