美文网首页
JS中0.1 + 0.2 不等于0.3 ?

JS中0.1 + 0.2 不等于0.3 ?

作者: QiShare | 来源:发表于2022-10-26 17:45 被阅读0次

    例子:

    你有没有发现一个场景,在JS中对十进制数进行了一些算术计算,但它返回了一个奇怪的结果?

    比如以下例子:

    • 0.1 + 0.2 期望是等于 0.3 但显示结果是 0.30000000000000004
    • 6 * 0.1期望是 0.6 但显示结果是 0.6000000000000001
    • 0.11 + 0.12 期望是 0.23 但显示结果是 0.22999999999999998
    • 0.1 + 0.7 显示结果是 0.7999999999999999
    • 0.3 + 0.6 显示结果是 0.8999999999999999。…… 还有其他一些类似的情况。
    image.png

    从上面可以看到,0.1+0.2!==0.3并且0.1+0.2的结果是0.30000000000000004。为什么会出现这样的结果呢?

    下文就让我们一块来探索一下这背后的原因。

    十进制和二进制表示的小数特点:

    • 在base10 (十进制)的系统(由人类使用)中,如果使用以10为底的质因数,则可以精确表示分数。
      • 2和5是10的质因数。
      • 1/2、1/4、1/5 (0.2)、1/8 和 1/10 (0.1) 可以精确表示,因为分母使用 10 的质因数。
      • 而 1/3、1/6 和 1/7 是重复小数,因为分母使用 3 或 7 的质因数。
    • 另一方面,在base 2 (二进制)的系统中(由计算机使用),如果使用以 2为底的素因子,则可以精确表示分数。
      • 2 是 2 的唯一质因数。
      • 所以 1/2、1/4、1/8 都可以精确表示,因为分母使用 2 的质因数。
      • 而 1/5 (0.2) 或 1/10 (0.1) 是重复小数。

    我们在计算数学问题的时候,使用的是十进制,计算0.1 + 0.2的结果等于0.3,没有任何问题。但在计算机中,存储数据使用的是二进制,数据由0和1组成。所以在对数据进行计算时,需要将数据全部转换成二进制,再进行数据计算。

    进制转换:

    十进制转二进制的主要原则如下所示:

    • 十进制整数转换为二进制整数:除2取余,逆序排列
    • 十进制小数转换为二进制小数:乘2取整,顺序排列

    这里主要介绍小数转换。十进制小数转二进制,小数部分,乘 2 取整数,若乘之后的小数部分不为 0,继续乘以 2 直到小数部分为 0 ,将取出的整数正向排序。

    例如: 0.1 转二进制

    0.1 * 2 = 0.2 --------------- 取整数 0,小数 0.2
    0.2 * 2 = 0.4 --------------- 取整数 0,小数 0.4
    0.4 * 2 = 0.8 --------------- 取整数 0,小数 0.8
    0.8 * 2 = 1.6 --------------- 取整数 1,小数 0.6
    0.6 * 2 = 1.2 --------------- 取整数 1,小数 0.2
    0.2 * 2 = 0.4 --------------- 取整数 0,小数 0.4
    0.4 * 2 = 0.8 --------------- 取整数 0,小数 0.8
    0.8 * 2 = 1.6 --------------- 取整数 1,小数 0.6
    0.6 * 2 = 1.2 --------------- 取整数 1,小数 0.2
    ...
    

    最终 0.1 的二进制表示为 0.000110011...... 后面将会 0011 无限循环,因此二进制无法精确的保存类似 0.1 这样的小数。那这样无限循环也不是办法,又该保存多少位呢?也就有了我们接下来要重点讲解的 IEEE 754 标准。

    IEEE 754

    维基百科的链接,感兴趣的自行了解一下。

    这里用一句话概述,IEEE754是一种二进制浮点数算术标准

    IEEE 754 常用的两种浮点数值的表示方式为:单精确度(32位)、双精确度(64位)。例如, java语言中的 float 通常是指 IEEE 单精确度,而 double 是指双精确度。

    在 JavaScript 中不论小数还是整数只有一种数据类型表示,这就是 Number 类型,其遵循 IEEE 754 标准,使用双精度浮点数(double)64 位(8 字节)来存储一个浮点数。

    双精度(64bits)浮点数的三个域:

    • sign bit(S,符号):用来表示正负号,0 为 正 1 为 负(1 bit)
    • exponent(E,指数):用来表示次方数(11 bits)
    • Significand(M,尾数):用来表示精确度 1 <= M < 2(52 bits)
    image.png

    下面看一下0.1在IEEE 754 标准中是如何存储的?

    如下图所示(此网站):

    image.png

    可以看出: 指数位决定了大小范围,小数位决定了计算精度

    有两个点需要注意:

    • IEEE 754标准规定,在保存小数Significand时,第一位默认是1,因此可以被舍去,只存储后边的部分。例如,1.01001保存的时候,只保存01001,等到用的时候再把1加上去。这样,就可以节省一个位的有效数字。
    • 指数E在存储的时候也有些特殊。为64位浮点数时,指数占11位,范围为0-2047 。但是,指数是有正有负的,因此实际值需要在此基础上减去一个中间数。对于64位,中间数为1023 。

    故0.1最后保存在计算机里,成为了以下形式:

    符号位: 0
    指数位: -4+1023 = 1019,二进制表示为:01111111011
    小数位:1.1001100110011001100110011001100110011001100110011001 ,舍弃第一位的1,根据最右边未显示的一位0舍1入,表示为:1001100110011001100110011001100110011001100110011010
    

    所以最终是:

        0  01111111011  1001100110011001100110011001100110011001100110011010
    S符号      E指数                            M尾数
    

    可以通过这个网站验证,如下所示:

    image.png

    0.1 + 0.2 等于多少?

    上面得到了0.1的二进制表示形式,下面推算一下0.2的二进制表示形式

    十进制0.2转为二进制为0.001100110011(0011循环),即 1.100110011(0011)*2^-3 ,存储时:

    符号位: 0

    指数位:-3,实际存储为 -3 + 1023 = 1020 的二进制 01111111100

    小数位: 1.100110011(0011循环),舍弃首位,截掉多余位后(精度损失的原因之一)为1001100110011001100110011001100110011001100110011010

    0  01111111100  1001100110011001100110011001100110011001100110011010
    S     E指数          M尾数
    
    image.png

    对阶运算

    接下来,计算 0.1 + 0.2 。

    浮点数进行计算时,需要对阶。即把两个数的指数阶码设置为一样的值,然后再计算小数部分。其实对阶很好理解,就和我们十进制科学记数法加法一个道理,先把指数部分化成一样,再计算小数。

    另外,需要注意一下,对阶时需要小阶对大阶。因为,这样相当于,小阶指数乘以倍数,小数部分相对应的除以倍数,在二进制中即右移倍数位。这样,不会影响到小数的高位,只会移出低位,损失相对较少的精度。

    因此,0.1的指数阶码为 -4 , 需要对阶为 0.2的指数阶码 -3 。尾数部分整体右移一位。

    1.100110011(0011)*2^-4 变成 0.1100110011(0011)*2^-3

    符号位: 0

    指数位:-3,实际存储为 -3 + 1023 = 1020 的二进制 1111111100

    小数位: 0.1100110011(0011循环),截掉多余位(0舍1入)后为1100110011001100110011001100110011001100110011001101

    原来的0.1
    0  01111111011  1001100110011001100110011001100110011001100110011010
    对阶后的0.1
    0  01111111100  1100110011001100110011001100110011001100110011001101
    

    然后进行尾数部分相加 ,做加法时我们带上整数位进行计算:

      0 01111111100   0.1100110011001100110011001100110011001100110011001101
    + 0 01111111100   1.1001100110011001100110011001100110011001100110011010
    = 0 01111111100  10.0110011001100110011001100110011001100110011001100111
    

    可以看到,产生了进位。因此,阶码需要 +1,即为 -2,尾数部分进行低位0舍1入处理(精度损失的原因之二)。因尾数最低位为1,需要进位。所以存储为:

    0  1111111101  0011001100110011001100110011001100110011001100110100
    

    最后把二进制转换为十进制,计算结果的二进制表示为:

    1.0011001100110011001100110011001100110011001100110100 * 2^-2

    转为十进制,最终结果为:

    0.30000000000000004

    所以 0.1 + 0.2 !== 0.3 这个问题就这样产生了。

    所以:

    精度损失可能出现在<u>进制转化</u>和<u>对阶运算</u>过程中

    只要在这两步中产生了精度损失,计算结果就会出现偏差。

    只有 JavaScript 中存在吗?

    这显然不是的,这在大多数语言中基本上都会存在此问题(大都是基于 IEEE754 标准),让我们看下 0.1 + 0.2 在一些常用语言中的运算结果。

    Python

    Python2 的 print 语句会将 0.30000000000000004 转换为字符串并将其缩短为 “0.3”,可以使用 print(repr(.1 + .2)) 获取所需要的浮点数运算结果。这一问题在 Python3 中已修复。

    # Python2
    print(.1 + .2) # 0.3
    print(repr(.1 + .2)) # 0.30000000000000004
    
    # Python3
    print(.1 + .2) # 0.30000000000000004
    

    Java

    Java 中使用了 BigDecimal 类内置了对任意精度数字的支持。

    System.out.println(.1 + .2); // 0.30000000000000004 (java中小数默认是double类型)
    
    System.out.println(.1F + .2F); // 0.3 (进制转换和对阶运算后正好是0.3)
    

    这个网站中可以看到有很多语言存在这种问题。

    解决方法:

    1、转换为整数计算

    function add(num1, num2) {
        //num1 小数位的长度
     const num1Digits = (num1.toString().split('.')[1] || '').length;
     //num2 小数位的长度
     const num2Digits = (num2.toString().split('.')[1] || '').length;
     // 取最大的小数位作为10的指数
     const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
     // 把小数都转为整数然后再计算
     return (num1 * baseNum + num2 * baseNum) / baseNum;
    }
    

    把计算数字 提升 10 的N次方 倍 再 除以 10的N次方。N>1。

    2、使用ES6提供的极小数Number.EPSILON:

    function numbersequal(a,b){ 
            return Math.abs(a-b) < Number.EPSILON;
    } 
    var a=0.1+0.2, b=0.3;
    console.log(numbersequal(a,b)); //true
    

    3、类库:

    (1)math.js

    math.js是JavaScript和Node.js的一个广泛的数学库。支持数字,大数,复数,分数,单位和矩阵等数据类型的运算。

    官网:http://mathjs.org/

    GitHub:https://github.com/josdejong/mathjs

    0.1+0.2 ===0.3实现代码:
    var math = require('mathjs')
    console.log(math.add(0.1,0.2))//0.30000000000000004
    console.log(math.format((math.add(math.bignumber(0.1),math.bignumber(0.2)))))//'0.3'
    

    (2)decimal.js

    为 JavaScript 提供十进制类型的任意精度数值。

    官网:http://mikemcl.github.io/decimal.js/

    GitHub:https://github.com/MikeMcl/decimal.js

    var Decimal = require('decimal.js')
    x = new  Decimal(0.1)
    y = 0.2
    console.log(x.plus(y).toString())//'0.3'
    

    (3)bignumber.js

    用于任意精度算术的JavaScript库。

    官网:http://mikemcl.github.io/bignumber.js/

    Github:https://github.com/MikeMcl/bignumber.js

    var BigNumber = require("bignumber.js")
    x = new BigNumber(0.1)
    y = 0.2
    console.log(x.plus(y).toString())//'0.3'
    

    (4)big.js

    用于任意精度十进制算术的小型快速JavaScript库。

    官网:http://mikemcl.github.io/big.js/

    Github:https://github.com/MikeMcl/big.js/

    var Big = require("big.js")
    x = new Big(0.1)
    y = 0.2
    console.log(x.plus(y).toString())//'0.3'
    

    总结:

    发现在js中存在0.1+0.2!= 0.3这个现象后,通过上面的分析发现此现象的原因为:在<u>进制转化</u>和<u>对阶运算</u>过程中会出现精度损失。在其他基于 IEEE754 标准的语言中也存在这种问题。并列出了几个解决的方法。

    参考链接:

    https://github.com/qufei1993/Nodejs-Roadmap/blob/master/docs/javascript/floating-point-number-0.1-0.2.md

    https://www.jianshu.com/p/e22d1268cb96

    https://juejin.cn/post/6844903680362151950

    https://xiaolincoding.com/os/1_hardware/float.html#_0-1-0-2-0-3

    相关文章

      网友评论

          本文标题:JS中0.1 + 0.2 不等于0.3 ?

          本文链接:https://www.haomeiwen.com/subject/uvtzzrtx.html