hnwの日記

PHPのround関数とは一体なんだったのか


(7/3 14:05追記)Javaに関する記述について誤認があったので盛大に書き換えました。Java 6、Java 7、Java 8それぞれで実装が変わっていたようです。

(7/13 23:55追記)本記事中ではroundを四捨五入と言い切ってしまっています。これは筆者がC99のroundを基準に考えているためですが、言語によっては偶数丸めになっているround関数も珍しくありません。ご注意ください。

PHPのround関数について、ネット上で次のような記述を見つけました。

PHP

四捨五入の計算を間違える唯一の言語として畏れられていましたが、そのバグは治っているかもしれません(治ってないかもしれません)

主要なプログラミング言語8種をぐったり解説 - 鍋あり谷あり

各言語を面白おかしく紹介する内容とはいえ、ずいぶん雑な理解だなーという印象です。ゆるふわな話だけでPHPがdisられ続けるのもどうかと思うので、一連のround関数の話題について僕なりの総括をしてみたいと思います。

以下、こんなあらすじで紹介していきます。

  • PHP以外でも四捨五入関数のバグっぽい挙動は珍しくない
    • Rubyã‚„Pythonの四捨五入はエッジケースで間違っていた
    • Javaの四捨五入にはバグなのか仕様なのか微妙なエッジケースがJava 7まで存在していた
    • 四捨五入で小数点以下n位に丸める仕様がそもそも難しい
  • PHPの現在の実装は整数への丸めについては他の言語と同じ
    • 当時話題になったround関数の実装は2009年リリースのPHP 5.3.0でリプレース済

経緯など

元ネタを知らない人向けに経緯を紹介します。

僕が2007年にPHPソースコード中の四捨五入関数の実装に気持ち悪いマジックナンバーを見つけて「PHPの奇妙なround関数」という記事にしたところ、なぜかRubyのまつもとさんの日記に拾われてバズったという事件がありました。

問題になったround関数の実装は率直に言ってやっつけ感あふれるもので、disられても仕方ない内容だったと思います。そもそも僕自身も「PHP気持ち悪いよね、ひどいよね」というつもりで記事を書いたわけです。しかし、非PHPユーザーが安全地帯からPHPを叩くためだけに乗っかってくるのに若干イラっとしたので、「お前らが安全地帯だと思ってる土台も実は脆いんだぜ」と言いたいがために他言語の浮動小数点数周りのバグを探してみました。

そうして調べていく中で、浮動小数点数の四捨五入について何個かエッジケースがあることに気づきました。また、各種プログラミング言語の中の人が必ずしも浮動小数点数に詳しくないこともわかってきました*1。まずはround関数のエッジケースと各言語の対応状況について紹介します。

四捨五入のエッジケース1:0.49999999999999994

0.5より小さい倍精度浮動小数点数の中で最大の数が0.49999999999999994です。これを四捨五入すると、なぜか繰り上がって1になってしまう実装があります。

上記記事で紹介した時点では、RubyとPythonがそのような実装になっていました。これは四捨五入が「引数が正なら0.5足して小数点以下を切り捨てる」という実装になっている場合に発生します。この問題を回避する実装は「引数が正のとき小数点以下を切り捨てて元の数との差が0.5以上なら1.0を足す」です。いやー浮動小数点数って難しいですね。

四捨五入の挙動がマニュアル通りとは言えない処理系がPHP以外にもあった、というのはこの例だけ見ても明らかでしょう。

ちなみに、本件はRuby・Pythonとも最新版では修正済みとなります(かなり前から修正されているはずですが詳細な時期は把握していません)。

四捨五入のエッジケース2:9007199254740991.0

9007199254740991.0を四捨五入するとなぜか繰り上がって9007199254740992.0になってしまう実装があります。この数は倍精度浮動小数点数の仮数部全bitが1であるような数になります。詳細は下記の記事をご覧ください。

これも先ほどの0.49999999999999994と同じく、「引数が正なら0.5足して小数点以下を切り捨てる」という実装のときに問題になる例です。上記記事のタイミングではPythonだけが該当しましたが、その直前くらいまではRubyも同様の実装でした。

もちろん、現在ではPythonの実装も修正されています。

Javaの四捨五入にはバグなのか仕様なのか微妙なエッジケースがJava 7まで存在していた

さて、上記2つのエッジケースについてですが、Java 6のround関数は2つとも間違いっぽい方に丸めます。

public class RoundTest {
    public static void main(String[] args) {
        double d1 = 0.49999999999999994d;
	double d2 = 9007199254740991.0d;
        System.out.println("d1: " + d1); // 0.49999999999999994
	System.out.println("round(d1): " + Math.round(d1)); // Java 6: 1, Java7-8: 0
        System.out.println("d2: " + d2); // 9.007199254740991E15
	System.out.println("round(d2): " + Math.round(d2)); // Java 6-7: 9007199254740992, Java8: 9007199254740991
    }
}

しかし、Java6の挙動はバグとも言い切れません。Java 6のマニュアルには下記のような記述があります。

public static long round(double a)

Returns the closest long to the argument. The result is rounded to an integer by adding 1/2, taking the floor of the result, and casting the result to type long. In other words, the result is equal to the value of the expression:

(long)Math.floor(a + 0.5d)

http://docs.oracle.com/javase/6/docs/api/java/lang/Math.html#round%28double%29

先ほどからイマイチな実装として紹介してきた「0.5足して小数点以下を切り捨てる」が内部実装だと書いてありますので、上の挙動は仕様なのかもしれません。この記述だけでエッジケースの分かるJavaプログラマがどれほどいるかは疑問ですが、文書化されていること自体は素晴らしいと僕は当時から絶賛していました。

ところで、Java 7以降のマニュアルからはこの内部実装に関する記述が消えているようです。トラックバック頂いた記事「JavaのMath.roundがバグっていないと言える可能性について - What will be done tomorrow?」によれば、Java 7で実装が変わったタイミングでマニュアルの記述も変わったということのようです。ただ、このタイミングではd1のみ正しく丸めるようになったようで、d2は繰り上がってしまう実装だったようです。これはマニュアルの記述「the value of the argument rounded to the nearest long value」に反していると言えるでしょう。

Java 8でさらに実装が変わったようで、Java 8からはエッジケース2件とも正しい方向に丸めるようになっています。

本件、OSやCPUにも依存すると考えられるので環境によって再現できたりできなかったりあると思いますが、MacOSX環境のOracle JDKでの実験結果を添付しておきます。

四捨五入で小数点以下n位に丸める仕様がそもそも難しい

良い関数の条件の一つに「挙動が直感的である」ということがあるように思います。特に言語の標準関数であれば、長々説明しないと使えないような関数は害の方が多いくらいだと言えるでしょう。

その意味で、round関数で小数点以下(n+1)位を四捨五入して小数点以下n位に丸める実装は危険です。n=0のとき、つまり普通の整数への丸めについて言えば2進の浮動小数点数でも0.5や1.5などが誤差無く表現できるので問題とはなりませんが、n>0の場合について言えば四捨五入の境界線(0.05や0.005)も丸まった後の数(0.1や0.01)もピッタリ表現できないため、仕様を言語化すること自体が難しいと言えます。

容易に思いつく実装として、10^n倍して整数への丸めを行って10^nで割るというものがあります。しかし、浮動小数点数を不用意に10^n倍するのは誤差の蓄積を生みやすい処理です。実際、次の例ではRubyとPythonが直感に反する結果を返してしまいます。

$ ruby -e 'x=5.015; print x.round(2), "\n";'
5.01
$ python -c 'x=5.015; print round(x, 2),"\n";'
5.01

こうした実装に対する問題点の指摘を3年ほど前に記事にしました。

その後、より良い実装はどのようなものか?という議論に発展してshiroさんの素晴らしい見解を読むことが出来たのは良かったと思っています。

詳細は記事を読んで頂くとして、やはり「2進の浮動小数点数を10進表記で小数点以下n位に丸める関数の直感的な仕様は存在しない」というのが結論かなと思います。

元々のPHPのround関数も、小数点以下n位に丸める挙動を直感的にしようとして失敗してしまったものです。この件から我々が学ぶべきことは採用された実装のまずさについてではなく、小数点以下n位に丸める仕様を採用したという仕様策定段階の失敗についてではないでしょうか。

余談:その後Rubyは小数点以下n位に丸める仕様を採用した

ところで、PHPのround関数に関する議論をしていた2007年当時最新だったRuby 1.8系のround関数には小数点以下n位に丸める指定はありませんでした。つまり、PHPのような悩みは無かったことになります。僕としては、その平和な状態を維持して頂きたいと思っていました。

その後、2008年頃にまつもとさんと飲み会で同席させて頂く機会があり、「Rubyのround関数には小数点以下n位に丸める仕様は絶対に入れない方がいいですよ」的な進言をしたように記憶しています。前後の文脈も何もなくお伝えしたのでキョトンとされていたような気もしますが、僕としてもそんなキモい仕様が採用されちゃう言語はPHPくらいだろうと思っていたので、詳細の話をすることもありませんでした。

ところが、その後Ruby 1.9系のround関数に小数点以下n位で丸める仕様が入ってくることになります。僕は経緯を追っていませんが、ユーザーの声に抗えなかったんでしょうか。これまで誰も不満なく使っているようならいいんですけど。

現在のPHPのround関数の実装

現在のPHPのround関数の処理は、問題のあった実装がPHP 5.3.0で改善されてから変わっていません。詳細は次の記事で紹介しています。

この頃からPHPの仕様変更に関する議論はRFCと呼ばれるWiki文書ベースで行われるようになり、大きい変更は多くの人の目が入るようになりました。また、仕様がきっちりドキュメントとして残るようになったのも大きな利点です。この丸め処理も下記のRFCベースで議論されており、これを読めば誰でも仕様把握できる状態になっています。

ちなみに、新たな丸め処理では整数への丸めは他の言語と完全に同じでケチの付け所はありません。

一方、小数点以下n位に丸める処理は直感的とは言えません。丸め位置を変えながら2回丸めるような処理になっており、一部エッジケースでバグっぽい挙動をするような、ある意味PHPらしい処理になっています。

とはいえ、先ほどから繰り返しているように小数点以下n位に丸める仕様を採用した時点で失敗だというのが個人的見解です。既に紹介した通り他の言語も誤差上等で実装しているわけで、PHPだけが変というのも違う気がします。

そんなわけで、PHP 5.3.0以降のround関数は他の言語と同等と言ってしまって差し支えないでしょう。

ちなみに当時話題になったround関数が実装されていたのはPHP 5.2系ですが、2009年に5.3系がリリースされてリプレースがはじまり、2011年には5.2系のセキュリティサポートが切れている状況です。完全に昔話という感じですね。

おわりに

四捨五入くらい誰でも実装できるって思うでしょ?意外とそうでもないわけですよ。ホントに。

*1:もちろん平均的には詳しい人が多いですし、スーパー詳しい人もたくさんいます

'); $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); } });