初投稿です。
C#からPHPにメイン言語を転換して一ヶ月、初めて見る事象が起きたので備忘録も兼ねて残しておきます。
1. 発生した事象
業務で税込みの金額を出力する場面があったので計算ロジックを組んでいたところ、下記の事象が発生しました。
//価格
$price = 1800;
//消費税
$tax = 1.08;
//小数点以下切り上げ
ceil($price * $tax);//1945
1945か〜うんうん
…!?
1944じゃないの!??
試しにceilで切り上げない素の値を出力してみると
var_dump($price * $tax); //float(1944)
小数点以下無いやん…
これは不味いと思い調査してみると、どうやら浮動小数点数の精度が影響してしまっているようでした。
2. 浮動小数点数とは
「浮動小数点数とは」
2進数を扱うコンピュータにおいて小数を表現するために用いられる表現方法です。
極端に桁数が大きい数値や、小数を扱う際に2進数で表現出来るようにするための方法ですね。
浮動小数点数についての詳しい説明は各々わかりやすいサイトや書籍を参考にしていただければと思いますが、では今回PHPの計算では何故このような誤差が発生してしまうのでしょう。
このあたりについては公式リファレンスにて説明されています。
https://www.php.net/manual/ja/language.types.float.php
浮動小数点数の精度は有限です。 システムに依存しますが、PHP は通常 IEEE 754 倍精度フォーマットを使います。 この形式は、1.11e-16 のオーダーでの丸め処理で誤差が発生します。 複雑な算術演算をすると、誤差はさらに大きくなるでしょう。そしてもちろん、 いくつかの演算を組み合わせる場合にも誤差を考慮しなければなりません。
さらに、十進数では正確な小数で表せる有理数、たとえば 0.1 や 0.7 は、 二進数の浮動小数点数としては正確に表現できません。 これは、仮数部をいくら大きくしても同じです。 したがって、それを内部的な二進数表現に変換する際には、どうしても多少精度が落ちてしまいます。 その結果、不思議な結果を引き起こすことがあります。たとえば、 floor((0.1+0.7)*10) の結果はたいてい 7 となるでしょう。おそらくは 8 を想定していらっしゃるでしょうが、そのようにはなりません。 これは、(この計算結果の) 内部的な値が 7.9999999999999991118... のようになっているからです。
簡単に説明するとPHPにおいて浮動小数点数(float型)をそのまま計算に用いた場合、
2進数が扱える桁数(PHPの場合倍精度のため64bit)を超えてしまう場合があります。
よって変換の過程で扱いきれない数値がIEEE754倍精度で規定されたルールに従って最も近い数値に丸められてしまい、結果誤差が発生してしまうということでした。
先ほどceilで切り上げていない素の数値が整数で表示されていたのは、計算結果に小数点以下があるにも関わらず、近似値1944に丸められてしまっていたからかと思われます。(本来ならありがたい仕様のはずだけど、今回は厄介すぎる…)
試しに表示する計算結果を小数点以下40桁まで広げて表示してみます。
var_dump(sprintf('%.40f', $price * $tax));
//string(45) "1944.0000000000002273736754432320594787597656"
なるほど、この内部的な値が切り上げられてしまうことで想定されない計算結果が出力されてしまうわけですね。
#3. 解決法
公式リファレンスによると、浮動小数点数をそのまま計算に用いるのは推奨されないということでした。
ではどうすれば良いかというと、「任意精度数学関数(BC Math)またはgmp関数を代わりに使用してください。」とのことでした。
私の環境ではgmpは別途インストールが必要だったため、既にインストールされていたBC Mathを選択することにしました。
下記実行結果です。
ceil(bcmul($price, $tax, 1)); //1944
bcmul関数の第一引数と第二引数に乗算の対象、第三引数で乗算時の有効桁数を指定してやることでようやく目的の数値を出力することが出来ました。
もしお手持ちの環境にBC Mathがインストールされていない場合、下記のコマンドでPHPにインストールしてあげる必要があるかと思いますので、別途インストールしてください。
# yum install php-bcmath
###※補足
var_dump(ceil((string)($price * $tax))); //float(1944)
var_dump(ceil(sprintf('%.3f', $price * $tax))); //float(1944)
他にも上記のようにstring型へのキャスト時に小数点以下に0が続く部分を自動で丸める性質を利用した方法や、sprintfで小数点以下の桁数を指定してしまう方法があったのですが、公式リファレンスで推奨はされていなかったために不採用としました。
任意精度数学関数(BC Math)またはgmp関数に対して環境にインストールが不要ではあり「こっちの方がいいよ」という方もいらっしゃると思うので、もしご意見があればコメントをいただけると有り難いです。
#4. まとめ
以前メインで使っていたC#では小数計算の際、金融系や財務で用いられるdecimal型という精度の高いデータ型を用いており、decimalでは今回のようなケースに出くわしませんでした。
PHPは型の定義の必要がないためコーディングの自由度が増すのは大きな利点だと思いますが、その分自分が書くコードは必ずしも正しくない、という意識をより一層持つ必要があるのかもしれません。
今回は偶然にも計算ミスに気がつくことができましたが、言語毎の違いは正確にキャッチアップしていく必要があるという教訓を得ることができました。