javascript语言数字运算缺陷

2,148 阅读11分钟
原文链接: blog.5udou.cn

前言

但凡是入门了js的童鞋,都知道js的数学运算是有缺陷的,当然这个问题并不只是在Javascript中才会出现,几乎所有的编程语言都采用了 IEEE-745 浮点数表示法,任何使用二进制浮点数的编程语言都会有这个问题,只不过在很多其他语言中已经封装好了方法来避免精度的问题,而 JavaScript 是一门弱类型的语言,从设计思想上就没有对浮点数有个严格的数据类型,所以精度误差的问题就显得格外突出。但也许很少有人会去琢磨为什么会有这种问题。这篇文章我们将从计算机基础讲起,告诉大家为什么会有这种问题,以及解决这些缺陷的办法。

1、十进制是如何转二进制的?

大家都知道,计算机内部存储的数据全都是二进制,无论什么数据类型,都会在最后转为二进制存储并运算。十进制也不例外。在代码中我们写一个赋值:const a = 1,这段代码是会转为一段计算机可以识别的二进制数字,从而让CPU可以执行。 其中数字1也会转为二进制,那么二者之间的转换规律是什么呢?

比如整数1,转化为二进制便是:

0000 0000 0000 0001

接下去我们说一下正整数、负整数、浮点数是如何转为二进制的,从而为后面的介绍奠定基础

1.2、正整数转为二进制

简单地概括其方法便是: 除二取余,然后倒序排列,高位补零。

举个栗子,数字13,按照上面的方法除二取余:

13 / 2 = 6 --- 1 6 / 2 = 3 --- 0 3 / 2 = 1 --- 1 1 / 2 = 1 --- 0

然后 倒序排放: 1011

然后高位补零(假设内存是32位的): 0000 0000 0000 0000 0000 0000 0000 1011

1.3、负整数转为二进制

在计算机中,负数以其正值的补码形式存储。

那么什么叫做补码呢?稍微有点计算机基础的童鞋应该都能知道吧?

计算机有三大码: 原码、反码、补码。

原码就是刚才正整数转为二进制的数

反码是将原码按位取反得到的新的二进制数,比如刚才的13:

原码: 0000 0000 0000 0000 0000 0000 0000 1011

反码: 1111 1111 1111 1111 1111 1111 1111 0100

补码是反码加1,即:

补码: 1111 1111 1111 1111 1111 1111 1111 0101

得到的补码便是-13在内存中的存储形式,十六进制表示为:0xFFFF FFF5

所以负整数的转换比刚才的正整数多了三个步骤:

  1. 对负整数取绝对值得到结果A
  2. 对正整数A转换为二进制,结果为B
  3. 对B的每一位都取反,得到结果C
  4. 对结果C加1

1.4、浮点数转为二进制

小数转二进制采用"乘2取整,顺序排列"法,具体做法是用2乘十进制小数,可以得到积,将积的整数部分取出,再用2乘余下的小数部分,又得到一个积,再将积的整数部分取出,如此进行,直到积中的小数部分为零,此时0或1为二进制的最后一位,或者达到所要求的精度为止。

比如: 0.125转为二进制:

0.125 2 = 0.25 ---> 0 0.25 2 = 0.5 ---> 0 0.5 * 2 = 1.0 ---> 1

于是其二进制便是(0.001)B

我们反验证一下: 0.125 = 0 2^(-1) + 0 2^(-2) + 1 * 2^(-3) = 1/8 = 0.125

结果是成立的。

以上的转换方法的原理可以参考: 十进制转二进制

2、JS的双精度格式存储

Js的数字存储标准是IEEE 754,标准是采用64位双精度浮点数,其中:

第0位:符号位, s 表示 ,0表示正数,1表示负数;

第1位到第11位:储存指数部分, e 表示 ;

第12位到第63位:储存小数部分(即有效数字),f 表示

如下图:

要最后搞懂数字的最后存储格式,我们还需要知道科学计数法

其表达式是: m × b^n (1 ≤ m <b 并且 n∈ℤ)

举个🌰 :

1234 = 1.234 × 10^3

当然也有二进制表示法:

举个栗子:

十进制浮点数7.25转换为二进制表示: 111.01(不要问我怎么转换的哈,不清楚地继续读读上面第一节的文章),用二进制的科学计数法来表示:

1.1101 2^2, 我们可以来验证一下: (12^0 + 12^(-1) + 12^(-2) + 02^(-3) + 12^(-4)) 2^2 = (1+0.5+0.25+0.0625) 2^2 = 7.25

现在知道了二进制的科学计数法转换,我们还要说一下IEEE 754的一些规则:

1、符号位决定了一个数的正负,指数部分决定了数值的大小,小数部分决定了数值的精度。

2、IEEE 754规定,有效数字M第一位默认总是1,不保存在64位浮点数之中。也就是说,有效数字总是1.xx…xx的形式,其中xx..xx的部分保存在64位浮点数之中,最长可能为52位,但是可以表示53位有效数字。比如保存1.01的时候,只保存01,等到读取的时候,再把第一位的1加上去。这样做的目的,是节省1位有效数字。

3、E为一个无符号整数(unsigned int)。因为E为11位,所以它的取值范围为0~2047。但是,我们知道,科学计数法中的E是可以出现负数的,所以IEEE 754规定,E的真实值必须再加上一个中间数后才能存储到内存中,对于11位的E,这个中间数是1023。比如刚才的7.25,E为2,保存到内存中应该是2+127=129,也就是000 10000 0001

4、另外E还需要考虑下面三种情况:

(1)E不全为0或不全为1。这时,浮点数就采用上面的规则表示,即指数E的计算值减去127(或1023),得到真实值,再将有效数字M前加上第一位的1。

(2)E全为0。这时,浮点数的指数E等于0-127(或者0-1023),有效数字M不再加上第一位的1,而是还原为0.xxxxxx的小数。这样做是为了表示±0,以及接近于0的很小的数字。

(3)E全为1。这时,如果有效数字M全为0,表示±无穷大(正负取决于符号位s);如果有效数字M不全为0,表示这个数不是一个数(NaN)

整合上面的所有信息,我们使用下面的例子来说:

浮点数0.125存储到内存的格式按照下面步骤计算:

  1. 0.125 转为二进制:0.001
  2. 二进制转为科学计数法表示: 1.0 * 2^-3
  3. 按照上面的表示,其E=-3+1023,M=0,所以存储内容如下:

逆方向可以直接转换为十进制:

  1. 0表示正数
  2. E符合上面说的第一种情况:真实值是1020-1023=-3
  3. 有效数M为0

于是二进制的科学计数法是:1.0*2^-3 => 转为十进制为0.125

3、js的浮点数运算缺陷

有了上面两小节的基础夯实,现在我们可以解释为什么在js中0.1+0.2会等于很长的一串数字?0.1*0.2也会是一串很长的数字?

根本原因是0.1转换为二进制是一个无限循环:

0.1 => 0.0 0011 0011 0011.....

因为在双精度的格式中有效数字是52位,所以0.1转化后完整的是 0.0 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011

转为科学计数法: 1.1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 * 2^-4

于是真实存储结构是:

同理0.2的真实存储结构是:

实际存储的位模式作为操作数进行浮点数加法,得到 0-01111111101-0011001100110011001100110011001100110011001100110100

转为十进制便是0.300000000000000044408920985006

参考:0.300000000000000044408920985006

但是为什么javascript只会打印前面有效数字17位呢?

原因可以查看我在stackoverflow的提问

How does JavaScript determine the number of digits to produce when formatting floating-point values?

另外一个经典的例子是: 0.1 + 0.125 为什么结果没有好多小数点?

因为二者相加得到:0.225000000000000005551115123126

根据上面的解释,得到0.22500000000000000,舍去最后的0就是0.225

参考:0.225000000000000005551115123126

3、 js超大整数溢出

因为所有的数字都需要转换为二进制,而转换完成后的二进制再使用科学计数法,从而存储到内存单元中,因此我们知道M的长度决定了可以表示的数字的范围。

M固定长度是52位,再加上省略的一位,最多可以表示的数是 2^53 - 1 = 9007199254740991, 所以范围是-9007199254740991 ~ 9007199254740991

可以通过JS定义的常量来证实:

Number.MIN_SAFE_INTEGERNumber.MAX_SAFE_INTEGER

那如果存储一个超过这个最大整数的值呢?会发生什么呢?

答案当然是溢出了。那么现在我们不仅仅要知道溢出,还想知道有什么溢出的规律呢?

因为M最多是53位,所以但凡是超过的位数的都会溢出从而被截断,我们举个🌰 :

9007199254740992的二进制表示:

100000000000000000000000000000000000000000000000000000

因为只能保存52位,所以这个值将被截断:

1.0000000000000000000000000000000000000000000000000000 * 2^53

保存到内存是:

取出来还原,可以还原到原来的值,因为补充53个0后是得到原来的二进制数,从而转化后可以得到原先的数: 9007199254740992。

但是9007199254740993呢? 我们还是按照上面的步骤:

二进制是: 100000000000000000000000000000000000000000000000000001

还是因为M的限制,只能保存52位,所以最后的一个1将被截断:

1.0000000000000000000000000000000000000000000000000000 * 2^53

保存到内存将会和9007199254740992一样,从而还原回十进制后,因为无法还原最后一位数,也就是1,所以得到的十进制数依然是9007199254740992,

从而你在控制台这样写是判断为true的: 9007199254740992 === 9007199254740993

举了这么一个例子是否看出什么规律来了?

溢出的数字将被截断,从而导致有对应的几个数会相等。

关于这点,牛人们已经总结出了对应的规律:

1、(2^53, 2^54) 之间的数会两个选一个,只能精确表示偶数 2、(2^54, 2^55) 之间的数会四个选一个,只能精确表示4个倍数 3、.. 依次跳过更多2的倍数

下面的这张图片很好解释了整数溢出导致精度缺失的严重程度:

4、解决方案

  1. 使用mathjs
  2. 使用number-precision
  3. 使用bigjs
  4. 自己动手写几个简单可用的运算函数,这个在网上都可以找到的。

这几种方法的权衡和选择依据项目条件来决定。

到此介绍完毕,如果文章有哪些纰漏的话,各位童鞋可以留言指出,多谢。

参考

  1. 双精度浮点数
  2. 在线转换双精度数字
  3. 0.30000000000000004
  4. What Every Computer Scientist Should Know About Floating-Point Arithmetic
  5. 进制互转
  6. toString()规范
  7. CSC231 An Introduction to Fixed- and Floating-Point Numbers
  8. Is Your Model Susceptible to Floating-Point Errors
  9. JavaScript 浮点数陷阱及解法