PHPのjson_encodeでfloatが0.30000000000000004になる理由と対処 ― serialize_precisionとPRESERVE_ZERO_FRACTION
json_encodeとは、PHPの値をJSON文字列に変換する関数です。floatは表示用のprecisionではなくserialize_precisionで文字列化されるため、0.1+0.2が0.30000000000000004のまま出力されます。
画面ではちゃんと0.3と表示されていたのに、APIのレスポンスを見たら0.30000000000000004が入っていた、という経験はないでしょうか。私は金額の合計をJSONで返したときに、フロント側から「小数点以下がおかしい」と言われて気づきました。原因はPHPの出力経路によって、floatの文字列化のルールが違うことです。
echoと json_encode で桁数が違うのはなぜか
まず挙動を並べてみます。
<?php
$x = 0.1 + 0.2;
echo $x; // 0.3
var_dump($x); // float(0.30000000000000004)
echo json_encode($x); // 0.30000000000000004
echoはprecision(デフォルト14桁)で丸めて文字列にします。一方、var_dumpやjson_encode、var_exportはserialize_precisionを使います。こちらのデフォルトは-1で、「元のfloatに戻せる最短の表現」で出力するモードです。この-1がデフォルトになったのはPHP 7.1からだと理解しています。
つまり0.30000000000000004は間違った値ではなく、倍精度浮動小数点数が実際に持っている値を、往復しても壊れない形で書いた結果です。画面表示で丸められていたので、見えていなかっただけですね。
丸めるならjson_encodeの前でround する
JSONに出す値の桁数を決めたいなら、エンコード前に丸めるのが一番素直です。
<?php
$total = 0.1 + 0.2;
echo json_encode(['total' => round($total, 2)]);
// {"total":0.3}
round()の結果は「0.3にいちばん近いfloat」なので、最短表現でも0.3と出ます。ini_set('serialize_precision', 14) のようにグローバルな設定を触る手もありますが、var_exportやserializeにも影響するので、私は避けています。出力側の都合を、アプリ全体の設定に持ち込みたくないからです。
金額のように誤差が許されない値は、そもそもfloatで持たず、整数(円やセント単位)や文字列で扱うほうが安全だと思います。
10.0が10になる問題はどう防ぐのか
もう一つ、地味にハマるのが小数部が0のfloatです。
<?php
echo json_encode(['price' => 10.0]);
// {"price":10}
echo json_encode(['price' => 10.0], JSON_PRESERVE_ZERO_FRACTION);
// {"price":10.0}
デフォルトだと10.0は10として出力されます。JavaScript側では10も10.0も同じ数値なので困らないことが多いのですが、型の区別があるクライアント(SwiftやKotlin、JSON Schemaでnumberとintegerを分けている場合など)では、整数として扱われて不整合になることがあります。JSON_PRESERVE_ZERO_FRACTIONを付ければ10.0のまま出せます。
逆方向のjson_decodeでも同じ話があって、10はint、10.0はfloatとしてデコードされます。往復で型を保ちたいなら、両側でこのフラグを意識しておくと安心です。
大きな整数とNaN・INFはどう扱われるのか
json_decodeでPHP_INT_MAXを超える整数を読むと、デフォルトではfloatになり精度が落ちます。桁数の多いIDを扱うときは、JSON_BIGINT_AS_STRINGを付けると文字列として受け取れます。
<?php
$json = '{"id":12345678901234567890}';
var_dump(json_decode($json, true)['id']);
// float(...) 精度が落ちる
var_dump(json_decode($json, true, 512, JSON_BIGINT_AS_STRING)['id']);
// string(20) "12345678901234567890"
もう一つ、NaNやINFはJSONで表現できないので、json_encodeは失敗します。JSON_THROW_ON_ERRORを付けておくと、falseが返る代わりに例外で気づけます。
<?php
try {
json_encode(['ratio' => NAN], JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
echo $e->getMessage(); // INF/NANはエンコードできない旨のメッセージ
}
0除算の結果を平均値として返す集計APIなどで、NANが紛れ込むことがあります。falseを黙って返すより、例外で止まるほうが原因を追いやすいです。
まとめ
floatの桁数の違いは、echoがprecision、json_encodeがserialize_precisionを使っていることから来ます。出力したい桁数はエンコード前のroundで決めて、小数部0を残したいときはJSON_PRESERVE_ZERO_FRACTION、エラーはJSON_THROW_ON_ERRORで拾う。この三点を押さえておけば、だいたい落ち着く気がしています。
よくある質問
Q. serialize_precisionを変えれば0.3と出せますか?
A. 14などに下げれば丸められますが、var_exportやserializeにも影響します。局所的にはround()で丸めてからエンコードするほうが安全です。
Q. JSON_PRESERVE_ZERO_FRACTIONはいつから使えますか?
A. PHP 5系の頃からあるフラグなので、いま実務で使うPHPなら問題なく使えます。
Q. 金額はfloatで返してもいいですか?
A. 誤差が問題になる値は、整数(最小通貨単位)か文字列で返すほうが安全だと思います。