又见浮点数精度问题
今天看到一篇文章:http://younglab.blog.51cto.com/416652/241886,大概是说在使用Javascript进行下面的浮点数计算时出现了问题:
obj.style.opacity = (parseInt(obj.style.opacity *100) + 1)/100;
obj.style.opacity是一个浮点数,范围从0~1,初始值为0。这句代码每隔一小段时间执行一次,从而让目标由透明慢慢变为不透明(淡入效果)。
问题是,起初obj.style.opacity还能够按照预期的每次以0.01逐步增加,但增加到0.29时就一直保持不变了。
作者只是记录了这个问题,没有写出为什么。读完这篇博客后我的第一感觉是:
这又是一个由于浮点数精度所引发的问题。
下面让我们来写一个小程序重现一下这个问题:
double opacity = 0; for (int i = 0; i < 100; i++) { opacity = ((int) (opacity * 100 + 1)) / 100.0; System.out.println("opacity=" + opacity); }
程序是用Java写的,共执行100次循环,采用了与那篇文章中相同的计算方法。正常情况下opacity会由0逐步增大到1。
程序输出如下:
opacity=0.01
opacity=0.02
opacity=0.03
opacity=0.04
opacity=0.05
opacity=0.06
(中间省略……)
opacity=0.27
opacity=0.28
opacity=0.29
opacity=0.29
opacity=0.29
……后面一直为0.29
可以发现,当opacity达到0.29后便不再增加了。由于Java和JS使用的是相同的浮点数格式,所以采用Java和JS结果都是相同的。
这里有一个细节需要注意:在这段程序中,除数必须写成100.0。这是由于在Java中有整数除法和浮点数除法两种不同的运算,如果写成100,那么被除数和除数将都是整数,Java就会按照整数除法来计算,就会导致每次计算的结果都是0(因为每次计算的结果都小于1,因此取整后就变为了0)。JS里没有这个问题,因为JS没有整数除法,所有除法都会当成浮点数除法来对待。
深入分析
现在我把上面那个程序做一点修改:
double opacity = 0; for (int i = 0; i < 100; i++) { opacity = ((int) (opacity * 100 + 1)) / 100.0; System.out.println("opacity=" + new BigDecimal(opacity)); System.out.println("opacity*100=" + new BigDecimal(opacity * 100)); System.out.println("----------------------------"); }
因为Java在将浮点数转换为字符串时会做一些处理,让结果看起来更“美观”一些,但这样会让我们无法看清楚程序运行的真实情况。
在这个程序中我借助BigDecimal来显示浮点数在内存中的真正的样子。BigDecimal有一个以double数字为参数的构造方法,该方法会完整拷贝此double参数在内存中的位模式,它的toString( )方法也会严格按照实际的值进行转换,而不会为了“美观”而做任何处理。因此我们可以利用这种方法来看清一个double的“真面目”。
程序输出如下:
opacity=0.01000000000000000020816681711721685132943093776702880859375opacity*100=1----------------------------opacity=0.0200000000000000004163336342344337026588618755340576171875opacity*100=2----------------------------opacity=0.0299999999999999988897769753748434595763683319091796875opacity*100=3(中间省略……)opacity=0.270000000000000017763568394002504646778106689453125opacity*100=27----------------------------opacity=0.2800000000000000266453525910037569701671600341796875opacity*100=28.000000000000003552713678800500929355621337890625----------------------------opacity=0.289999999999999980015985556747182272374629974365234375opacity*100=28.999999999999996447286321199499070644378662109375……后面一直重复相同的内容
可以发现,当opacity的值为0.29时,实际上在内存中的准确值是0.2899999……,所以乘以100变成28.99999……,这比29要稍微小那么一点点。但就是少了这一点点,当强制转换为整数后的结果却是28而不是期望的29。而这正是导致这个问题的原因所在。
从这个程序的运行结果中我们还可以观察到以下几个现象:
1. 每个中间结果例如0.01、0.02……等等,都无法用double类型精确表示
2. 即使本身无法精确表示,但在0.28之前,opacity*100的结果却都是精确的
3. 在无法精确表示的数中,有些比真实值略大,而有些却比真实值略小。如果是前者,当截断小数位转成整型时得到的结果是“正确”的;但如果是后者则会得到错误的结果。例如0.28*100转成整型为28,而0.29*100转成整型不是29而是28。
如何改正
经过前面的分析,现在我们已经弄明白了问题产生的原因,那么该如何修正它呢?
之前的代码之所以无法正确运行,其根本原因在于一个double类型的数字强制转换为整型时会发生截断,这会导致小数部分全部丢失,然而计算的中间结果中有一些要比期望的整数值略小,截断小数位以后得到的是比期望值小1的值。
因此我们可以从以下两个方面着手修正此问题:一是从代码中去除强制转换操作;或者,保证截断之前的中间结果一定是比期望值略大的。
方法1. 去除强制转换
程序的目的是让opacity的值每次增加0.01,那么就只需要每次加上0.01就好了,完全不需要绕圈子。如下:
double opacity = 0; while (opacity < 1) { opacity += 0.01; System.out.println("opacity=" + opacity); }
这个程序简单、直接,而且没有任何问题。我个人推荐这个方法。该程序输出如下:
opacity=0.01
opacity=0.02
opacity=0.03
opacity=0.04
opacity=0.05
opacity=0.060000000000000005
(中间省略……)
opacity=0.9800000000000006
opacity=0.9900000000000007
opacity=1.0000000000000007
方法2. 保证截断之前的中间结果略大于期望值
既然原程序的问题发生在截断时,那么只要保证截断发生之前,中间结果的值略大于期望值,就能保证程序的正确性。例如如果要让截断后的结果为29,只要保证截断前的值在[29, 30)这个范围内即可。
如何做到这一点呢?
由于我们可以肯定在这个问题中,opacity*100的结果是非常接近我们所期望的整数的,只是由于double类型的精度限制而比期望的整数略大或略小而已,其误差一定非常非常小。
所以我们可以修改这句代码:
opacity = ((int) (opacity * 100 + 1)) / 100.0;
不是给opacity * 100加上1,而是加一个更大一些的数,例如1.5,变为:
opacity = ((int) (opacity * 100 + 1.5)) / 100.0;
如果我们期望的值是29,那么修改后的中间结果一定是在29.5附近,这样就能保证截断后的值一定是29了。程序如下:
double opacity = 0; for (int i = 0; i < 100; i++) { opacity = ((int) (opacity * 100 + 1.5)) / 100.0; System.out.println("opacity=" + opacity); }
输出为:
opacity=0.01
opacity=0.02
opacity=0.03
opacity=0.04
opacity=0.05
opacity=0.06
(中间省略……)
opacity=0.96
opacity=0.97
opacity=0.98
opacity=0.99
opacity=1.0
可以看到结果是正确的。
总结
只要稍有经验的程序员都知道浮点数不能直接进行相等比较,但是像这篇文章中所碰到的问题可能并不那么常见,因此有时不容易意识到发生了问题。
每个程序员都应该知道计算机中是采用近似值来保存浮点数的,当进行浮点数相关的计算时,需要时刻提防由于精度问题所导致的误差,并注意避免那些会影响到结果正确性的误差(所谓正确性,就是误差超出了所允许的最大范围)。
附:
下面这个网页列举了历史上的一些由于计算问题引起的软件灾难,其中一例是1996年欧洲航天局的Ariane 5火箭发射失败事件,该火箭发射后仅40秒即发生爆炸,导致发射基地的2名法国士兵死亡,并导致历时近10年、耗资达70亿美元的航天计划严重受挫。事后调查报告显示问题的原因出在火箭的惯性参考系的软件系统中,其中有一个地方是将水平方位的64位浮点数转换为一个16位的整数,当浮点数的值超过32767时,转换就会失败(即转换的结果是错误的),从而导致了悲剧的发生。