脳みそスワップアウト

揮発性なもので。おもにPHPのこととか。

浮動小数点は安全じゃない

浮動小数点数は誤差が出て安全じゃないから、重要な計算の時は使っちゃいけないよという話。
PHP に限った話ではなく、コンピュータ全般。

若手エンジニアからこんな質問がきた。
「5761800 を 小数切り捨てしたら 5761799 になっちゃった!」

<?php
$rate = 4.850/100;
$result = 1188000 * $rate;
echo $result*100;                 // 5761800
echo floor($result*100), "\n";    // 5761799

浮動小数点数には以下のような特徴があり、誤差が発生する。
誤差の許されないような重要な計算には使うことができず、
そういう場合には 整数 もしくは 固定小数点数 で行う必要がある。

  • 有限桁(53)による近似値への丸め
  • 値の近い二値の減算で桁落ち発生
  • 絶対値の大きな値と小さな値のかさんで情報落ち発生

他にも、緩和策がある

  • 浮動小数点数でも、近似値に丸められない値を使う
  • 誤差の発生しうる計算を後回しにする(誤差を小さくする)
    • 主に除算

実際の値はこうなっている

<?php
$rate = 4.850/100;                // 0.0485
$result = 1188000 * $rate;        // 57617.99999999999272404238581657409667968750000000000000000
echo $result*100;                 // 5761799.99999999906867742538452148437500000000000000000000000
                                  //   文字列キャストで少数点以下が丸められ、
                                  //   5761800
echo floor($result*100), "\n";    // floor(5761799.99xxx) = 5761799.0

解決策1. 整数で計算する

但し今度はINTのオーバフローに注意しなければならない。
具体的には BCMath や GMP が必要になる。

<?php
$rate = 4850;                           // 100,000 倍して整数にした
$result = 1188000 * $rate;              // 5761800000
echo $result*100 /100000;               // 5761800
echo floor($result*100 /100000), "\n";  // 5761800

GMP を使うとこうなる

<?php
$rate = gmp_init(4850);
$result = gmp_mul(gmp_init(1188000), $rate);
echo floor(gmp_strval(gmp_div_q(gmp_mul($result, gmp_init(100)), gmp_init(100000)))), "\n";
echo gmp_strval(gmp_div_q(gmp_mul($result, gmp_init(100)), gmp_init(100000)));

解決策2. 固定小数点数で計算する (PHPでは無理)

PHPには固定小数点数型は存在しない。
保持するだけなら文字列で持っておけばよいが、計算時は必ず浮動小数点数になってしまう。

固定小数点数への変換自体は、sprintf() で文字列にキャストすればよい。
指定桁数内で丸められ、固定小数点数の文字列になる。
ceil() / floor() / round() と組み合わせれば、ある程度の精度にはなる。

sprintf('%.53f', $floatVal);

少数切り捨てという今回の仕様なら満たせそうだけど、
$rate と $result で2回丸めが発生しているため誤差は出ている。

<?php
$rate = 4.850/100;                  // 0.04849999999999999450439602810547512490302324295043945
$result = 1188000 * $rate;          // 57617.99999999999272404238581657409667968750000000000000000
$result = sprintf('%.0f', $result); // 小数第1位で丸められ、57618
echo floor($result*100), "\n";      // floor(5761800) = 5761800
echo $result*100;                   // 5761800

緩和策1. 誤差の発生しうる計算を後回しにする

そもそも $rate を 100除算して、$result を100乗算している意味はあるのか?
100 がユーザ入力なのか固定値なのか仕様は不明だが、固定値だとしたら消すことができる。
すると、$rate の誤差が小さくなり、$result の誤差はわからなくなった。

<?php
$rate = 4.850;                       // 4.84999999999999964472863211994990706443786621093750000
$result = 1188000 * $rate;           // 5761800.00000000000000000000000000000000000000000000000000000
echo floor($result), "\n";           // 5761800
echo $result;                        // 5761800

まとめ

  • 誤差が許されないなら 整数 か 固定小数点数 で計算する。(PHPなら整数 + GMP)
  • 誤差が許されるなら計算順をあれこれ工夫して精度を上げることはできる