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. 誤差が問題になる値は、整数(最小通貨単位)か文字列で返すほうが安全だと思います。

類似投稿

コメントを残す

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