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 の大きな値は範囲を超えるので、文字列で扱うほうが安全です。