hnwの日記

PHPの奇妙なround関数


(2012/11/01追記) 4年ほど前の記事「PHP5.3.0alpha3のround関数の実装がPHP5.2.6と変わった - hnwの日記」でお伝えした通り、PHP 5.3.0から別の実装が採用されており、本ページで指摘しているような挙動のPHPは既に絶滅危惧種です。念のため。


さて、プログラミングの話題もたまには書いてみます。今回はPHPround関数の挙動が変だ!という話題です。


round()は浮動小数点数を四捨五入する関数で、大抵の言語に同じ名前で実装されているかと思います。ではPHPのround関数の何が問題なのか、ちょっと試してみましょう。

$ uname -sro
Linux 2.6.9-42.0.10.plus.c4smp GNU/Linux
$ php --version
PHP 5.1.6 (cli) (built: Feb 23 2007 06:56:38)
Copyright (c) 1997-2006 The PHP Group
Zend Engine v2.1.0, Copyright (c) 1998-2006 Zend Technologies
$ php -r '$x1=0.49999999999;$x2=0.5;var_dump($x1,$x2,($x1===$x2),round($x1),round($x2));'
float(0.49999999999)
float(0.5)
bool(false)
float(1)
float(1)
$

上記の通り、0.49999999999を四捨五入したら1になってしまいました。ここでクイズです。上記のような結果になった理由は何でしょうか。


上の結果を見た瞬間に「ああ、よくある浮動小数点数の精度とか誤差とかの話題か」と思った方は一定以上経験のあるプログラマなのだろうと思いますが、残念ながらハズレです。0.5は2進で正確に表せる数ですから、丸め誤差が発生するわけでもありません。また、PHP浮動小数点数はCでいうdoubleそのものですから、10進11桁程度であればそれなりの精度で格納できます。


さて、答え合わせの前に、他の言語でも試してみることにしましょう。Rubyだとどうなるでしょうか。

$ ruby -e '$x1=0.49999999999;$x2=0.5;p($x1,$x2,($x1==$x2),$x1.round(),$x2.round());'
0.49999999999
0.5
false
0
1                   
$

なるほど。これは直感通りの結果です。念のためCでも試してみましょうか。

$ cat /tmp/round-test.c
#include<stdio.h>
#include<math.h>

int main() {
  double x1=0.49999999999, x2=0.5;
  printf("%.11f\n%.11f\n%d\n%f\n%f\n",
         x1, x2, x1==x2, round(x1), round(x2));
  return 0;
}
$ gcc -lm /tmp/round-test.c
$ ./a.out
0.49999999999
0.50000000000
0
0.000000
1.000000
$

やはり普通はそうなるはずですよね。


PHPソースコードを少し眺めていると、すぐに不穏な個所にぶつかりました。

#define PHP_ROUND_WITH_FUZZ(val, places) {                      \
        double tmp_val=val, f = pow(10.0, (double) places);     \
        tmp_val *= f;                                   \
        if (tmp_val >= 0.0) {                           \
                tmp_val = floor(tmp_val + PHP_ROUND_FUZZ);      \
        } else {                                        \
                tmp_val = ceil(tmp_val - PHP_ROUND_FUZZ);       \
        }                                               \
        tmp_val /= f;                                   \
        val = !zend_isnan(tmp_val) ? tmp_val : val;     \
}                                                       \

どうやらこのマクロがPHPのround関数の実体のようです。不思議なことに、ライブラリ関数のround(3)を呼ばずに謎の定数PHP_ROUND_FUZZとfloor(3)とceil(3)を使ってround関数らしきものを実装しているようです。round(3)を避ける意味がわかりません。更に不安なことに、少なくとも僕の手元の環境では定数PHP_ROUND_FUZZは0.50000000001なんていう不思議な数に定義されているみたいですよ!こんな見事なマジックナンバーは久々に見ました!ステキすぎてクラクラしちゃいますね!


皮肉はさておき下記のURLを読んでみると、こんな実装になっている理由がわかったような気がします。

この一連のバグ報告を斜め読みで要約すると、「紙とペンで計算すると5.045になるはずの値(実際にはコンピュータ上では約5.04499999999999992894573)を小数点以下第二位までで四捨五入してるのになぜか5.04になった!バグだ!」って騒いでいるプログラマがバグ報告をしてきて、これに対処するために四捨五入の境界値付近(0.00000000001くらいの差)だったら全部0から遠い方に切り上げるようなコード修正をした、ということかと思います。他の言語なら無知なバグ報告者を罵倒して終わるはずのところを、バグ修正として対応してしまうところがPHPらしいのかもしれません。もっとも、これは想像なので実際どうだか知りませんけどね。もし詳しい人がいらしたら教えてください。


というわけで、このエントリの解答としては「どうやらPHPの仕様」ということになります。実は僕が調べ始めた時点での予想は「PHPのバグ」でしたので、これは僕自身にとっても意外な結果です。


蛇足になりますが、http://jp.php.net/manual/ja/function.round.php の先頭には英語版には付いていない注意書き

(訳注:内部的な 2 進数表現と 10 進数表現の差により生じる丸め誤差の影響により 必ずしも小数点以下を四捨五入した結果を返さないことに注意してください。)

が書いてあります。これは訳者の優しさが表れている文章だと思います。一方で、今回のroundの妙な挙動が本当にPHPの仕様だとしたら、PHPの中の人は優しさが無いと思うんですよね。それとも、これはこれで優しさなんでしょうかね?



追記:ここに直接飛んでくる人も多そうなのでご案内を。下記の通り、実はこの話題は延々と続いているのですが、まだまとめきれていません。おそらく重要な話題は、その3「PHP_ROUND_FUZZが0.50000000001と定義されるかどうかは環境次第、多くのLinux環境が該当する」および、その6「PHPは無知なクレーマーへの対策としてバグ修正をしたわけではない」「本件をポータビリティに関するバグとみなして修正すること自体は特に問題がない」といった点だと思います。「PHP以外全員不正解」はround関数とは別の話題ですが、大抵の言語の実装者でさえ浮動小数点数のプロではない、という傍証と言えるかもしれません。それでも誰も困っていないんだから、正確性を議論するなんて無意味なんじゃないでしょうか。また、「PHP5.3.0alpha3のround関数の実装がPHP5.2.6と変わった」の通り、PHP5.3.x系から上記実装は廃止され、新たな実装が採用される予定です。
 
 2007-05-15 PHPの奇妙なround関数
 2007-05-30 round関数で整数を四捨五入してみる
 2007-06-03 PHPのround関数の謎が少し解けた
 2007-06-05 Pythonのround関数の議論を読んでみた
 2007-06-07 round関数その5:そろそろ反撃していいですか?
 2007-06-11 round関数その6:啓蒙とお詫び
 2007-06-16 round関数その7:偶数丸め
 2007-06-23 round関数その8:RubyとPythonのround関数は奇妙じゃないんですか?
2007-07-03 round関数その9:PerlのMath::Roundモジュールについて
 2007-07-28 PHP以外全員不正解
 2007-08-02 Rubyの浮動小数点数リテラルの扱いは正しいのか
 2008-12-07 PHP5.3.0alpha3のround関数の実装がPHP5.2.6と変わった
 2008-12-09 PHP5.2.7のround関数の実装もPHP5.2.6と変わった