在处理现实世界的货币值时,我被建议使用BigDecimal而不是Double。但除了“通常这样做”,我没有得到令人信服的解释。
请问您能解释一下这个问题吗?
在处理现实世界的货币值时,我被建议使用BigDecimal而不是Double。但除了“通常这样做”,我没有得到令人信服的解释。
请问您能解释一下这个问题吗?
我认为这篇文章描述了你问题的解决方案:Java 陷阱:BigDecimal 和关于 double 的问题在这里
以下是原始博客,现在已经失效。
Java 陷阱:double
对于走向软件开发之路的学徒程序员来说,有许多陷阱。本文通过一系列实用例子,说明了使用 Java 的简单类型 double 和 float 的主要陷阱。然而,请注意,要完全掌握数值计算的精度,需要阅读一本(甚至两本)教材。因此,我们只能浅尝辄止。话虽如此,本文传达的知识应该足以让您了解如何发现代码中的错误。我认为任何专业的软件开发人员都应该具备这种知识。
十进制数是近似值
虽然 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.100000001490116119384765625
和0.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
结论
a+b = a
和 a != a/3 + a/3 + a/3
这被称为精度损失,当使用非常大的数字或非常小的数字时就会非常明显。用基数表示的十进制数的二进制表示在许多情况下是一个近似值而不是绝对值。要理解为什么需要阅读有关二进制浮点数表示的内容。这里是一个链接: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
如果您是银行,每天要负责成千上万的交易,即使它们不是到同一帐户,您也必须拥有可靠的数字。二进制浮点数不可靠,除非您了解它们的工作原理和局限性。
虽然BigDecimal可以存储比double更多的精度,但通常情况下并不需要这么高的精度。使用BigDecimal的真正原因是它清楚地说明了舍入的方式,包括许多不同的舍入策略。在大多数情况下,您可以使用double实现相同的结果,但除非您知道所需的技术,否则在这些情况下使用BigDecimal是更好的选择。
一个常见的例子是货币。即使在99%的使用情况下,货币也不需要BigDecimal的精度,但通常认为最佳实践是使用BigDecimal,因为控制舍入的软件可以避免开发人员在处理舍入时犯错误的风险。即使您有信心可以使用double
处理舍入,我建议您使用助手方法来执行舍入,并对其进行彻底测试。
x = x1*2^-1 + x2*2^-2 + ...
和 x = x1*10^-1 + x2*10^-2 + ...
是存储同一个数值x的不同方式,尽管x1、x2等在内部以二进制值存储。 - undefinedBigDecimal#divide
会强制设置舍入策略,表示:嘿!我这里不准确,请处理一下。其他算术是准确的。 - undefined这主要是出于精度的原因。BigDecimal可以存储具有无限精度的浮点数。您可以查看这个页面,它很好地解释了这一点。http://blogs.oracle.com/CoreJavaTechTips/entry/the_need_for_bigdecimal