【PHP8.3】PHPの乱数がますます改善される

PHP8.2で乱数が[大改善](https://qiita.com/rana_kualu/items/ab82a9c6dbd2e7c6edd0)[されました](https://qiita.com/rana_kualu/items/a6a727d7ab379d44eb18)が、早くもPHP8.3で幾つかの機能が追加されることが決まりました。

以下は該当のRFC、[Randomizer Additions](https://wiki.php.net/rfc/randomizer_additions)の紹介です。

# PHP RFC: Randomizer Additions

## Introduction

このRFCでは、ユーザランドでの実装が困難であったり面倒であったりする、幾つかの有用な機能を[\Random\Randomizer](https://www.php.net/manual/ja/class.random-randomizer.php)に追加することを提案します。

識別子、バウチャーコード、整数範囲を超える数値文字列を作るといった用途で、特定の文字を含むランダムな文字列を生成することはよくあります。
この操作をユーザランドで実装するには、ループ中で入力文字列からランダムなオフセットを取る必要があり、非常に単純な内容であるにもかかわらず何行ものコードが必要になります。
また文字列長から1を引くのを忘れるなど、微妙なバグも発生しがちです。

ランダムな浮動小数値の生成は、たとえば一定確率でbool値を出したいといった場合にも有用です。
これをユーザランドで行うことは、一見簡単そうに見えて実は面倒です。
`Randomizer::getInt()`で取得した値を割るというよくある実装は、丸め誤差や浮動小数の偏りなどにより、わりと正しくないことがよくあります。

## Proposal

`\Random\Randomizer`に3つのメソッド、およびひとつのenumを追加します。

```php
namespace Random;

final class Randomizer {
    // […]
    public function getBytesFromString(string $string, int $length): string {}
    public function nextFloat(): float {}
    public function getFloat(
        float $min,
        float $max,
        IntervalBoundary $boundary = IntervalBoundary::ClosedOpen
    ): float {}
}

enum IntervalBoundary {
    case ClosedOpen;
    case ClosedClosed;
    case OpenClosed;
    case OpenOpen;
}
```

### getBytesFromString

与えられた文字列から、ランダムな文字列を生成します。

第一引数`$string`は選択対象の文字列。
同じ文字が複数あった場合、その文字が選択される確率が増える。
全ての文字が1回しか登場しない場合、選択される確率は一様である。

第二引数`$length`は返り値の長さ。

#### Example

```php
$randomizer = new \Random\Randomizer();

// ランダムなドメイン名
var_dump(sprintf(
    "%s.example.com",
    $randomizer->getBytesFromString('abcdefghijklmnopqrstuvwxyz0123456789', 16)
)); // string(28) "xfhnr0z6ok5fdlbz.example.com"

// 多要素認証の認証コード
var_dump(
    implode('-', str_split($randomizer->getBytesFromString('0123456789', 20), 5))
); // string(23) "09898-46592-79230-33336"

// 小数
var_dump(sprintf(
    '0.%s',
    $randomizer->getBytesFromString('0123456789', 30)
)); // string(30) "0.217312509790167227890877670844"

// aが75%、bが25%で出現するランダム文字列
var_dump(
    $randomizer->getBytesFromString('aaab', 16)
); // string(16) "baabaaaaaaababaa"

// DNA
var_dump(
    $randomizer->getBytesFromString('ACGT', 30)
); // string(30) "CGTAGATCGTTCTGATAGAAGCTAACGGTT"
```

小数を作る場合、最後の桁に0が入る可能性があることに注意してください。

### getFloat()

引数`$min`と`$max`の間の小数を返します。
区間の端が開いているか閉じているかは、引数`$boundary`に依存します。
デフォルトは半開区間`[$min, $max)`であり、`$min`を含みますが`$max`は含まれません。
返される値は、設定された区間内に均等分布しています。

均等分布しているとは、各区間の値になる割合が、同じ幅の他の区間になる値と同じ割合ということです。
たとえば`getFloat(0, 1, IntervalBoundary::ClosedOpen)`を呼び出したときの返り値が0.5未満になる確率は、0.5以上になる確率と同じです。
同じく、0.1未満になる確率は10%であり、0.9以上になる確率と同じです。

使用したアルゴリズムは[Drawing Random Floating-Point Numbers from an Interval](https://dl.acm.org/doi/10.1145/3503512)という論文で公開されている`γ-section`というものです。

第一引数`$min`は最小値。
第二引数`$max`は最大値。

第三引数`$boundary`は返り値における境界の扱い。
・デフォルトは`\Random\IntervalBoundary::ClosedOpen`で`[$min, $max)`
・`ClosedClosed`は`[$min, $max]`
・`ClosedOpen`は`($min, $max]`
・`OpenOpen`は`($min, $max)`

#### Example

```php
$randomizer = new \Random\Randomizer();

// 経緯度
// 緯度は90/-90どちらも可
// 経度は180はあるけど-180はない
var_dump(sprintf(
    "Lat: %+.6f Lng: %+.6f",
    $randomizer->getFloat(-90, 90, \Random\IntervalBoundary::ClosedClosed),
    $randomizer->getFloat(-180, 180, \Random\IntervalBoundary::OpenClosed),
)); // string(32) "Lat: -51.742529 Lng: +135.396328"
```


### nextFloat()

このメソッドは`->getFloat(0, 1, \Random\IntervalBoundary::ClosedOpen)`と同じです。
`[0, 1)`を取得したいというありがちなケースを、より高速に処理します。

#### Example

```php
$randomizer = new \Random\Randomizer();

// 50%の確率
var_dump(
    $randomizer->nextFloat() < 0.5
); // bool(true)

// 10%の確率
var_dump(
    $randomizer->nextFloat() < 0.1
); // bool(false)
```


## Backward Incompatible Changes

クラス名`\Random\IntervalBoundary`が使用できなくなります。
名前空間`\Random`はPHPの乱数で使用されているものであり、またGitHubでも同名クラスはヒットしないので、実影響はないでしょう。

## Proposed PHP Version(s)

PHP8.x

## RFC Impact

SAPIや拡張モジュールへの影響、定数やphp.iniの変更はありません。

## Proposed Voting Choices

`getBytesFromString()`は賛成18反対0、`getFloat()/nextFloat()`は賛成16反対1の賛成多数で可決されました。
PHP8.3で導入されます。

## Patches and Tests

[getBytesFromString()の実装](https://github.com/php/php-src/pull/9664)
[getFloat()/nextFloat()の実装](https://github.com/php/php-src/pull/9679)

## Implementation

https://github.com/php/php-src/commit/ac3ecd03af009d433d4b75d570b3b0f0a3fc0ff7
https://github.com/php/php-src/commit/f9a1a903805a0c260c97bcc8bf2c14f2dd76ca76

## References

[最初の提案](https://externals.io/message/118762)
[RFCの議論](https://externals.io/message/118810)


# 感想

ランダム文字列って何気に面倒なんですよね。
[よくある例](https://qiita.com/suin/items/c958bcca90262467f2c0)は`str_shuffle`ですが、これは[暗号学的に安全でない](https://www.php.net/manual/ja/function.str-shuffle.php)のであまり適切ではありません。
[random_bytes](https://www.php.net/manual/ja/function.random-bytes.php)は安全ですが、返り値がバイナリなので`[0-9A-Za-z]`に限定したいんだなんてときは使い辛いです。

そんなわけで、実用的に便利なメソッドが追加されました。
暗号学的に安全なパスワードを作りたいなんて要求が、今後は容易に実装可能です。


ちなみにこのRFCには[Random Extension 5x](https://wiki.php.net/rfc/rng_extension)と[Random Extension Improvement](https://wiki.php.net/rfc/random_extension_improvement)を[作った人](https://twitter.com/zeriyoshi)は関わっていないみたいで、主にメーリングリストでそれらのRFCに[突っ込みを入れていた人](https://github.com/TimWolla)が作っていました。