保姆级讲解JS精度丢失问题(图文结合)

3,369 阅读14分钟

一、问题来源

js精度丢失的问题相信大家都不陌生

截屏2023-03-29 下午10.41.04.png

1. 观点

我刚学前端那会儿就知道有这么个问题,之前背八股文的时候也去查过,但一直都不是真的懂这个知识点,所以光学习这一个知识点,我就花费了至少4次学习时间。

于是我明白了一个道理,如果一个知识点我们不是真正的懂得和理解,也许迟早我们会一遍又一遍的花费时间去学习这个知识点,在同一个地方跌倒很多次,这无疑是一个巨大的时间的浪费,所以再给我们一次机会,建议一次性搞懂一个问题的本质,了解一个知识点的来龙去脉,不要一次次浪费我们自己的时间。

所以这一次,我和大家一起把这个知识点搞的透透的,本篇文章可能很长,建议耐心阅读。

2.收获

耐心读完本篇文章,我们彻底搞清楚

  1. javascritp数字计算过程?
  2. 为什么会存在精度丢失?
  3. 隐藏位到底是什么?
  4. 64位双精度浮点数?
  5. 进制转换详细过程?

二、我们需要学习的前置知识

在阅读本篇文章之前,我们需要有以下的知识储备

  • 进制

    进制通俗来讲就是一种表示数量大小的制度(方式),人类创造进制可以说是一项伟大的发明,我们都知道古人计数是通过打绳结的方式,如果没有进制的出现,要表示一个很大的数字就会特别的困难。

    常见的进制有:

    进制特点进位
    二进制有2个数字符号,即0和1满2进1
    八进制有8个数字符号,0、1、2、3、4、5、6、7满8进1
    十进制有10个数字符号,0、1、2、3、4、5、6、7、8、9满10进1
    十六进制有16个数字符号,0、1、2、3、4、5、6、7、8、9、a、b、c、d、e、f满16进1

    人经过长期的学习和训练,已经习惯了对10进制的条件反射。所以当我们看到10这个符号的时候,我们会本能的觉得有10个数。那是因为我们人类的自然语言有能力用10个符号甚至更多来表示个位数,但是在计算机的世界,只有两个符号,所以当他们识别10的时候,表示这是2个数。所以才会有下面这个梗。

    这个世界上只有10种程序员,懂二进制的和不懂二进制的。

    题外话
    话说在阿凡达中,潘多拉星球的文明使用的就是8进制,因为纳威人每只手只有4根手指,所以形成了8进制的计数方式。 b13fd480a5739f9b9123d9bd.jpeg

    其实所有的进制都是为了一个目的,就是描述数量的大小。并且进制基数越大,在相同的位数下能够描述数量范围的能力就越强。如果只有2位,二进制可以最多表示2个数量,十进制可以最多表示10个数量,而16进制可以表示16个数量,而所有的进制都有同一种数学表达方式,如下所示

    7980320b052aedd72c6a2a119bf0f292.svg

    对于10进制来说,如果我们要计算101的数量,那么就有

    1×102+0×101+1×100=100+0+1=1011×10^2 + 0×10^1 + 1×10^0 = 100 + 0 + 1 = 101

    对于二进制来说,计算101数量,那么就有

    1×22+0×21+1×20=4+0+1=51×2^2 + 0×2^1 + 1×2^0 = 4 + 0 + 1 = 5

    也就是说进制能够表达的数量就是对 每一位 的位数 乘以基数的 当前位数次方 进行求和即可。

    以上的内容是让我们对进制有一个大概了解,接下来我们主要关注二进制(计算机)和十进制(人)以及他们之间的转换。

  • 如何转换

    十进制转二进制

    十进制转二进制其实比较简单,就是将十进制的数不断除以2,直到结果是0。然后得到每一次的余数逆序汇总起来即可。

    例如:我们要对十进制的101转为二进制,那么就有:

    101 / 2 = 50 ------ 余1
    50 / 2 = 25 ------ 余0
    25 / 2 = 12 ------ 余1
    12 / 2 = 6 ------ 余0
    6 / 2 = 3 ------ 余0
    3 / 2 = 1 ------ 余1
    1 / 2 = 0 ------ 余1

    余数逆序排列一下得到十进制的101的转为二进制就为1100101

    用js的方式表达就是:

    function decimalToBinary(decimal) { 
      let binary = ''; 
      while (decimal > 0) { 
        binary = (decimal % 2) + binary; 
        decimal = Math.floor(decimal / 2); 
      } return binary; 
    }
    

    二进制转十进制

    二进制转十进制其实就是将二进制表示的数量算出来就行。

    例如:我们要把1100101转为10进制,那么就有:

    = 1×26+1×25+0×24+0×23+1×22+0×21+1×201×2^6 + 1×2^5 + 0×2^4 + 0×2^3 + 1×2^2 + 0×2^1 + 1×2^0

    = 64 + 32 + 0 + 0 + 4 + 0 + 1

    = 101

    用js的方式表达就是这样:

    function binaryToDecimal(binary) { 
      var decimal = 0; 
      var len = binary.length
      for (var i = 0 ; i < len; i++) { 
        var bit = binary[i]; 
        decimal += bit*Math.pow(2,len - i - 1); 
      } 
      return decimal; 
    }
    
    

    想要快速对进制之间进行转换可以使用这个在线工具,我们只需要懂得其中的原理就可以了。

    以上是整数的转换,那小数呢?

    十进制的小数如何转换为二进制

    转小数其实就是将十进制的 xxx.xxx 转为二进制的 101010.101010 就可以了,整数部分我们上面已经搞定了,所以把问题简化成为十进制的0.xxx 转为 二进制的 0.101010 就可以。

    小数部分的转换原则是将十进制的数不断乘以2,然后看得到的值是否大于1,如果大于1就收集一个1并且减去一个1,小于1就收集0,不断循环往复直到得到的是1,然后顺序排列就得到二进制的小数了。

    我们举一个例子 将十进制的0.625转为二进制:

    0.625 x 2 = 1.25 大于1 收集 1 减掉1
    0.25 x 2 = 0.5 小于1 收集 0
    0.5 x 2 = 1 等于1 收集 1

    顺序排列得到 .101 就是十进制的0.625的二进制表达了。

    接下来我们再来看一个例子,将十进制的 0.1 转为二进制:

    0.1 x 2 = 0.2 小于1 收集 0
    0.2 x 2 = 0.4 小于1 收集 0
    0.4 x 2 = 0.8 小于1 收集 0
    0.8 x 2 = 1.6 大于1 收集 1 减掉1
    0.6 x 2 = 1.2 大于1 收集 1 减掉1
    0.2 x 2 = 0.4 小于1 收集 0
    0.4 x 2 = 0.8 小于1 收集 0
    0.8 x 2 = 1.6 大于1 收集 1 减掉1
    0.6 x 2 = 1.2 大于1 收集 1 减掉1
    0.2 x 2 = 0.4 小于1 收集 0
    0.4 x 2 = 0.8 小于1 收集 0
    0.8 x 2 = 1.6 大于1 收集 1 减掉1
    0.6 x 2 = 1.2 大于1 收集 1 减掉1
    0.2 x 2 = 0.4 小于1 收集 1
    ....

    得到的结果是0.000110011001100110011001100....
    会发现,这就陷入死循环了。

    如果空间无限,根据极限原理,二进制是可以无限接近十进制的0.1的。

    but!
    but!
    but!

    计算机的存储空间是有限的,所以就需要在合适的位置截取,然后来表达十进制的0.1
    所以二进制出现了第一个问题,无法严格准确的表达十进制的某些小数(这个特点特别重要,请狠狠的把它记住,我们之后会用到)

    我们还是把小数部分十进制如何转二进制用js来表达一下:

    function dec2bin(decimal , precision = 12) { // 因为要表达精度,避免无限循环,所以用一个精度来做限制,默认精确到第12位
      var binary= '.';
      while(decimal > 0 && precision > 0){
        decimal*=2;
        if(decimal >= 1){
          binary+='1';
          decimal-=1;
        }else{
          binary+='0'
        }
        precision--;
      }
      return binary
    }
    
    
    dec2bin(0.625)  // .101
    dec2bin(0.1)    // .000110011001
    

    既然氛围都烘托到这里了,那大家是不是猜到接下来要讲二进制的小数转为十进制了!

    二进制的小数转十进制的小数就相对好理解一些。

    例如:将0.000110011001转为十进制的小数

    先截取小数部分000110011001 12位

    = 0×21+0×22+0×23+1×24+1×25+0×26+0×27+1×28+1×29+0×210+0×211+1×2120×2^{-1} + 0×2^{-2} + 0×2^{-3} + 1×2^{-4} + 1×2^{-5} + 0×2^{-6} + 0×2^{-7} + 1×2^{-8} + 1×2^{-9} + 0×2^{-10} + 0×2^{-11} + 1×2^{-12}

    = 0+0+0+116+132+0+0+1256+1512+0+0+140960 + 0 + 0 + \frac{1}{16} + \frac{1}{32} + 0 + 0 + \frac{1}{256} + \frac{1}{512} + 0 + 0 + \frac{1}{4096}

    = 4094096\frac {409}{4096}

    = 0.099853515625

    大家可以看到当我把0.1的二进制截取12位之后再转为十进制的时候,就会存在一定的误差,但也近似的约等于0.1,如果我们截取的精度更长一些,那么这个误差就会越小。

    那么我们留一个小作业可以评论区思考探讨一下哈!

    用js表达二进制小数转十进制小数!评论区见

    通过上面的内容我们了解了二进制和十进制之间的互相转换,并且分类讨论了整数和小数。并且知道了一个结论,在计算机有限的存储空间中,二进制无法准确的表示十进制的某些小数。

  • 科学计数法

    百度百科
    科学记数法是一种记数的方法,把一个数表示成a与10的n次幂相乘的形式(1≤|a|<10,a不为分数形式,n为整数),这种记数法叫做科学记数法。 当我们要标记或运算某个较大或较小且位数较多时,用科学记数法免去浪费很多空间和时间。

    我相信各位之前都学过科学计数法,根据我的了解在全网大多数的文章当中,也都介绍了这个概念。

    但是为什么要用这个呢?如果不用这个方法行不行!

    于是接下来我需要我们一起来做一个思考和想象。

    我们都知道计算机存储数据是需要空间的,假设现在有4个格子,每一个格子可以表示一位数,可以使用任何方式,请问怎么用这4个格子表示最大的数量。

    截屏2023-03-30 下午11.20.31.png

    为了方便我们就用十进制来思考这个问题。

    思考:如果用10进制,我们很容易想到,每个格子就使用当前位的最大的数就可以表示最大的数量。

    截屏2023-03-30 下午11.21.20.png

    所以9999就是在十进制和4个格子这两个大前提下所能表示的最大的数量!

    思考:还能否有其他的方式呢?既然我们的目标是尽可能表达更大的数量,那我们是否可以修改计算规则呢?

    聪明的你想到了,我们可以把最后一位拿出来作为指数,前面三位作为基数,表达数量的计算规则改为:

    最终数量 = 基数*(自由数^指数)

    在脑海中构思一个下面的例子

    截屏2023-03-30 下午11.48.12.png

    以上的小数点和分割线都是构想出来的,不需要真的表示出来。并且小数点应该恒定在第一位后面,如果在第二位的话就无法表达出1这个数字,根据这个规则,能够表示的最大数量为9.99×109=99900000009.99 × 10^9 = 9990000000

    惊了,我们只是改变了一下计算规则,当自由数为10的情况下,能够表示的最大数量就远远超过了刚开始的9999。

    但是它真的毫无缺点么?这个世上哪有只有优点没有缺点的东西呀!

    抱着这丝怀疑,我们发现还真有一个缺点 —— 通过这种规则我们没办法准确表示1001这个数。

    我们不妨试一下,如果要表达1001,那指数位必须是3,前面肯定就是1.001了呗,因为1.001×103=10011.001 × 10^3 = 1001,但是!!!

    截屏2023-03-30 下午11.53.35.png

    这样的话,就需要多加一个格子,但是我们依然要遵循大前提使用4个格子,因此必须舍弃一位,那这一位只能是最微不足道的最后一位1了,因为他的权重最小,所以在这种规则下只能用1000来近似表示1001了。

    并且不仅仅是1001表示不了,1002、1003、1004都无法准确表示,这些数都只能用1000去近似表示。这个特点我们把它叫做精度丢失

    我们再回想之前最开始的方法,它虽然最大只能表达9999,但是它能够精确的表达0-9999之间的任何一位数。所以任何技巧都有缺点有优点,我们要正确认识并加以利用。

    所以根据以上的思想实验,我们发现,通过我们自定义的规则我们虽然在有限位的基础上表达了更大的数量,但却在一定程度上丧失了表达的准确度,也就是精度。并且我们可以推导出,如果自由数选择的越大,精度丧失的就越多,自由数选择的越小那么精度丧失的就越小。

    聪明的你可能已经想到了,我们自定义的规则就是科学计数法,为什么要用科学计数法的原因就是 -- 在有限的存储空间下能够尽可能表达更大的数,但缺点是丧失了一定的精度。最后请将这个小案例狠狠的记住,代号为案例1,因为接下来我们要引用到。

    课外知识点

    在数学当中0到1之间的存在数其实是无限的,更别说整个实数范围了,我们人类无法通过有限的空间枚举和存储每一个实数,因此我们只能存储和枚举那些我们常用到的和可以被用到的数,使用进制的方式可以存储和表示的数大概是这样一个感觉。 截屏2023-03-31 下午12.48.06.png 当然小于零也是镜像对称的,只是没画而已哈!其中x表示某个范围,用我们之前的例子x就是9999。也就是用4位十进制数能够表达的最大范围就是9999,最小就是0。可以看出能够表示的数是均匀分布的。

    但是如果采用科学计数法呢?
    截屏2023-03-31 下午12.57.50.png 这是一条逐渐稀疏的线,其中x在相同条件下要比上面普通进制的x大很多。科学计数法能够表示的数在越小数量级范围内会越多,所以能够被枚举和存储的数就越多,给我们的感觉就是越精确,在越大数量级范围内就会越少,所以要表示出一个不能被枚举的数,只能用可以被枚举出来的数来近似的替代,给我们的感觉就是越不精确。

三、javascript的数字世界

前面准备工作可能有点长,但是一切都是为了更好的吸收下面的内容,话不多说,喝口水我们开始吧!

任何一门编程语言,在引入数这个概念的时候都要考虑输入输出、计算、存储这几件事情。

输入输出是为了与人进行交互,程序员要用编程语言输入一个数,和期待的输出肯定希望是十进制。

计算是将两个数进行加减乘除,在计算机中采用二进制的方式进行运算,这个没有别的选择。

上面都是比较基础的内容,但是存储呢?

在javascript中数值是一种基本类型,都在Number的管理范畴之内,无论是通过字面量的方式创建一个数值,还是通过构造函数的方式创建,他们都是Number类型。而Number类型采用64位双精度浮点数来存储数值。

64位双精度浮点数

64位双精度浮点数是一种二进制的科学计数法,它采用64个比特,也就是8个字节来存储数值。通俗的理解我们可以认为有64个格子来表示一个数。并且每一位只能是0或者1。

这64位中,1位是符号位,11位是指数位,52位是尾数,就像下面这样。

v2-8a7a3dfe54d0c5ce9b008bc29e144873_1440w.webp

v2-5036be957a2d50373ca74e83bad3e8be_1440w.png

为什么要使用科学计数法来存储呢?知其然也要知其所以然嘛!

其实主要的目的就是为了表示更大的范围!我们分别来讨论一下吧!

  • 假如使用普通二进制存储

    那么能表示的最大数为26312^{63} - 1,因为还要去除掉一位符号位。它的结果是:

    9223372036854776000 // 大概是920亿亿多一点
    

    虽然看起来挺大的,但是作为一门编程语言还是有点小气了点,如果告诉别人我能表示的最大数就是这么多,很不利于这门编程语言的推广。而且让有限的空间尽可能表示更多的数也是应该不断探索的。

  • 尝试用科学计数法

    在准备部分我们学过怎么表示计算科学计数法的最大值。

    指数部分最大的数 ✖️ 尾数部分最大的数。

    分别对应尾数部分全为1的情况和指数部分全为1的情况。那就是下面这样

    截屏2023-04-01 上午10.55.18.png

    指数部分为 2101=10232^{10}-1 = 1023

    案例1中,我们将小数点定义在了第一个格子后面,我们当然也可以认为二进制中小数点是在第一位的。

    截屏2023-04-01 上午11.23.15.png

    如果将第一位永远都视为1,在与指数位的配合下,会多出一种可能来性来,因此既然永远是1就可以不用存储,而将52位永远存储为1.后面的小数部分,这样52位就具备了53位的存储能力,这没有真正占有空间的一位就是隐藏位。

    截屏2023-04-01 下午2.11.28.png

    所以尾数最大部分为 1(隐藏位).(52个1),在二进制将小数点移到最右侧,需要将指数部分减去52位:

    =11111111111111111111111111111111111111111111111111111×2102352= 11111111111111111111111111111111111111111111111111111 × 2^{1023 - 52}

    =(2531)×2971= (2^{53} - 1) × 2^{971}

    =1.7976931348623157e+308= 1.7976931348623157e+308

    1.79×10308≈ 1.79 × 10^{308}

    实际上,这正是Number能够表达、枚举和存储的最大值了,Number用了一个常量来表示,超过这个数就会使用Infinity来表示:

    
    Number.MAX_VALUE // 1.7976931348623157e+308
    
    

    课外知识点
    大家不要小瞧这个数,在可观测的宇宙范围内,整个宇宙的原子数量也才到1078108210^{78}到10^{82}这个数量级,所以理论上来说没有什么统计和计算超过Number.MAX_VALUE这个范围了,完全够人类使用了。

    上面只是Number有能力表示的最大数,但是和精度表示还是两码事。实际上一旦超过某个范围,js就不能够精确表示某个数了,只能用近似的数去表示,例如你可以看到这样的奇怪现象。

    9007199254740993 === 9007199254740992 // true
    
    

    全等是类型和值都相等,类型相当可以理解,因为都是Numbe类型。那就说明在底层的值的存贮上,他们俩的值是一样的。

    在案例1中我们学过,科学计数法能够均匀枚举的整数范围就是尾数最大值呀,所以我们很容易得出,安全的整数范围就是:

    =2531= 2^{53}- 1
    =9007199254740991= 9007199254740991

    因为有53位(加上隐藏位)所以能够枚举和表达出的每一个[0,9007199254740991]之间的整数,超过这个范围就不行了,所以才会出现上面的现象。实际上,这正是Number也用了一个常量来存储。

    Number.MAX_SAFE_INTEGER // 9007199254740991
    

四、精度丢失过程

讲到这里其实大家可能或多或少能够明白为什么会丢失精度了,那接下来我们再打扎实一点,我们一步步来过一下为什么会丢失的过程。我们回到最开始的提问,从 0.1 + 0.2来开始吧!

分析:0.1和0.2是一种十进制的表达方式,因此当输入到计算机中会转为二进制。

第一步:解析+存储

0.1 => 0.0001100110011001100110011001100110011001100110011001101...
0.2 => 0.001100110011001100110011001100110011001100110011001101...

如果在前面看的仔细,这一步应该没问题哈!也可以偷个懒直接用这个工具

在计算机读到十进制的数,转为二进制成功的那一刻时,就要将该数读到内存中进行存储了。在存储过程中,就要使用科学计数法存储。因为上面的二进制表示实际上是一个无限循环的,而存储的空间只有52位所以需要截断

将第一位永远视为1那就会截断这个样子
=0.1= 0.1
=0.0001100110011001100110011001100110011001100110011001101...= 0.0001100110011001100110011001100110011001100110011001101... =1.1001100110011001100110011001100110011001100110011001×24= 1.1001100110011001100110011001100110011001100110011001 × 2^{-4}

第一位不用存储,且指数位为-4,加上偏移量1023的到1019。

为什么要加偏移量,因为在指数位有11位,11位能够表示的是[0 , 2023],如果用[0,2023]表示[-1024 , 1023],就需要将每次实际的指数加上1023。这样做的目的是为了更方便的比较大小。

1019转一下二进制就是 01111111011 (用11位表示) ,那么最终的存储方式就是 ,并且最后一位截断时需要0舍1入,所以就产生了误差,一步错步步错,这就是精度丢失的本质。

= 0+01111111011+1001100110011001100110011001100110011001100110011010

也就是下面这样

截屏2023-04-01 下午2.27.58.png

同理0.2也来看一下。

=0.2= 0.2
=0.001100110011001100110011001100110011001100110011001101...= 0.001100110011001100110011001100110011001100110011001101...
=1.1001100110011001100110011001100110011001100110011010×23= 1.1001100110011001100110011001100110011001100110011010 × 2^{-3}
= 0+01111111100+1001100110011001100110011001100110011001100110011010

第二步:计算
0.0001100110011001100110011001100110011001100110011001101
0.0011001100110011001100110011001100110011001100110011010
=>
0.01001100110011001100110011001100110011001100110011001110

第三步:存储

=0.010011001100110011001100110011001100110011001100110011100= 0.010011001100110011001100110011001100110011001100110011100
=1.00110011001100110011001100110011001100110011001100110100×22= 1.00110011001100110011001100110011001100110011001100110100 × 2^{-2}
= 0+011111111101+0011001100110011001100110011001100110011001100110100

第四步:输出

=0.010011001100110011001100110011001100110011001100110011100= 0.010011001100110011001100110011001100110011001100110011100
=0.30000000000000004= 0.30000000000000004

以上就大概还原了js精度丢失的过程。

最后我用两张图来大概表达Number所能表达的数,其实如果把64位所能表达的所有数看作一堆散点,那么他们撒向整个实数轴上,就有下面的分布:

对于整数

WechatIMG20.jpeg

对于小数+整数

WechatIMG19.jpeg

五、解决方案

目前前端领域有很多工具都可以解决精度丢失以及大数计算的问题,下面咱们可能需要到的库列出来一下,如何使用就自行查看啦。


1.math.js

2.number-precision

3.bigDecimal.js

4.bigNumber.js

六、参考资料

zhuanlan.zhihu.com/p/100353781

zhuanlan.zhihu.com/p/514844599

zhuanlan.zhihu.com/p/410729303

www.jianshu.com/p/3e08b3efc…

我的近期好文

shell、bash、zsh、powershell、gitbash、cmd这些到底都是啥

慎用正则表达式test!!

从0到1开发一个浏览器插件

更多精彩内容在我的个人网站 new-story.cn

七、最后的话

以上文章并没有太多讨论小于0的情况,但是他们都是和正数镜像对称的,我们掌握了正数的规律就可以。

以上文章是看了全网很多文章之后加上自己的理解总结出来的,如果有认为不正确或者有失公允的地方,请评论区留言讨论。

创作不易,如果您觉得文章有任何帮助到您的地方,或者触碰到了自己的知识盲区,请帮我点赞收藏一下,或者关注我,我会产出更多高质量文章,最后感谢您的阅读,祝愿大家越来越好。

八、维护

更新于4月6日

更新于4月8日

更新于4月14日