hnwの日記

ECMAScriptの浮動小数点数の丸め仕様がスゴい

ECMAScript浮動小数点数の丸め関数である Number.prototype.toFixed() について調べてみたところ、浮動小数点数をわかっている人が作った硬派な仕様だと感じたので、解説してみます。

浮動小数点数の丸めの善し悪しについて

私はプログラミング言語浮動小数点数の丸め処理に興味があり、過去に関連記事を30本以上書いています。こうした活動から得られた知見として、良い丸め関数には次のような性質があると考えています。

  • 仕様がシンプルで直感的であること
  • 仕様が抜け漏れなく文書化されていること
  • バグを作り込みにくい仕様であること

どれも良い関数の一般論のような話ですが、丸め処理に限って言えば簡単な話ではありません。そもそも浮動小数点数の性質が人の直感に反するため利用者にとっても実装者にとっても罠が多く、結果として上の条件を満たせないことが多いのです(私が面白いと感じるポイントでもあります)。

toFixed()の仕様

toFixed()ECMAScriptのNumber型のメソッドで、引数で指定された桁数までの最近接丸めを行います。無引数で呼ばれた場合は整数への丸めを行います。返り値は10進固定小数点数(要は普段よく見る小数)の 文字列 となります。

丸め方式は四捨五入です。つまり、最近接となる値が2つある場合は0から遠い方に丸めます。例えば (0.5).toFixed(0)"1" を返します。

このメソッドはJavaScript 1.5(1999年, ECMA-262 3rd Edition)で採用されており、現代のJavaScript実装であれば何であろうと動くはずです。

下記URLはES8の toFixed() の仕様ですが、記述内容は初出からほとんど変わっていません。

この仕様の素晴らしい点

この toFixed() の仕様を読んでみて、他の言語ではあまり見たことのない優れた点に気づきました。順に紹介します。

ポイント1:返り値が文字列型である

この関数は丸めの結果を文字列型で返します。これは明確な意図と知見が感じられる仕様だと思います。

仮にこの関数が浮動小数点数を返すとしましょう。(1.23456).toFixed(4) の結果は10進表記で1.2346になりますが、コンピュータ上の浮動小数点数は2進数なのでピッタリ表現できません。つまり、返り値の型が浮動小数点数だったとすると、正確な値に一番近い浮動小数点数を返すような仕様になります。言い換えると、返り値の時点で誤差を含んでしまうわけです。

実際には toFixed() の返り値は文字列ですから、10進小数を文字列の形で誤差なく表現することができます。こんな仕様が偶然生まれるわけがありません。この仕様を考えた人物は浮動小数点数の性質に詳しいだけでなく、「標準関数が仕様として誤差を含むべきではない」という強い意思を持っていたのではないでしょうか。

補足しておくと、ECMAScriptには浮動小数点数を整数に丸めるMath.round()という関数もあります。こちらは丸め桁数の指定オプションを持たず、必ず整数への丸めになるので、返り値に誤差が入ることはありません。ECMAScriptのNumber型では約9000兆までの整数をピッタリ表現できるので、正確な四捨五入が実現できます。

このように、ECMAScriptでは整数への丸め関数と10進で小数点以下n位までに丸める関数とをそれぞれ別の関数として実装しています。似た機能を持った関数を2つ作り、しかも一方は返り値の型が違うというのは言語利用者にとっては不親切かもしれません。それでもなお言語仕様として正確さの方が重要だ、とECMAScriptの仕様策定者は考えたのでしょう。これが言語利用者にとってベストの選択だったかは疑問も残りますが、浮動小数点数の都合だけで言えばベストだと思います。

ちなみにこの仕様(ECMA-262 3rd Edition)は1999年に作られています。そんな古い時期からこんな知見が文書化されていたとは驚きです。

ポイント2:最近接の値を「数学的に正確な値」で判断している

さらにこの仕様には面白い点があります。与えられた浮動小数点数を上下どちらに丸めるか計算する際に「the exact mathematical value」で比較しなさい、と書いてあります。言い換えると、丸め方向を決定する際に浮動小数点数の加減算や比較演算をしてはいけない、と言っているのです。ヤバいですね。

浮動小数点数で複雑な計算をすると計算順序や計算方法によって結果が変わってしまい、実装依存の挙動やバグの原因になりがちです。浮動小数点数演算を減らすことでバグが入りにくくなるという観点でも良い仕様だと思います。

この実装はそれほど難しくありません。丸める対象の数を10進小数で書き下して丸める桁を四捨五入すれば正確な計算をしたことになります。つまり、 (5.015).toFixed(2) の場合、5.015をIEEE754倍精度浮動小数点数として格納すると5.01499...という値になるので "5.01" となります。一方 (5.025).toFixed(2) であれば5.02500...となるので "5.03" となります。他の言語では5.01や5.03を浮動小数点数として比較するような実装もあるのですが、ECMAScripttoFixed() の実装としては誤りです。

この仕様は言語利用者から見て直感的ではないように見えるかもしれません。5.015や5.025が浮動小数点数としてピッタリ表現できないことを知らない人が「toFixed()は四捨五入のはずなのに(5.015).toFixed(2)"5.01"になった、バグだ」と誤解するかもしれませんが、このような混乱は他の仕様にしたとしても避けようがありません1。言語利用者に対しては「浮動小数点数に詳しくなってください」とスパルタ指導していくしか解決策は無いでしょう2

個人的には、「浮動小数点数の性質について理解していれば」利用者から見ても一番シンプルで説明しやすい仕様だと思います。

ポイント3:仕様の大半が擬似コードで表現されている

ECMASCriptの仕様全体の特徴とも言えるのですが、仕様の大半が擬似コードスタイルで記述されているのも面白い点です。具体的には、toFixed() の仕様は次のようになっています。

f:id:hnw:20190226101807p:plain
toFixed() の仕様(抜粋)

これは言語の実装者には非常に親切で、仕様の抜け漏れを減らして実装ごとのブレを出にくくする効果があるのではないでしょうか。他の言語の仕様ではあまり見ない気がしますが、こうした書き方も選択肢としてはアリだと思います。

一方で、言語の利用者が詳細まで読み解けるかは疑問です。仕様の副読本のようなものが必要かもしれません。

実際のブラウザでの挙動

この記事を書くにあたり、toFixed() の挙動を色々なブラウザで確認してみました。

IE8は目茶苦茶でした。IE9からIE11は幾分マシになっていますが、「the exact mathematical value」の意味がわかっておらず浮動小数点数のまま計算して妙な結果を生んでいそうな印象でした。これらのブラウザでは残念ながら toFixed() を使わない方が良さそうです。

一方モダンブラウザはほぼ完璧で、ChromeSafariFirefoxについてはバグが見つかりませんでした。Edgeだけは惜しい印象で、下記のバグが修正できれば完璧になりそうです(私も2件issueを上げていますが原因は同じに見えます)。

IEはそろそろ死んだ扱いしていいと思うので(?)、ようやく安心して toFixed() が使える時代が来そうですね。

追試したい方は色々なブラウザで下記URLを確認してみてください。

まとめ

  • ECMAScript浮動小数点数の丸め仕様は誤差が入らないことを目標にしているようで素晴らしい
    • toFixed() は10進n桁への丸めを誤差なく実現するため文字列型を返す
    • Math.round() は誤差なく数値型を返すため10進n桁への丸めを提供していない
  • 似て非なる関数が2個あって言語利用者は不便に感じるかもしれない
    • 仕様としては利用者の混乱にはあまり興味がなさそう、原理主義すぎる気もする

他の多くの言語ではround関数1つで実現している機能をECMAScriptでは(おそらく)意図的に2つに分けていること、またその関数分割には十分合理性があることを紹介しました。これはスゴいし面白いと思うんです、というお話でした。


  1. この誤解を起こさないためだけの実装 hnw/precise-round を私が提案していますが、美しくないので実際の言語で採用されることは無いと思います(私としても、あくまで議論のための実装だと思っています)

  2. そもそも浮動小数点数の性質に詳しければ内部的には100倍や1000倍した整数で処理し、表示のときだけ小数点数表示するなどしてこの手の罠を踏むことはなくなるはずです。