こんにちは、R&D事業部のチャンウクです。
今回の全研ブログでは、R&D事業部でエンジニアとして働く私たちが、実際に経験したトラブルシューティングについてご紹介します。

■ 概要

R&Dでは ライター募集求人サイト「ライターステーション」を開発しております。

ライターステーション

このメディアは、ライター専門の求人サイトで、お仕事の依頼から、ライターへの報酬支払まで、ライターステーション編集部で行っています。

報酬に関する税率や税金、ライター評価システム内の仕組みで平均値を扱うため、実数表現を多く使います。

s_写真1

■ 問題発見

ライターステーションでは、ライターのレベルに合わせて業務を依頼していますが、その判断基準となる、ライターを評価するページ(管理システムからのみ閲覧可能)で一部のメンバーの環境で問題が発生しました。

問題発生

ここの平均値は、小数点第二位で四捨五入をしているため、平均値は6.33333333 → 6.3になるはずですが、6.2999999999999998が出力されました。

なぜ6.2999999999999998が表示されたのでしょうか?
事例を挙げて、ご説明します。

■ 原因分析

すでにご存知だと思いますが、コンピューターは全てのデータを01の2進数体系で表現します。

6.3をコンピューターで表現するために2進数に変換してみます。

6.3(10)→ 110.010011001100110011001100110011001100110011… (2)

10進数では割り切れた6.3が、2進数では循環小数になってしまいました。

実数の小数点は無限に増えることができる反面、コンピューターの保存空間は限定されているので、上記のような無限小数を正確にメモリー空間に載せる方法は存在しません。
無限小数を正確に表示するためには無限のメモリー空間が必要になるわけです。

なので、正確度を捨て、効率的に保存をするためにできたのが、
IEEE 754 Floating Point(浮動小数点数)標準です。

IEEE 754では、決められたスペースに入力された実数の近似値を保存して実数を扱います。

早速、Floating Pointで6.3(10)を表現してみましょう。
IEEE 754はSingle Precision、 Double Precision、 Extended Double Precisionといった規則がありますが、いずれも処理単位が違うだけで、本質的な変換方法は一緒です。

ここでは、PHPが採用しているDouble Precision 「Binary64」 を使います。

Floating Point Binary64は、64bitの大きさで正規化された実数を3つの部分に分けて表現します。

1Bitの符号 S(正数は0、 負数は1)
11Bitの小数点位置 E(exponent、指数)
52Bitの有効数字 M(significand、仮数)

(-1)^S ×(1 + M)× 2^E

イメージ 1

6.3を2進数に変換して、

110.010011001100110011001100110011001100110011…(2)

このような2進数は

(-1)^0 × (1 + 0.10010011001100…)× 2^2
上記のように正規化されます。

Sは0なので符号bitに0を入れます。

イメージ2

有効数字は52bitで、桁数が52を超える場合53Bitで四捨五入してメモリーに搭載。

1001001100110011001100110011001100110011001100110011

イメージ3

※ 正規化過程で必ず有効数字の最上位Bitは1になりますので、これを保存するのは無駄です。
これを省略して53桁までの有効数字が表現できます。(54bitで四捨五入)

指数は11Bitで2048まで表現できますが、
最小の[0000000000]は数字0を表現するため、
最大の[11111111111]は無限大を表現するために、すでに予約されています。

また、指数が正数の場合と負数の場、合両方存在するため、符号が必要ですが、
11bitの中で実際使える範囲は00000000001(1)から11111111110(2046)までです。

これを負数/正数で半分ずつ分けて使いたい場合は、2046の半分である1023を引いて00000000001(1)を -1022、11111111110(2046)は1023として使用することになっています。
保存する時は逆に1023を足すことで対応ができます。(ここではこの1023を偏向・かたよりを意味する「Bias」と呼びます。)

ここで先程の式をもう一度確認してみましょう。

(-1)^0 × (1 + 0.10010011001100…)× 2^2

指数は2なので、bias 1023を追加して1025
10000000001

イメージ4

このようにユーザーが入力した6.3(10)は、

符号 0 指数 10000000001
有効数字 1001001100110011001100110011001100110011001100110011

の形でメモリーにセットされます。

これを逆正規化すると有限小数

110.01001100110011001100110011001100110011001100110011(2)

つまり、6.3(10)

6.29999999999999982236431605997495353221893310546875(10)

近似値に変換され扱われていることが分かります。

これはバグでもエラーでもありません。
2進数体系であるパソコンの実数表現の限界なのです。

ここで疑問が浮上します。
なぜPHPプログラム上では6.3で表示される人も、6.2999999999999998で表示される人もいたのでしょうか?

s_写真2

PHPはphp.iniファイルの中にPrecisionという設定項目を持っています。

Precisionは、Floating Pointを画面に表示するとき、設定されている数字に該当する桁数で、有効数字を切って四捨五入をしてくれる機能で、Floating Pointの仮数部精密度を調節するとのことです。
Defaultは14になっています。

「Case : Precision = 14」
6.29999999999999982236431605997495353221893310546875
14桁目で四捨五入をして6.3が表示されるようになるものです。(表示だけ)

「Case : Precision = 17」
6.29999999999999982236431605997495353221893310546875

Precisionを17に設定したバージョンを使っているメンバーは、
四捨五入する箇所が変わって6.3の代わりに6.2999999999999998が表示されたというワケです。

そこで、17になっていたメンバーの設定値を14に変更して表示形式を揃えました。

PHPがDefault Precisionを14にしてあるのは、表示の安全性を考えたPHPならではのスタイルだとも見えます。
実際、14桁目で四捨五入することで、日常で使われる実数は安全に表示できます。(表示だけ)

0.1 = 0.1000000000000000055511151231257827021181583404541015625
1.08 = 1.0800000000000000710542735760100185871124267578125
99.9 = 99.900000000000005684341886080801486968994140625

しかし厳密にいえば、これは間違った表示であり、開発者に混乱を引き起こすことがあります。

■ 解決

写真3

これでスッキリしました。

バグだと思っていたのは実はコンピューターの仕様だったんです。

正確な実数を表示、計算するためには、
・文字列基盤のDecimal Type
・BCMath関数
が出ているので、そちらを使用することをおすすめします。

■ 最後に

実は、「Floating Pointで金額計算は絶対しない」という格言はプログラマーの中では有名な話です。
戦闘機を作る規約である JSF Air Vehicle C++ Coding Standards や 車両エンベデッドシステムコーディング規約の MISRA-C等にもFloating Pointに関する絶対的な規約が書かれていて、 近似値によるバグを防ぐために安全を追求していることを確認できます。

『Floating Pointの不正確、不精密な特徴』を考えた上で、プログラミングするようにしましょう。

専門的なお話でしたが、いかがでしたでしょうか?
少しでもおもしろいと思ってくださった方、ぜひR&D事業部に遊びに来てください!!

以上、R&D事業部のチャンウクでした。