Java:在实际世界中为什么应该使用 BigDecimal 而不是 Double?

63

在处理现实世界的货币值时,我被建议使用BigDecimal而不是Double。但除了“通常这样做”,我没有得到令人信服的解释。

请问您能解释一下这个问题吗?


这是因为浮点数是二进制的,而会计是以十进制进行的,这两个基数是不可比的。例如,没有精确的浮点表示$0.01。 - user207421
6个回答

49

我认为这篇文章描述了你问题的解决方案:Java 陷阱:BigDecimal 和关于 double 的问题在这里

以下是原始博客,现在已经失效。

Java 陷阱:double

对于走向软件开发之路的学徒程序员来说,有许多陷阱。本文通过一系列实用例子,说明了使用 Java 的简单类型 double 和 float 的主要陷阱。然而,请注意,要完全掌握数值计算的精度,需要阅读一本(甚至两本)教材。因此,我们只能浅尝辄止。话虽如此,本文传达的知识应该足以让您了解如何发现代码中的错误。我认为任何专业的软件开发人员都应该具备这种知识。

  1. 十进制数是近似值

    虽然 0 - 255 之间的所有自然数可以使用 8 位准确地描述,但描述 0.0 - 255.0 之间的所有实数需要无限多位。首先,在该范围内存在无限多个数字(即使在范围 0.0 - 0.1 内也是如此),其次,某些无理数根本无法用数字表示。例如 e 和 π。换句话说,计算机中的数字 2 和 0.2 的表示方式差异极大。

    整数由表示值为 2n 的位组成,其中 n 是位的位置。因此,值为 6 的数字表示为 23 * 0 + 22 * 1 + 21 * 1 + 20 * 0 对应于位序列 0110。而小数则由表示 2-n 的位组成,即分数 1/2、1/4、1/8... 数字 0.75 对应于 2-1 * 1 + 2-2 * 1 + 2-3 * 0 + 2-4 * 0,得到的位序列为 1100 (1/2 + 1/4)

    有了这个知识,我们可以得出以下经验法则:任何十进制数都是近似值。

    接下来,我们通过执行一系列琐碎的乘法来研究这种情况的实际后果。

System.out.println( 0.2 + 0.2 + 0.2 + 0.2 + 0.2 );
1.0

输出结果为1.0。虽然这是正确的,但它可能会给我们一种虚假的安全感。巧合的是,0.2是Java能够正确表示的少数值之一。现在,让我们再次挑战Java,使用另一个简单的算术问题:将数字0.1相加十次。

System.out.println( 0.1f + 0.1f + 0.1f + 0.1f + 0.1f + 0.1f + 0.1f + 0.1f + 0.1f + 0.1f );
System.out.println( 0.1d + 0.1d + 0.1d + 0.1d + 0.1d + 0.1d + 0.1d + 0.1d + 0.1d + 0.1d );

1.0000001
0.9999999999999999

根据Joseph D. Darcy博客的幻灯片,这两个计算的总和分别为0.1000000014901161193847656250.1000000000000000055511151231...。 这些结果仅适用于有限的一组数字。 float的精度为8个前导数字,而double具有17个前导数字的精度。 现在,如果期望结果1.0与屏幕上显示的结果之间的概念不匹配还不足以引起您的警惕,则请注意mr. Darcy幻灯片中的数字似乎并不对应打印的数字!那是另一个陷阱。 更多相关信息稍后会提到。

意识到即使在最简单的场景中也可能出现错误的情况后,我们可以考虑印象会多快产生。 让我们将问题简化为只加三个数字。

System.out.println( 0.3 == 0.1d + 0.1d + 0.1d );
false

令人震惊的是,只要进行三次加法,不准确性就会开始出现!

  • 双精度溢出

    和Java中的其他简单类型一样,双精度浮点数是由一组有限的比特表示的。因此,对一个双精度浮点数进行加法或乘法可能会产生令人惊讶的结果。尽管必须使用非常大的数字才能发生溢出,但它确实会发生。让我们尝试将一个大数字相乘然后再除以它。数学直觉告诉我们结果应该是原始数字。但在Java中,我们可能得到不同的结果。

    double big = 1.0e307 * 2000 / 2000;
    System.out.println( big == 1.0e307 );
    false
    

    这里的问题在于将大数相乘时会先溢出,然后再进行除法运算。更糟糕的是,程序员没有收到任何异常或其他警告。基本上,这使得表达式 x * y 完全不可靠,因为对于由 x、y 表示的所有双精度值,在一般情况下都没有指示或保证。

    大和小不合作!

    劳雷尔和哈迪经常意见不合。同样地,在计算中,大和小也不是好朋友。使用固定位数表示数字的后果是,在相同的计算中操作非常大和非常小的数字将无法按预期工作。让我们试着将一些小的东西加到一些大的东西上。

  • System.out.println( 1234.0d + 1.0e-13d == 1234.0d );
    true
    

    加法没有效果!这与数学中加法的任何(理智的)直觉相矛盾,因为根据加法,给定两个正数d和f,则d + f > d。

    十进制数不能直接比较

    到目前为止,我们已经学习到,必须放弃我们在数学课和整数编程中所获得的所有直觉。小心使用十进制数。例如,语句for(double d = 0.1; d != 0.3; d += 0.1) 实际上是一个伪装的无限循环!错误在于直接将十进制数相互比较。您应该遵循以下指南。

    避免两个十进制数之间进行相等测试。不要使用if(a == b) {..},而要使用if(Math.abs(a-b) < tolerance) {..}其中公差可以定义为例如public static final double tolerance = 0.01。

    考虑使用运算符<,>作为替代方法,因为它们可能更自然地描述您想要表达的内容。例如,我更喜欢形式for(double d = 0; d <= 10.0; d+= 0.1) 而不是更笨拙的形式for(double d = 0; Math.abs(10.0-d) < tolerance; d+= 0.1) 在不同情况下,两种形式都有各自的优点:在单元测试中,我更喜欢表达assertEquals(2.5, d, tolerance)而不是说assertTrue(d > 2.5)不仅第一种形式读起来更好,而且通常是您想要进行的检查(即d不太大)。

    WYSINWYG-你所见到的并不是你得到的

    WYSIWYG是通常用于图形用户界面应用程序的表达式。它的意思是“你所见到的就是你得到的”,并用于描述在编辑过程中显示的内容与最终输出非常相似的系统,该输出可能是打印文档、网页等。该短语最初是由Flip Wilson的变装角色“Geraldine”创造的流行口号,她经常会说“你看到的就是你得到的”来解释她的古怪行为(来自维基百科)。

    程序员经常陷入的另一个严重陷阱是认为十进制数是WYSIWYG的。必须认识到,在打印或写入十进制数时,并不打印/写入近似值。换句话说,Java在幕后进行了许多近似计算,并坚持试图保护您永远不知道它的存在。唯一的问题是,您需要了解这些近似值,否则您可能会在代码中遇到各种神秘的错误。

    然而,通过一些独创性,我们可以调查幕后发生的实际情况。现在我们知道数字0.1被一些近似表示。

    System.out.println( 0.1d );
    0.1
    

    我们知道0.1不是0.1,但是屏幕上会显示0.1。结论:Java是所见即所得!

    为了多样化,让我们选择另一个看似无害的数字,比如2.3。像0.1一样,2.3也是一个近似值。当打印这个数时,Java毫不意外地隐藏了近似。

    System.out.println( 2.3d );
    2.3
    

    为了探究2.3的内部近似值,我们可以将这个数字与接近范围内的其他数字进行比较。

    double d1 = 2.2999999999999996d;
    double d2 = 2.2999999999999997d;
    System.out.println( d1 + " " + (2.3d == d1) );
    System.out.println( d2 + " " + (2.3d == d2) );
    2.2999999999999994 false
    2.3 true
    

    因此,2.2999999999999997与值2.3一样!请注意,由于近似,枢轴点位于..99997而不是通常在数学中四舍五入的..99995。掌握近似值的另一种方法是调用BigDecimal的服务。

    System.out.println( new BigDecimal(2.3d) );
    2.29999999999999982236431605997495353221893310546875
    

    现在,不要沾沾自喜地认为你可以直接使用BigDecimal而不用再费心。BigDecimal也有自己的一套陷阱,详见此处文档。

    没有什么是轻而易举的,很少有东西是免费的。而且,“自然地”,浮点数和双精度数在打印/写入时会产生不同的结果。

    System.out.println( Float.toString(0.1f) );
    System.out.println( Double.toString(0.1f) );
    System.out.println( Double.toString(0.1d) );
    0.1
    0.10000000149011612
    0.1
    
    根据Joseph D. Darcy博客中的幻灯片,浮点近似值具有24位有效位,而双精度近似值则具有53位有效位。要保留值,必须以相同的格式读取和写入十进制数。除以0会导致应用程序突然终止,在Java中操作int时也会出现类似的行为,但令人惊讶的是,当操作double时并非如此。任何数字(零除外)除以零分别得到∞或-∞。将零除以零将得到特殊的NaN,即“不是数字”值。
    System.out.println(22.0 / 0.0);
    System.out.println(-13.0 / 0.0);
    System.out.println(0.0 / 0.0);
    Infinity
    -Infinity
    NaN
    

    当正数与负数相除时,结果为负数,而当负数与负数相除时,结果为正数。由于可以对0进行除法,因此取决于您将数字除以0.0还是-0.0,您将获得不同的结果。是的,没错!Java有负零!但请不要被欺骗,这两个零值是相等的,如下所示。

    System.out.println(22.0 / 0.0);
    System.out.println(22.0 / -0.0);
    System.out.println(0.0 == -0.0);
    Infinity
    -Infinity
    true
    
  • 无限大很奇怪

    在数学世界中,我发现很难理解无限大的概念。例如,我从未掌握过如何感性地理解一个无限大何时比另一个无限大大得多。当然Z > N,即所有有理数的集合是无穷大的,而自然数的集合只是其中一个子集,但这也是我关于这个问题的直觉极限!

    幸运的是,在Java中的无限大与数学世界一样不可预测。您可以对一个无限大值执行通常的操作(+、-、*、/),但不能将一个无限大应用于另一个无限大。

  • double infinity = 1.0 / 0.0;
    System.out.println(infinity + 1);
    System.out.println(infinity / 1e300);
    System.out.println(infinity / infinity);
    System.out.println(infinity - infinity);
    Infinity
    Infinity
    NaN
    NaN
    

    这里的主要问题在于NaN值会没有任何警告地返回。因此,如果你愚蠢地探究一个特定的双精度数是奇数还是偶数,你可能会陷入���手的境地。也许抛出运行时异常会更合适?

    double d = 2.0, d2 = d - 2.0;
    System.out.println("even: " + (d % 2 == 0) + " odd: " + (d % 2 == 1));
    d = d / d2;
    System.out.println("even: " + (d % 2 == 0) + " odd: " + (d % 2 == 1));
    even: true odd: false
    even: false odd: false
    

    突然间,你的变量既不是奇数也不是偶数! NaN比无穷大更加奇怪。 无穷大的值和双精度浮点数的最大值不同,而NaN又与无穷大的值不同。

    double nan = 0.0 / 0.0, infinity = 1.0 / 0.0;
    System.out.println( Double.MAX_VALUE != infinity );
    System.out.println( Double.MAX_VALUE != nan );
    System.out.println( infinity         != nan );
    true
    true
    true
    

    通常情况下,当双精度浮点数取得NaN值时,任何操作都会导致结果为NaN。

    System.out.println( nan + 1.0 );
    NaN
    
  • 结论

    1. 十进制数是近似值,而不是您分配的值。在数学世界获得的直觉不再适用。预计 a+b = aa != a/3 + a/3 + a/3
    2. 避免使用 ==,比较其与某些容差或使用 >= 或 <= 运算符
    3. Java 是 WYSINWYG!永远不要相信您打印/写入的值是近似值,因此始终以相同的格式读取/写入十进制数。
    4. 要小心不要使您的 double 溢出,不要将您的 double 转换为 ±Infinity 或 NaN 状态。在任何情况下,您的计算结果可能会出人意料。在返回方法中的值之前,始终检查这些值可能是个好主意。

  • No link only answers please! - fabian
    这是一个三年前的回答,我会用正确的内容进行更新。感谢指出。 - zengr
    更新了链接并复制/格式化了内容。 - zengr
    1
    这一定是我在StackOverflow上看到的最长的答案了。但我想我会坚持使用Double来测量纬度/经度。 - IgorGanapolsky

    44

    这被称为精度损失,当使用非常大的数字或非常小的数字时就会非常明显。用基数表示的十进制数的二进制表示在许多情况下是一个近似值而不是绝对值。要理解为什么需要阅读有关二进制浮点数表示的内容。这里是一个链接:http://en.wikipedia.org/wiki/IEEE_754-2008。以下是快速演示:
    在精度为10的bc(一种任意精度计算器语言)中:

    (1/3+1/12+1/8+1/15) = 0.6083333332
    (1/3+1/12+1/8) = 0.541666666666666
    (1/3+1/12) = 0.416666666666666

    Java double:
    0.6083333333333333
    0.5416666666666666
    0.41666666666666663

    Java float:

    0.60833335
    0.5416667
    0.4166667


    如果您是银行,每天要负责成千上万的交易,即使它们不是到同一帐户,您也必须拥有可靠的数字。二进制浮点数不可靠,除非您了解它们的工作原理和局限性。


    “使用基数的十进制数的二进制表示在许多情况下是一种近似而不是绝对值。” - 的确如此,除非“分”部分为.00、.25、.50或.75,否则任何货币金额都是一种近似。 - slothrop
    2
    @vinoth... 正如你所说的“除了‘这是通常的做法’之外,我没有得到令人信服的解释”,你应该与你的同事分享这个页面 :-) - slothrop
    2
    因此,一家银行必须对涉及无理数(如1/3)的10万亿笔交易进行求和才能产生影响。当然,每个交易(或每1000亿笔交易)都会将所有小数位除了最后的1/100美分以纠正这个问题 - 如果它存在的话,但我从未听说过有银行使用1/3。 虽然杂货店可能会有三件商品1美元的促销活动。 Peter Lawrey在下面给出了真正的答案——BigDecimal具有四舍五入方法,可防止开发人员错误。听起来很多人在这里掌握的不仅是“通常是这样做的”。 - fijiaaron
    1
    除了解决精度丢失的问题外,它还方便进行十进制运算,如减法、除法以及去除前导和尾随零。 - Asanka Siriwardena

    32

    虽然BigDecimal可以存储比double更多的精度,但通常情况下并不需要这么高的精度。使用BigDecimal的真正原因是它清楚地说明了舍入的方式,包括许多不同的舍入策略。在大多数情况下,您可以使用double实现相同的结果,但除非您知道所需的技术,否则在这些情况下使用BigDecimal是更好的选择。

    一个常见的例子是货币。即使在99%的使用情况下,货币也不需要BigDecimal的精度,但通常认为最佳实践是使用BigDecimal,因为控制舍入的软件可以避免开发人员在处理舍入时犯错误的风险。即使您有信心可以使用double处理舍入,我建议您使用助手方法来执行舍入,并对其进行彻底测试。


    真正的原因是他们将小数部分存储在十进制而不是二进制中。 - Alex Salauyou
    @AlexSalau你说得没错,这就是他们的做法,但并没有解释为什么这很重要。所有的数字和数据都以二进制形式存储。 - Peter Lawrey
    1
    E.g.: x = x1*2^-1 + x2*2^-2 + ...x = x1*10^-1 + x2*10^-2 + ... 是存储同一个数值x的不同方式,尽管x1、x2等在内部以二进制值存储。 - undefined
    @AlexSalau你的问题是,你仍然需要关注,但这不会那么明显。尝试在BigDecimal中计算1.0/7*7,你很容易得到一个看起来正确但实际上不正确的答案。 - undefined
    1
    当然不行,因为中间结果1/7无法准确表示为小数。顺便说一句,BigDecimal#divide会强制设置舍入策略,表示:嘿!我这里不准确,请处理一下。其他算术是准确的。 - undefined
    显示剩余3条评论

    1
    另一个想法:用一个long变量来跟踪美分的数量。这种方法更简单,避免了BigDecimal的繁琐语法和低效性能。
    在金融计算中精度非常重要,因为由于舍入误差而导致资金消失会让人们非常愤怒,这就是为什么double不适合处理货币的原因。

    人们对一年中几分钱的损失感到恼火,与花费数小时甚至数天去弄清楚为什么你的实时和每日结算报告不匹配相比,简直微不足道。 - undefined

    0

    4
    它无法存储任何具有无限精度的数字 - 例如,BigDecimal无法存储1/3的精确表示。只有当一个数具有非重复的十进制表示时,它才能完全存储一个数字。通常,货币值根据定义具有非重复的十进制表示。(这并不总是这样的:例如,在英国的前十进制货币系统中,最小单位是四分之一便士,相当于1/960英镑。) - slothrop

    -1
    当使用BigDecimal时,它可以存储比Double更多的数据,这使得它更准确,并且是现实世界中更好的选择。
    尽管它慢得多而且更长,但它是值得的。
    打赌您不会想向老板提供不准确的信息,对吧?

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