小数(浮動小数点数型)の計算が思った結果にならない理由と解決法
from https://dobon.net/vb/dotnet/beginner/floatingpointerror.html
小数(浮動小数点数型)の計算が思った結果にならない理由と解決法
小数(浮動小数点数型)の計算が思った結果にならない理由と解決法
Decimal型はいつ使うか?
小数の計算をしていて、その計算結果が常識では考えられない、変な値になったという経験はないでしょうか?単精度浮動小数点型であるSingle型(C#では、float型)や、倍精度浮動小数点数型であるDouble型(C#では、double型)を使った計算ではそのようなことがあります。ここではそのようなことが起こる理由と、その対策を説明します。
また、Decimal型はどのような時に使うのかについても説明しています。
不可解な小数の計算の例
「0.1 + 0.2 = 0.3」は正しいでしょうか?数学では、当然そうでなくては困ります。
それでは、VB.NETで「0.1 + 0.2 = 0.3」(C#では、「0.1 + 0.2 == 0.3」)は「True」になるでしょうか?実は「False」になります。信じられない方は、実際に以下のようなコードで試してみてください。
Console.WriteLine(0.1 + 0.2 = 0.3)
'False
Console.WriteLine(0.1 + 0.2 == 0.3);
//False
もう一つ例を紹介します。今度は2000円の商品の消費税(5%)がいくらかを計算します。1円以下は繰上げます。
Dim price As Integer = 2000 Dim rate As Single = 0.05F Console.WriteLine(Math.Ceiling(price * rate)) '101
int price = 2000; float rate = 0.05f; Console.WriteLine(Math.Ceiling(price * rate)); //101
このコードを実行してみると、結果は「100」にはならず、「101」になります。なぜこんなことが起こるのでしょうか?
不可解な計算結果の理由
このような数学のルールを無視した計算結果を返すなんてバグに違いないと思われるかもしれませんが、そうではありません。これにはちゃんとした理由があります。
SingleやDoubleのような浮動小数点数型は、値を2進数で格納しています。しかし、ほとんどの10進数の小数は2進数で表現することができません。そのためこのような値はSingleやDouble型では近似値でしか表現することができず、その誤差が上記のような非常識的な計算結果として現れます。
例えば十進数の「0.1」を2進数に変換すると「0.0001100110011…」となり、「0011」の部分が永遠に循環します。よって「0.1」をSingleやDouble型に格納するには、適当な桁で丸める必要があります。丸め方は、最近接偶数への丸めです。結果として「0.1」はDouble型では2進数で「0.0001100110011001100110011001100110011001100110011001101」となります。これを10進数に戻すと「0.1000000000000000055511151231257827021181583404541015625」となり、「0.1」ではなくなります。
ただし整数は、有効桁数(Singleは7桁、Doubleは15桁)の範囲内であれば、正確に格納できます。また小数であっても、k/(2^n)(kとnは整数)で表すこともできる小数は2進数で表現できるため、正確に格納できます。
なおこのような誤差は、.NET Frameworkだけで起こるわけではありません。IEEE 754の2進浮動小数点形式を採用して計算していれば、同じことが起こります。
この誤差についてもっとよく知りたい方は、「日経PC21 / エクセル(Excel)「演算誤差」対策講座」が参考になるかと思います。
補足:DoubleのToStringメソッドをパラメータなしに呼び出した場合は、Doubleの有効桁数である15桁までしか文字列にせず、それ以降は丸められるため、この方法で誤差を確認することはできません。しかしToStringメソッドのパタメータに"G17"を指定すれば、17桁まで知ることができます。例えば、「0.3」と「0.1 + 0.2」をToString()で文字列にするとどちらも「0.3」となりますが、ToString("G17")だと「0.3」は「0.29999999999999999」、「0.1 + 0.2」は「0.30000000000000004」となり、違いが出ます。
Dim d1 As Double = 0.3 Dim d2 As Double = 0.1 + 0.2 Console.WriteLine(d1.ToString()) '0.3 Console.WriteLine(d2.ToString()) '0.3 Console.WriteLine(d1.ToString("G17")) '0.29999999999999999 Console.WriteLine(d2.ToString("G17")) '0.30000000000000004 Console.WriteLine(d1 = d2) 'False
double d1 = 0.3; double d2 = 0.1 + 0.2; Console.WriteLine(d1.ToString()); //0.3 Console.WriteLine(d2.ToString()); //0.3 Console.WriteLine(d1.ToString("G17")); //0.29999999999999999 Console.WriteLine(d2.ToString("G17")); //0.30000000000000004 Console.WriteLine(d1 == d2); //False
変数に代入するかしないか、最適化するかしないかによって計算結果が異なる
今まで説明してきた事柄は一般的によく知られています。しかしこれ以外にも浮動小数点数の計算を狂わせる要因があります。
例えば具体例の2番目に紹介したコードですが、計算結果を変数に代入してからMath.Ceilingメソッドで切り上げると、違う結果になります。また、消費税を直接計算した値と、計算結果を変数に代入した値を等号演算子で比較すると、「False」になってしまいます。
Dim price As Integer = 2000 Dim rate As Single = 0.05F Dim tax As Single = price * rate Console.WriteLine(Math.Ceiling(tax)) '100 Console.WriteLine((price * rate) = tax) 'False
int price = 2000; float rate = 0.05f; float tax = price * rate; Console.WriteLine(Math.Ceiling(tax)); //100 Console.WriteLine((price * rate) == tax); //False
この理由を一言で言うと、直接計算した値の方が、変数に代入した値よりも精度が高いためです。
ECMA-335(「Partition III: CIL Instruction Set」の日本語訳)によると、浮動小数点数は、それと同じか、それ以上の精度、サイズ(桁数、最大値と最小値の範囲)を持った内部表現に変換して使用することができます。内部表現というのは、例えばx87 FPUの80ビット表現のように、通常はハードウェアで効率的に操作できる表現のことです。ただしこれは、クラスのフィールド、配列の要素、静的変数以外の、引数、戻り値、評価スタック、ローカル変数の場合のみです。
上記の例の場合、「price * rate」は精度の高い内部表現のままですが、変数「tax」に代入すると、より精度の低いSingle型に変換されるため、はみ出た桁が丸められます。そのため、両者は違う値として判断されます。
それでは変数に代入すれば必ず内部表現からその型の精度に戻されるかというと、そうとも言い切れません。例えば上記の例で、4行目を削除してから(しなくても大丈夫かもしれません)、「コードの最適化」を有効にして(構成がリリースであれば、通常は有効になっています)、「デバッグなしで開始」(Ctrl + F5)すると、今度は「True」になります。この場合は、taxも内部表現のままの精度です。
補足:もしどうしても内部表現からSingle型の精度に戻したいということであれば、Single型にキャストします。例えば以下のようにキャストしてから比較すれば、最適化の有無にかかわらず常にTrueになります。
Console.WriteLine(CSng(price * rate) = CSng(tax))
Console.WriteLine((float)(price * rate) == (float)tax);
JITの最適化を有効にするかしないかで結果が変わる例が「Binary floating point and .NET」の「Comparing floating point numbers」にもありますので、興味のある方はそちらもご覧ください。
対策
Decimal型を使う
10進数の小数を2進数に変換するときの丸め誤差をなくすには、SingleやDouble型の代わりにDecimal型を使用します。Decimal型は10進数の小数でも正確に表現することができます。
Dim d1 As Decimal = 1.000001D Dim d2 As Decimal = 0.000001D Console.WriteLine((d1 - d2) = 1D) 'True Dim price As Integer = 2000 Dim rate As Decimal = 0.05D Console.WriteLine(Math.Ceiling(price * rate)) '100
decimal d1 = 1.000001m; decimal d2 = 0.000001m; Console.WriteLine((d1 - d2) == 1.0m); //True int price = 2000; decimal rate = 0.05m; Console.WriteLine(Math.Ceiling(price * rate)); //100
ただし、Decimal型で計算すれば必ず正しい計算結果を得られるという訳ではありません。例えば、「1m / 3m * 3m」の結果は「1」にならず、「0.9999999999999999999999999999」になります。
Decimal型の欠点は、パフォーマンスが悪いことと、格納できる値の大きさの範囲が狭いことです。よって通常は、Decimalを使用するのは、精度の高い計算が要求されるときだけです。例えば、誤差の許されないお金の計算(財務計算)にはDecimalを使用します。一方、はじめに与えられた値から誤差があるような科学計算では、Decimalを使用する意味がありません。
補足:Double型と比べてSingle型のメリットはほとんどありませんので、Single型を積極的に使用することはまずありません。
補足:Decimal型も浮動小数点数(floating point number)です。固定小数点数(fixed point number)ではありません。SingleやDouble型が2進浮動小数点数(binary floating point number)なのに対して、Decimal型は10進浮動小数点数(decimal floating point number)と呼ばれます。
許容範囲を決めて値を比較する
今まで見てきたように、2進浮動小数点数の比較は非常に厄介です。例えば、2つの2進浮動小数点数が等しいかを判断するのに = (C#では、 == )を使うのは危険です。両者が全く同じ値の時だけ等しいと判断するのではなく、「両者の差の絶対値がこの値以内ならば等しいと判断する」という許容範囲を決めて比較するのが安全です。
例えば次の例では、許容範囲を 0.000001 として2つのDouble値を比較しています。
Dim d1 As Double = 0.1 + 0.2 Dim d2 As Double = 0.3 If Math.Abs(d1 - d2) < 0.000001 Then Console.WriteLine("等しいと判断する") End If
double d1 = 0.1 + 0.2; double d2 = 0.3; if (Math.Abs(d1 - d2) < 0.000001) { Console.WriteLine("等しいと判断する"); }
補足:許容範囲は、0より大きい最小の値を示すDouble.Epsilonフィールドよりも大きい値でないと意味がありません。
また、具体例の2番目に示したように小数を切り上げる場合は、切り上げる値からあらかじめ許容できる範囲の小さな値を引いておくという方法があります。
Dim price As Integer = 2000 Dim rate As Single = 0.05F Console.WriteLine(Math.Ceiling(price * rate - 0.0001F))
int price = 2000; float rate = 0.05f; Console.WriteLine(Math.Ceiling(price * rate - 0.0001f));
整数化する
上記以外に考えられる対策としては、小数を整数にして計算するという方法があります。例えば「0.1 + 0.2 = 0.3」であれば、両辺を10倍して「1 + 2 = 3」とすれば正しい結果を得られます。ただし小数をそのまま10倍しただけではきっちり整数になっていない可能性もありますので、Math.Roundメソッドを使って整数に丸めます。
ここに示した方法以外にも有用な対策があるかもしれません。お気付きの方がいらっしゃいましたら、ぜひ教えてください。
- 参考:
- (Complete) Tutorial to Understand IEEE Floating-Point Errors
- 浮動小数点演算での丸めエラーを修正する方法
- CLR and floating point: Some answers to common questions
留言
張貼留言