hnwの日記

PHP5.3.0alpha3のround関数の実装がPHP5.2.6と変わった


(2016/07/02 20:00追記)本稿をさらに掘り下げた記事「PHPのround関数を読み解く (1)丸め桁数が大きすぎ・小さすぎる場合」「PHPの新しいround関数を読み解く (2)pre-roundingの意味」を書きました。合わせてご確認ください。


12月4日付でPHP5.2.7とPHP5.3.0alpha3が同時リリースされましたが、これに関連して毎度おなじみPHPのround関数の話題です。相変わらず記事は長いので簡単なまとめから。

  • PHP5.3.0alpha3ではこれまでのPHPのいずれとも違うround関数が実装されました。少々疑問は残るものの、比較的マシな実装だと僕は考えています。今回は0.50000000001のような不思議な数は含まれていませんし、問題が起こる例が以前より減ったように思います。
  • 第3引数で丸め方式を選択できるようになりました。四捨五入(デフォルト)や偶数丸めなど、4種類の丸め方式が選択可能です。

背景

以下、現時点での僕の理解を書いていきます。


PHPでは、bugs #24142への対応として、PHP 5.1.0からPHP_ROUND_FUZZという定数が導入されました。これは、10進小数で考えれば不思議な丸め結果になることへの防止策として、正の数を丸める場合であれば0.5を足してから切り捨てるはずのところを、一部環境のみ0.50000000001(fuzz)を足す、というものです(参考:「PHPの奇妙なround関数」)。浮動小数点数への理解度が低い初心者が混乱するのを防ぐための修正と言っても構わないと思います。


この実装に対し、「俺がもっとマシな実装を考えたぜ!」という人がPHP本家にhttp://wiki.php.net/rfc/roundingという文章を送りつけ、提案が採用されてPHP5.3.0alpha3から実装された、というのが今回の変更です。このままいけば、PHP5.3.0以降この実装が標準になるはずです。

新しいround関数の実装

PHP5.3.0alpha3のround関数の実装について、http://wiki.php.net/rfc/roundingに書いてある例で説明します。以下、正の整数に対する1引数のroundはfloor(x+0.5)で計算するとします。


round関数で1.255を小数点以下第2位までに丸めることを考えます。このとき、素直なround関数の実装であれば下記のように計算するはずです。

  • round(1.255 * 100) / 100


3桁目で四捨五入と考えれば、一見すると1.26が答えになりそうな気がします。ところが、1.255をIEEE754倍精度浮動小数点数で表現すると約1.2549999999999998934ですので、100倍して丸めるとround(125.499…)となり、125となってしまいます。全体の計算結果は1.25となります。Pythonも上記の素直な実装をしており、実験すると確かに1.25が返ってきます。

$ python -c 'print "%.19f" % round(1.255, 2)'
1.2500000000000000000


ところが、PHP5.3.0alpha3では異なる結果が返ってきます。

$ php-5.3.0 -r 'printf("%.19f\n", round(1.255, 2));'
1.2600000000000000089


これは、PHP5.3.0での計算方法の変更によるものです。PHP5.3.0では、round(1.255, 2)を次のように計算します。

  • round( round( 1.255 * 100000000000000) / 1000000000000) / 100


こうすると1つ目のroundが125500000000000を返し、2つ目のroundはピッタリ126を返すので、全体の計算結果は1.26となります。


PHPの新しい実装は素直な実装に比べると複雑な計算をしており、正確な結果が得られるというよりは、誤差が蓄積しそうな計算に見えます。この意味について説明します。

PHPの新しい実装の意味

PHPの新しい実装を日本語で説明すると、次のようになります。結論から言うと、数学的な根拠があるような方法ではありませんが、これまでの実装よりは目的を実現できています。

  • 丸め対象の引数を10^14〜10^15の間の整数にします。上の例であれば10^14を掛けてから整数に丸めます。(内側のround)
  • 上で得た結果を10^(14-丸め桁数)=10^12で割り、再度丸めます。(外側のround)
  • 10^(丸め桁数)で割ります。


1つ目のroundは、できるだけ大きく正確な整数にすることが目標です。10^16は2^53より大きくなってしまうため、これ以上の桁数にしてしまうと精度が維持できなくなるということでしょう。


次の割り算で、丸め計算のために十分な精度で(元の数)×(10^丸め桁数)を計算しています。2つ目のroundは素直な実装のroundと同じ意味です。


結局何をしているかというと、1つめの掛け算とroundとで1.255に十分近い小数を全部1.255と同一視しているだけで、それ以外は素直な実装と同じです。つまり、1つ目のroundは例のfuzzと同じような役割をしています。

良くなった点(1)

今回の実装がfuzzを使った実装より優れている点の1つ目は、整数への丸め(小数点以下第1位での四捨五入)で妙な誤差が入らなくなったことです。round(0.49999999999999)は0になるようになりました。整数への丸めについては、今回の実装は素直な実装(=他の言語のround関数)と同一です。

良くなった点(2)

ここで、例のfuzzの導入のきっかけになったとも言える「初心者の混乱」について見直してみます。実は、これまでのfuzzでは目的を果たせない例があります。

$ php-5.2.6 -r 'var_dump(round(1111111.265, 2));'
float(1111111.26)


浮動小数点数は少し小さくなることがあるから、0.5より少し大きい数を足してしまおう、というのがfuzzの意図だったわけですが、対象の数が大きくなってくると情報落ちが起こってしまい、fuzzが無視されてしまうのです。


「初心者の混乱」への対策の意味では、PHP5.3.0の実装は完璧です。今回は近い数を同一視する方針にしたため、大きい数が対象でも目的を果たします。

$ php-5.3.0 -r 'var_dump(round(1111111.265, 2));'
float(1111111.27)

良くなった点(3)

浮動小数レジスタが80bitな環境でも、64bitでroundの計算を行うようになったようです。環境の差がなくなる方が混乱は小さいでしょう。

良くなった点(4)

round関数が第3引数を受けつけるようになりました。四捨五入(Round half up)だけでなく、Round half down、偶数丸め(Round half even)、Round half oddのいずれかが選べます。誰が喜ぶのかは疑問ですけど。

感想など

僕の意見としては、どうせround関数なんて精度を落とす演算なんだから、そこまでひどくなければどんな実装でもいいと思うんですね。そこまで細かい挙動の差が影響するコードを書く人ならCのソースコードを流し読みするくらいは神経を使うでしょうから、そんな人たちへの気配りは無用です。ただ、今までその実装について一切ドキュメントが無かったのがひどい点だというのがこれまでの僕の主張です。


今回の実装についても、ドキュメントが残るという点について非常に素晴らしいと思います。中身については、数学的根拠がある実装かと期待していたので少しガッカリしましたが、今までの実装よりは良い実装だと僕は思っています。