hnwの日記

round関数その6:啓蒙とお詫び

Referer一覧の存在に気づいたので、リンクして下さっている方々の文章を今更読んでいます。ただ、おそらく浮動小数点数の扱いが拙いのをround関数のせいにしているような文章を見つけました。もしそういう誤解があれば非常に残念だと思うと同時に、これだけ注目を集めたからには浮動小数点数一般の罠について簡単に紹介してみても良いかな、と思いつきました。浮動小数点数まわりでハマった人がここに検索でやってくることがあるかもしれません。


そんなわけで、今回のテーマは啓蒙です。浮動小数点数の不思議さについて簡単な紹介記事を書いてみました。また、第1回記事中の誤っている点について、1点訂正をします。今回はイメージ先行で書いてみましたが、伝えていないことはあっても嘘は書いていないつもりです。正確な内容が知りたくなった方はWikipediaの記事「浮動小数点数」を読むと良いでしょう。または、そこからリンクされている「浮動小数点演算について」を読めば完璧な知識を手に入れられると思います*1。


以下、この記事中ではIEEE64bit浮動小数点数のことを単に浮動小数点数と書くことにします。これは、Cで言うdouble型のことです。また、PHPやPerlやRubyやPythonで単に小数点を含んだ数を書いた場合にはIEEE64bit浮動小数点数として扱われます。


さて、浮動小数点数の正体をざっくり言うと、「大きい数も小さい数も表せる、有効数字が10進16桁程度の数」といったところです。僕らが知っている普段の小数と違う点は下記の2点です。

  1. 中身が2進数である
  2. 表現できる桁数が決まっている

そんなことわかってるよ、と思う人も多いかと思いますけど、本当にそうでしょうか。浮動小数点数の世界で「0.1を10回足してもおそらく1にならない」というのは教科書にも書いてあるようなことですけど、この事実から「よくわからないけど誤差が入るから怖いんだな、じゃあ使わないようにしよう」というくらいの認識の人が案外多いのではないでしょうか*2


0.1を10回足す話をもう少し詳しく見てみましょう。とりあえずPHPで実験してみます。

$ php -r '$x=0.1; $y=0; for($i=0; $i<10; $i++){$y+=$x;} printf("%.19f\n", $y);'
0.9999999999999998890
$ php -r '$x=0.1; $y=$x*10; printf("%.19f\n", $y);'
1.0000000000000000000

0.1を10回足すと1より少しだけ小さい数に、0.1を10倍するとちょうど1.0になりました。10回足すのと10倍するのとで結果が違うのは普段の常識で考えると不思議ですが、これはPHPが変なわけではありません(本稿最後のおまけ参照のこと)。浮動小数点数の世界では普段養ってきた直感が成り立たないだけのことです。


0.1が浮動小数点数でピッタリ表せない、ということはご存知の方が多いと思います。浮動小数点数でピッタリ表せない数は、一番近い数で表現されます。コンピュータ上の表現になると同時に誤差が入るわけです。


2進数はわかりにくいので、10進数で例を挙げてみます。たとえ話になってしまいますけど、わかりやすさのために我慢してください。10進で3桁までしか表せない世界を考えてみます。この世界で1/3を6回足してみましょう。この世界では1/3はピッタリ表現できず、0.333が1/3に一番近い数です。この世界の計算ルールとして、計算の結果が3桁を超えた場合には、4桁目を四捨五入します。

  • 0.333+0.333=0.666
  • 0.666+0.333=0.999
  • 0.999+0.333=1.332→1.33
  • 1.33+0.333=1.663→1.66
  • 1.66+0.333=1.993→1.99

1/3を6回足すと1.99になりました。では、6倍したらどうなるでしょうか。

  • 0.333*6=1.998→2.00

6回足すのと6倍するのとでは異なる結果になりました。原因は2点、1/3が10進数でピッタリ表せないことと、桁数に制限があることです。浮動小数点数でも似たことが起こります。これが0.1を10回足すのと10倍するのとで結果が違う理屈です。


ここで注目して欲しいのは、1/3がピッタリ表せない世界なのに、1/3を6倍した結果はピッタリになっていることです。なぜこんなことが起こるかというと、1/3がこの世界でピッタリ表せなかったのと同様、0.333を6倍した数がこの世界ではピッタリ表現できないからです。現象として見ると、1/3を0.333にしたときに生まれた誤差が、6倍すると消えています。浮動小数点数で0.1を10倍した場合も同様ですね。このように、誤差は蓄積する可能性もありますが、打ち消し合うこともあります。これも普段の直感とは異なる点ではないでしょうか。


さて、ここで最初の記事を思い出してみましょう。

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

hnwの日記 - PHPの奇妙なround関数

浮動小数点数の挙動が直感に合わないことを経験しているプログラマほど、この説明で「バグ報告者の勘違いで、バグではないんだな」という風に納得してしまうような気がします。しかし、自分で書いておいて随分な話ですけど、実はこれでは全然説明になっていません。


roundの実装がどうなっていたかというと、小数点以下第2位までに丸める場合、引数を100倍して0.5を足してfloor(小数点以下切り捨て)をして100で割る、ということでした。ところで、0.1を10倍すると誤差が小さくなる例を今見たばかりですね。浮動小数点数の世界は普段の10進小数の常識は通用しないわけですから、5.0449999…も100倍して0.5を足してみる必要がありそうです。それでは実験してみましょう。

$ php -r '$x=5.045; printf("%.19f\n", $x);'
5.0449999999999999289
$ php -r '$x=5.045; $y=$x*100; printf("%.19f\n", $y);'
504.5000000000000000000
$ php -r '$x=5.045; $y=$x*100+0.5; printf("%.19f\n", $y);'
505.0000000000000000000
$ php -r '$x=5.045; $y=($x*100+0.5)/100; printf("%.19f\n", $y);'
5.0499999999999998224

えええええええ!round(5.045,2)は5.05になるのが正しいんだ!むしろバグ報告者の方が正しいよ!*3



無知なバグ報告者とか書いちゃって本当にごめんなさい。反省してます。言い訳になりますけど、改めて浮動小数点数って直感が成り立たない世界ですよね。


そんなわけで第1回記事を訂正しておくと、PHPは無知なクレーマーへの対策としてバグ修正をしたわけではありません。このバグ報告された挙動の原因は3回目の記事「PHPのround関数の謎が少し解けた」で議論した、x86ç³»CPUの浮動小数点数レジスタが80bitあることからくる計算結果の差です。これをポータビリティに関するバグとみなして修正すること自体は特に問題がありません。あとは修正自体が正しいかどうかの問題です。


当初は僕自身がこの辺りを理解していなかったにもかかわらず、3回目の記事を書く頃にはこのことを読者が理解しているのを前提に記事を書いていたように思います。その点についてもごめんなさい。実際には気づいていない人の方が多いくらいだったのではないでしょうか。


今回のような嘘が紛れ込んでいるなど、本シリーズは全部通して読んでも読みにくくなっている点がありそうに思います。次回か次々回にはまとめの記事を書きたいですね。


おまけ:脱線して色々な言語でやってみました

$ perl -e '$x=0.1; $y=0; foreach(1..10){$y+=$x;} printf("%.19f\n", $y);'
0.9999999999999998890
$ perl -e '$x=0.1; $y=$x*10; printf("%.19f\n", $y);'
1.0000000000000000000
$ ruby -e 'x=0.1; y=0; (1..10).each{|i|y+=x}; printf("%.19f\n",y);'
0.9999999999999998890
$ ruby -e 'x=0.1; y=x*10; printf("%.19f\n",y);'
1.0000000000000000000
$ python -c 'x=0.1; print "%.19f" % reduce(lambda n,m:n+m, [ x for i in range(10)]);'
0.9999999999999998890
$ python -c 'x=0.1; y=x*10; print "%.19f" % y;'
1.0000000000000000000 
$ echo 'int main(){double x=0.1,y=0;int i;for(i=0;i<10;i++){y+=x;}printf("%.19f\n",y);}' | gcc -xc -; ./a.out
0.9999999999999998890
$ echo 'int main(){double x=0.1,y=x*10;printf("%.19f\n",y);}' | gcc -xc -; ./a.out
1.0000000000000000000

こういうときは普通Cで実験するものだと思いますけど、LLでも普通に確認できますよ、という話です。各言語の人たちが自然に書く内容かどうかは残念ながらわかりませんけど。



追記:トラックバックが来ていたhttp://d.hatena.ne.jp/lethevert/20070612/p2について、僕も少し不思議だったんですが、続報がありますね。toString()するときに10進で一番短くなる表現にするというのは現実世界との調和という意味では非常に良い落としどころですし、疑問も解消してスッキリです。疑問点があってもキッチリ解決できるような、こういうマニアックな方を見ると気持ちがいいですね。僕もできるだけ頑張りたいものです。

*1:実は僕も全部は読んでいませんが

*2:もちろん、それはそれで良い判断だと思います。君子危うきに近寄らず、とも言いますし。

*3:正確に言えば、Python風にround関数の内部で拡張精度で評価されても構わないポリシーであれば5.04でも5.05でも間違いでは無い、ということですけどね。

'); $entries_chunk.insertBefore(sections[0]); } else { chunk_id += 1; var $prev_entries_chunk = $entries_chunk; var $read_more_link = $('

これ以前の記事を表示する

'); $read_more_link.on('click', {chunk_id: chunk_id}, function(e){ $(e.target).hide(); $(this).remove(); $('#entries-chunk-' + e.data.chunk_id).fadeIn("slow"); }); $prev_entries_chunk.append($read_more_link); var $entries_chunk = $('
'); $entries_chunk.hide(); $entries_chunk.insertAfter($prev_entries_chunk); } } $(sections[i]).appendTo($entries_chunk); } });