在C#中将十进制转换为双精度浮点数会导致差异

45

问题概述:

对于一些十进制值,在将其从十进制类型转换为双精度浮点型时,会在结果中添加一个小分数。

更糟糕的是,有可能存在两个“相等”的十进制值,但在转换为双精度浮点数时得到不同的值。

代码示例:

decimal dcm = 8224055000.0000000000m;  // dcm = 8224055000
double dbl = Convert.ToDouble(dcm);    // dbl = 8224055000.000001

decimal dcm2 = Convert.ToDecimal(dbl); // dcm2 = 8224055000
double dbl2 = Convert.ToDouble(dcm2);  // dbl2 = 8224055000.0

decimal deltaDcm = dcm2 - dcm;         // deltaDcm = 0
double deltaDbl = dbl2 - dbl;          // deltaDbl = -0.00000095367431640625

看一下评论中的结果。 结果是从调试器的监视器复制的。 产生这种效果的数字比数据类型的限制要少得多,因此它不可能是溢出(我猜!)。

更有趣的是,可能存在两个相等的十进制值(在上面的代码示例中,参见"dcm"和"dcm2",其中"deltaDcm"等于零),转换时会导致不同的双精度值。(在代码中,“dbl”和“dbl2”,它们具有非零的“deltaDbl”)

我猜这应该与两种数据类型中数字的位表示差异有关,但是无法确定具体原因!我需要知道该怎么做才能使转换符合我的需要。(例如dcm2->dbl2)


1
我已经在MS Connect上报告了这个问题。以下是链接:https://connect.microsoft.com/VisualStudio/feedback/ViewFeedback.aspx?FeedbackID=498340 - Iravanchi
我不确定原因是什么,但似乎问题出在(6)个大的小数位上。我测试了5个小数位,可以正常工作。我有类似的情况,我从十进制转换为双精度浮点数,然后再转回来,由于我的精度只有2个小数位,所以我的代码是安全的转换。 - Jaider
5个回答

50
有趣 - 尽管我通常不信任在对精确结果感兴趣时编写浮点值的常规方式。
这里有一个稍微简单的演示,使用我以前使用过几次的 DoubleConverter.cs
using System;

class Test
{
    static void Main()
    {
        decimal dcm1 = 8224055000.0000000000m;
        decimal dcm2 = 8224055000m;
        double dbl1 = (double) dcm1;
        double dbl2 = (double) dcm2;

        Console.WriteLine(DoubleConverter.ToExactString(dbl1));
        Console.WriteLine(DoubleConverter.ToExactString(dbl2));
    }
}

结果:

8224055000.00000095367431640625
8224055000

现在的问题是为什么原始值(8224055000.0000000000)作为一个整数 - 并且可以精确地表示为double - 最终多了一些数据。我强烈怀疑这是由于将十进制转换为double的算法中存在的怪癖,但这很不幸。

它还违反了C#规范6.2.1节:

对于从十进制到float或double的转换,十进制值四舍五入为最接近的double或float值。虽然此转换可能会失去精度,但它永远不会导致抛出异常。

“最接近的double值”显然只是8224055000 ... 所以我认为这是一个错误。不过我不指望它会很快得到修复。(顺便说一句,在.NET 4.0b1中它也会给出相同的结果。)

为避免出现错误,您可能需要先规范化十进制值,实际上是“删除”小数点后面的额外0。这有点棘手,因为涉及到96位整数运算 - .NET 4.0 BigInteger类可能会使其更容易,但这可能不是您的选择。


1
有趣的错误!正如Anton Tykhyy所指出的那样,这几乎肯定是因为带有大量额外精度的十进制表示不再“本地”在整数中,而是超出了能够在没有表示误差的情况下适合双倍大小的范围。我愿意打赌多达一美元,这个错误已经存在于OLE自动化中长达15年 - 我们使用OA库进行十进制编码。我恰好在我的机器上拥有10年前的OA源代码存档;如果明天我有一些空闲时间,我会看一下。 - Eric Lippert
5
客户支持不会比这更好了 :) - Jon Skeet
最近在从Oracle数据库检索值时遇到了这个问题。没有涉及整数:从包含对象的DataRow中检索到Oracle Number(3,1),对象包含QuickWatch中预期值。使用Convert.ToDouble(object)返回错误值:8.4变成8.39999...,9.1变成9.1000003...。我的丑陋解决方法:使用期望的Oracle值精度四舍五入Convert返回的值。 - Keorl
@Keorl:那么对象的执行时间类型是什么?我期望它应该是“decimal”。 - Jon Skeet
@EricLippert 我已经追溯很久了,但您是否有关于这个 bug 的更多信息?它已经被修复了吗? - IEatBagels
显示剩余10条评论

26
答案为: decimal试图保留有效数字的数量,这就是答案的来源。因此,8224055000.0000000000m具有20个有效数字,并存储为82240550000000000000E-10,而8224055000m只有10个有效数字,并存储为8224055000E+0double的尾数(逻辑上)为53位,即最多16个十进制数字。当您转换为double时,这正好是您获得的精度,实际上,您示例中额外的数字“1”在第16位小数处。转换不是一对一的,因为double使用二进制。 以下是您的数字的二进制表示:
dcm:
00000000000010100000000000000000 00000000000000000000000000000100
01110101010100010010000001111110 11110010110000000110000000000000
dbl:
0.10000011111.1110101000110001000111101101100000000000000000000001
dcm2:
00000000000000000000000000000000 00000000000000000000000000000000
00000000000000000000000000000001 11101010001100010001111011011000
dbl2 (8224055000.0):
0.10000011111.1110101000110001000111101101100000000000000000000000

对于双精度浮点数,我使用小数点来分隔符号、指数和尾数字段;对于十进制数,请参阅MSDN on decimal.GetBits,但本质上,最后96位是尾数。请注意,dcm2的尾数位和dbl2的最高有效位完全相同(不要忘记double的尾数中的隐式1位),实际上这些位表示8224055000。 dbl的尾数位与dcm2dbl2相同,但存在最低有效位的麻烦的1位。 dcm的指数为10,尾数为82240550000000000000。

更新 II:去掉尾随的零实际上很容易。

// There are 28 trailing zeros in this constant —
// no decimal can have more than 28 trailing zeros
const decimal PreciseOne = 1.000000000000000000000000000000000000000000000000m ;

// decimal.ToString() faithfully prints trailing zeroes
Assert ((8224055000.000000000m).ToString () == "8224055000.000000000") ;

// Let System.Decimal.Divide() do all the work
Assert ((8224055000.000000000m / PreciseOne).ToString () == "8224055000") ;
Assert ((8224055000.000010000m / PreciseOne).ToString () == "8224055000.00001") ;

这不是更准确的转换。十进制的精确值是可用的,因此应该返回。我知道为什么会发生这种情况,但这并不意味着它是正确的 :) - Jon Skeet
好的,如果你理解“准确”在这个意义上,我同意。 - Anton Tykhyy
谢谢,二进制表示非常有帮助。 我知道这是发生的事情,但我的问题是“为什么”转换会将那个“1”放在末尾?从高精度值到低精度值的转换应该执行四舍五入。就像将“0.0”转换为整数一样,结果为0,但将“0.000000000000”转换为整数则结果为1! - Iravanchi
1
关于“准确性”-一个相当简单的准确度度量是“表示开始的精确数字与转换结果的精确值之间的差异是多少”?0代表完全准确-至少在数字的数量方面,并且在这种情况下可用。那就是我所说的。由于double没有“有效数字的数量”的概念,我不认为可以用这些术语来衡量准确性。(对于其他转换,例如到另一种保留有效数字数量的类型,可以使用它。) - Jon Skeet
精确的PreciseOne技巧做得很好。我计划在进行所有十进制到双精度转换之前使用此技巧,以确保小数的精度/表示不会在强制转换期间引起不一致。 - Ilan
显示剩余6条评论

4
这篇文章《计算机科学家应该了解的浮点数算术知识》是一个很好的起点。
简单来说,浮点二进制算术必然是一种近似值,而且它并不总是你所猜测的近似值。这是因为CPU在二进制下进行算术运算,而人类(通常)在十进制下进行算术运算。这会带来各种意想不到的影响。

谢谢提供文章链接,虽然很长但我会尝试阅读。二进制算术和十进制算术是我怀疑的问题,但有两个要点:
  1. 十进制具有28-29个有效数字,而双精度浮点数具有15-16个有效数字。8个有效数字足以表示我的数字。为什么要这样处理?只要在双精度浮点数中有原始数字的表示,为什么转换应该导致另一个数字?
  2. 两个“相同”的十进制值转换为不同的双精度浮点数怎么办?
- Iravanchi
1
有效数字的数量并不是特别相关的 - “0.1”只有一个有效数字,但仍然无法在float / double中表示。关于是否存在可用的精确表示的问题更为重要。至于两个值给出不同的双倍数 - 它们是相等的,但它们不是相同的 - Jon Skeet
有没有一种方法可以将这些“相等但不同”的十进制数彼此转换?并且有没有办法在调试器中查看它们?(我猜我应该看到位表示,但是在VS中没有这样的选项。而“十六进制显示”也不能以这种方式工作) - Iravanchi
Decimal.GetBits会给你位表示法 - 你需要通过它来进行规范化。这并不容易 :( 你知道这个值实际上是一个整数吗?如果是的话,那会有所帮助... - Jon Skeet
这个实例中,该数字“实际上”是一个整数。但它也可以是一个非整数。可以确定的是,它不会有16个有效数字。 - Iravanchi
Irchi:将“相等但不同”的十进制数转换非常容易,可以看看我的代码示例。此外,Decimal.ToString()会忠实地显示尾随零,因此即使在VS调试器的普通监视窗口中,您也可以通过这种方式区分“相等但不同”的十进制数。 - Anton Tykhyy

3
为了更直观地说明这个问题,请在LinqPad中尝试此操作(或者如果您喜欢,将所有的“.Dump()”替换为“Console.WriteLine()”)。对我来说,十进制精度可以导致3个不同的双精度数似乎是逻辑上不正确的。对@AntonTykhyy的/PreciseOne想法表示赞赏。
((double)200M).ToString("R").Dump(); // 200
((double)200.0M).ToString("R").Dump(); // 200
((double)200.00M).ToString("R").Dump(); // 200
((double)200.000M).ToString("R").Dump(); // 200
((double)200.0000M).ToString("R").Dump(); // 200
((double)200.00000M).ToString("R").Dump(); // 200
((double)200.000000M).ToString("R").Dump(); // 200
((double)200.0000000M).ToString("R").Dump(); // 200
((double)200.00000000M).ToString("R").Dump(); // 200
((double)200.000000000M).ToString("R").Dump(); // 200
((double)200.0000000000M).ToString("R").Dump(); // 200
((double)200.00000000000M).ToString("R").Dump(); // 200
((double)200.000000000000M).ToString("R").Dump(); // 200
((double)200.0000000000000M).ToString("R").Dump(); // 200
((double)200.00000000000000M).ToString("R").Dump(); // 200
((double)200.000000000000000M).ToString("R").Dump(); // 200
((double)200.0000000000000000M).ToString("R").Dump(); // 200
((double)200.00000000000000000M).ToString("R").Dump(); // 200
((double)200.000000000000000000M).ToString("R").Dump(); // 200
((double)200.0000000000000000000M).ToString("R").Dump(); // 200
((double)200.00000000000000000000M).ToString("R").Dump(); // 200
((double)200.000000000000000000000M).ToString("R").Dump(); // 199.99999999999997
((double)200.0000000000000000000000M).ToString("R").Dump(); // 200
((double)200.00000000000000000000000M).ToString("R").Dump(); // 200.00000000000003
((double)200.000000000000000000000000M).ToString("R").Dump(); // 200
((double)200.0000000000000000000000000M).ToString("R").Dump(); // 199.99999999999997
((double)200.00000000000000000000000000M).ToString("R").Dump(); // 199.99999999999997

"\nFixed\n".Dump();

const decimal PreciseOne = 1.000000000000000000000000000000000000000000000000M;
((double)(200M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.0M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.00M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.0000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.00000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.0000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.00000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.0000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.00000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.000000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.0000000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.00000000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.000000000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.0000000000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.00000000000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.000000000000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.0000000000000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.00000000000000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.000000000000000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.0000000000000000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.00000000000000000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.000000000000000000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.0000000000000000000000000M/PreciseOne)).ToString("R").Dump(); // 200
((double)(200.00000000000000000000000000M/PreciseOne)).ToString("R").Dump(); // 200

我认为理解正在发生的事情的关键是打印出2E23/1E21和2E25/2E23。即使这可能会引入舍入误差,将Decimal转换为double是通过将整数值除以10的幂来执行的。 - supercat

1

这是一个老问题,也是StackOverflow上许多类似问题的主题。

简单化的解释是十进制数无法在二进制中精确表示。

这个链接是一篇可能能够解释这个问题的文章。


1
那并不能解释清楚,实际上很多十进制数在二进制中无法精确表示 - 但在这种情况下,输入可以在二进制中被精确地表示。数据被不必要地丢失了。 - Jon Skeet
Jon,数据并没有丢失,相反——正是不必要地保留(从Irchi的角度来看,没有冒犯的意思)数据才是问题所在。 - Anton Tykhyy
安东,看看Jon发布的规格。不必要保留的数据不应该破坏转换过程。在16个有效数字之后,小数值指定的数字应全部为“0”。为什么要将它舍入到第16位?“0”比“1”更接近“精确”的小数值。 - Iravanchi
我不了解“应该”的标准,但这就是它的行为方式,唯一的问题是如何处理这种行为。 - Anton Tykhyy
@Jon,我在我的回答中强调了“简单化”这个词,供参考。 - pavium
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接