PHPで整数がPHP_INT_MAXを超えるとfloatになる理由と、IDや金額で起きる落とし穴

PHPの整数は64ビット環境で最大 9223372036854775807(PHP_INT_MAX)までで、演算結果がこれを超えるとエラーにならず、黙ってfloatに変わります。その結果、大きなIDや金額の下の桁が静かに丸まることがあります。

オーバーフローで例外が飛ぶ言語に慣れていると、PHPのこの挙動は少し不気味ですよね。私も、あふれた値がそのまま画面に出て初めて気づく、という形で何度か遭遇しました。

PHP_INT_MAXに1を足すと何が起きるのか

先に挙動を確認します。ポイントは「エラーも警告も出ない」ことです。

<?php
var_dump(PHP_INT_MAX);        // int(9223372036854775807)
var_dump(PHP_INT_MAX + 1);    // float(9.223372036854776E+18)
var_dump(PHP_INT_MAX * 2);    // float(1.8446744073709552E+19)

$i = PHP_INT_MAX;
$i++;
var_dump($i);                 // float(9.223372036854776E+18)

var_dump(is_int(PHP_INT_MAX + 1)); // bool(false)

加算・乗算・インクリメントのどれでも、結果が整数に収まらなければfloatになります。PHP_INT_SIZE は64ビット環境なら 8 で、32ビット環境だと最大値が約21億(2147483647)まで下がる点も、サーバー移行のときに地味に効いてきます。

なお、var_dump の浮動小数点の表示桁は serialize_precision の設定に依存するので、環境によって E+18 の仮数部の見え方が少し変わることがあります。以降の出力例も、その前提で読んでください。

なぜ「同じ値」なのに比較がtrueになるのか

floatは仮数部が53ビットなので、2の53乗(9007199254740992)を超えると、連続する整数をすべては表せなくなります。そこへ整数が変換されると、近い値に丸められます。

<?php
var_dump(PHP_INT_MAX + 1 == PHP_INT_MAX); // bool(true)

$a = 9007199254740993;          // 2の53乗 + 1(まだint)
var_dump((float) $a == $a);     // bool(true)
var_dump((int) (float) $a);     // int(9007199254740992) ← 1つ減っている

1行目は、整数とfloatを == で比べるとき、整数のほうがfloatに寄せられるために起きます。「あふれた値が元の最大値と等しい」という、わりと嫌な結果になります。大きな数を扱う比較では、型を揃えたうえで === を使うのが安全です。

JSONで大きなIDが丸まるのはどう防ぐのか

実務で一番遭遇しやすいのは、外部APIが返す64ビットを超える数値や、ツイートIDのような巨大なIDを json_decode したときです。整数に収まらない数値は、floatとして読まれます。

<?php
$json = '{"id":12345678901234567890}';

var_dump(json_decode($json, true));
// array(1) { ["id"]=> float(1.2345678901234567E+19) }

var_dump(json_decode($json, true, 512, JSON_BIGINT_AS_STRING));
// array(1) { ["id"]=> string(20) "12345678901234567890" }

JSON_BIGINT_AS_STRING を付けると、整数に収まらない数値が文字列のまま渡されます。IDは計算しないので、文字列で持つのが一番事故りません。ただしこのフラグは「あふれる整数」が対象で、小数点付きの数値には効きません。

入力値の検証ではどう扱うのか

フォームやクエリで受け取った数字文字列を検証するとき、is_numeric だけだと範囲外の値も通ってしまいます。filter_var の FILTER_VALIDATE_INT は、範囲外ならfalseを返してくれます。

<?php
var_dump(is_numeric('9223372036854775808'));                  // bool(true)
var_dump(filter_var('9223372036854775808', FILTER_VALIDATE_INT)); // bool(false)
var_dump(filter_var('9223372036854775807', FILTER_VALIDATE_INT)); // int(9223372036854775807)

ここで嬉しいのは、「intとして安全に使えるか」を型変換の副作用に頼らず判定できることです。ちなみに (int) ‘9223372036854775808’ のように文字列から直接キャストすると、エラーにならず PHP_INT_MAX に張り付く挙動でした。静かに別の値になるので、検証には使わないほうがいいと思います。

金額計算や厳密な加算はどうするのか

桁が溢れる可能性があるなら、結果の型を見て分岐する手もありますが、素直なのは任意精度の計算に逃がすことです。bcmath 拡張が有効なら、文字列で足し算できます。

<?php
// bcmath 拡張が有効な環境の場合
echo bcadd((string) PHP_INT_MAX, '1'); // 9223372036854775808

// 足し算の結果がintかどうかで検知する方法もある
$sum = array_sum([PHP_INT_MAX, 1]);
if (!is_int($sum)) {
    throw new OverflowException('整数の範囲を超えました');
}

array_sum もあふれるとfloatを返すので、is_int で確かめれば「静かに丸まる」を例外に変えられます。なお、intdiv(PHP_INT_MIN, -1) だけは例外(ArithmeticError)になります。割り算はあふれる値を返せない、という扱いです。

まとめ

PHPの整数オーバーフローは、例外にならずfloatへ切り替わる、というのが一番の特徴だと思います。IDは文字列で持つ、外部入力は filter_var で範囲を確かめる、厳密な加算は bcmath か is_int での検知に任せる、くらいを覚えておくと、ほとんどの事故は避けられる気がしています。

よくある質問

Q. PHPで整数の最大値はどうやって調べますか。
A. 定数 PHP_INT_MAX と PHP_INT_MIN で取れます。64ビット環境なら最大値は 9223372036854775807 です。PHP_INT_SIZE でバイト数も確認できます。

Q. 整数があふれたかどうかを検知する方法はありますか。
A. 計算結果を is_int で確かめるのが簡単です。あふれていればfloatなので、falseになります。

Q. データベースの BIGINT 値をPHPで読むと丸まりますか。
A. 符号付き BIGINT の範囲は PHP_INT_MAX と同じなので、64ビット環境のPHPではintで読めるのが普通です。ただ BIGINT UNSIGNED の大きな値は範囲を超えるので、文字列で扱うほうが安全です。

類似投稿

コメントを残す

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