hnwの日記

C#のdecimalからdoubleへのキャストにバグを見つけた気がする

C#の丸めは基本的に偶数丸め(banker's rounding)だというのが僕の認識ですが、decimalの数をdoubleにキャストするときに四捨五入になる例がありました。また、.NET環境に限り最近接のdoubleに丸められない例も見つけました。

decimalからdoubleへのキャストは偶数丸めでなく四捨五入になる

まずは次の例を見てみましょう。これは.NET、Mono環境ともに同じ結果になります。

using System;

class DecimalTest1
{
    static void Main()
    {
        double x1 = 5000000000000000.5; // 偶数丸めで5000000000000000になる
        decimal y = 5000000000000000.5m;
        double x2 = (double)y; // 四捨五入されて5000000000000001になる

        Console.WriteLine(x1 - x2); // -1
    }
}


上記サンプルプログラム中の巨大な数は2^52以上2^53以下の数です。この範囲の数はdoubleでは1.0刻みでしか表現できません。上記の例ではdoubleでピッタリ表現できない数を浮動小数リテラルとして記述しているので丸めが起こりますが、この数はちょうど「ど真ん中」なので偶数丸めになり、この場合だと切り捨てられます。これは多くの処理系と共通の挙動になります。


一方、この数は10進で17桁しか無いのでdecimalではピッタリ表現できます。ところが、このdecimalの数をdoubleにキャストすると偶数丸めにならず、いわゆる四捨五入による切り上げが起こってしまいます。

Mono環境では「ど真ん中」以外は正確に丸め処理が行われている

Mono環境について言えば、decimalからdoubleへのキャストはかなり正確な計算をしているようです。次の例も見てみましょう。

using System;

class DecimalTest2
{
    static void Main()
    {
        decimal y1 = 0.4999999999999999722444m; // 2^(-1) - 2^(-55)より少し小さい
        decimal y2 = 0.4999999999999999722445m; // 2^(-1) - 2^(-55)より少し大きい

        Console.WriteLine((double)y1 - 0.5); // -5.55111512312578E-17
        Console.WriteLine((double)y2 - 0.5); // 0
    }
}


doubleで本来必要な精度より細かい差をdecimalで作ってみましたが、この差を正しく処理できており、y1を2^(-1) - 2^(-54)に、y2を0.5に丸められています。

.NET環境ではdecimalからdoubleのキャストで誤差が入る箇所がありそう

一方、.NET環境で似た例を試すと予想外の結果になりました。

using System;

class DecimalTest3
{
    static void Main()
    {
        decimal y3 = 0.4999999999999999691m;
        decimal y4 = 0.4999999999999999691000091m;

        Console.WriteLine(y3 <= y4); // True
        Console.WriteLine((double)y3 <= (double)y4); // False
        Console.WriteLine((double)y3 - 0.5); // 0
        Console.WriteLine((double)y4 - 0.5); // -1.11022302462516E-16
    }
}


y3よりy4の方が大きいのですが、両者をdoubleにキャストするとなぜかy3の方が大きくなってしまいます。


また、y3、y4ともに最近接のdouble値は2^(-1) - 2^(-54)のはずですが、y3は0.5に、y4は2^(-1) - 2^(-53)に丸められています。


似た内容がStack Overflowの「Conversion of a decimal to double number in C# results in a difference」でも指摘されていますね。こっちはこっちで意味不明すぎてビックリですね…。

仕様書での記述

仕様書もあたってみました。「Standard ECMA-334 C# Language Specification 4th edition (June 2006)」の「13.2.1 Explicit numeric conversions」には次のようにあります。

For a conversion from decimal to float or double, the decimal value is rounded to the nearest double or float value. However, if the value being converted is not within the range of the destination type, a System.OverflowException is thrown.


"rounded to the nearest"としか書いていないので、ど真ん中だった場合は仕様上どちらでもいいのかもしれません。これ以外の場所も、double/floatについては特に記述がなく、decimalについてはbanker's roundingだと明記してある場所が目立ちました。


仕様書上どちらでも良いとしても、多くの箇所の丸め処理が偶数丸めになっているわけですから、decimalからdoubleのキャストだけ四捨五入なのは少々納得感に欠ける気がします。


また、.NET環境で最近接のdoubleに丸められていないのはバグだろうと思います。