javascript语言数字运算缺陷
前言
但凡是入门了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
所以负整数的转换比刚才的正整数多了三个步骤:
- 对负整数取绝对值得到结果A
- 对正整数A转换为二进制,结果为B
- 对B的每一位都取反,得到结果C
- 对结果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) + 1*2^(-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存储到内存的格式按照下面步骤计算:
- 0.125 转为二进制:0.001
- 二进制转为科学计数法表示: 1.0 * 2^-3
- 按照上面的表示,其E=-3+1023,M=0,所以存储内容如下:
逆方向可以直接转换为十进制:
- 0表示正数
- E符合上面说的第一种情况:真实值是1020-1023=-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_INTEGER
和Number.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、解决方案
- 使用mathjs
- 使用number-precision
- 使用bigjs
- 自己动手写几个简单可用的运算函数,这个在网上都可以找到的。
这几种方法的权衡和选择依据项目条件来决定。
到此介绍完毕,如果文章有哪些纰漏的话,各位童鞋可以留言指出,多谢。
参考
公众号关注一波~
网站源码:linxiaowu66 · 豆米的博客
Follow:linxiaowu66 · Github
关于评论和留言
如果对本文 javascript语言数字运算缺陷 的内容有疑问,请在下面的评论系统中留言,谢谢。