• 又见浮点数精度问题


    今天看到一篇文章: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.01000000000000000020816681711721685132943093776702880859375
    opacity*100=1
    ----------------------------
    opacity=0.0200000000000000004163336342344337026588618755340576171875
    opacity*100=2
    ----------------------------
    opacity=0.0299999999999999988897769753748434595763683319091796875
    opacity*100=3
     
    (中间省略……)
     
    opacity=0.270000000000000017763568394002504646778106689453125
    opacity*100=27
    ----------------------------
    opacity=0.2800000000000000266453525910037569701671600341796875
    opacity*100=28.000000000000003552713678800500929355621337890625
    ----------------------------
    opacity=0.289999999999999980015985556747182272374629974365234375
    opacity*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时,转换就会失败(即转换的结果是错误的),从而导致了悲剧的发生。
     
  • 相关阅读:
    sublimetext3安装配置
    .Net Core 商城微服务项目系列(八):购物车
    .Net Core 商城微服务项目系列(七):使用消息队列(RabbitMQ)实现服务异步通信
    eShopOnContainers学习系列(三):RabbitMQ消息总线实践
    Docker系列(五):.Net Core实现k8s健康探测机制
    Docker系列(四):容器之间的网络通信
    Docker系列(三):将.Net Core Api部署到Kubernetes (K8s)中
    Docker系列(二):通过Docker安装使用 Kubernetes (K8s)
    生产环境项目问题记录系列(一):一次循环数据库拖垮服务器问题
    .Net Core 商城微服务项目系列(六):搭建自己的Nuget包服务器
  • 原文地址:https://www.cnblogs.com/antineutrino/p/4525224.html
Copyright © 2020-2023  润新知