JavaScript 浮点数陷阱

1,140 阅读18分钟

前言

  JavaScript中的浮点数经常会有奇怪的运算结果,例如0.1 + 0.2 != 0.3或者是1.005.toFixed(2)结果为1.00,又或者Number.MAX_VALUENumber.MAX_SAFE_INTEGER的区别等等。

  此处对JavaScript浮点数的存储标准和以上疑问做了比较细致的整理,希望对你有用。

IEEE 754

  JavaScript与其它语言不同,Number类型是不区分整型和浮点的。对于所有的数字包括整数和小数相同存储,遵循IEEE 754的双精度标准,64位固定长度,也即是常说的double类型。

  由于整数和小数都采用64位存储,对于内存来说整型和浮点型也就没有区别了。

位运算上,会将操作数转为32位有符号数,小数部分直接丢弃

  64位比特包括三个部分。

  • 符号位(Ssign):第63位,0表示正数,1表示负数
  • 指数位(Eexponent):第5262位,共11位,取值范围为0~2047。但是指数位是可以为负数的,偏移2023后取值范围变为[-1023, 1024]
  • 尾数位(Mmantissa):第051位,共52位。

在这里插入图片描述

  计算公式为。

V=(1)S2E1023(M+1)V=(-1)^S2^{E-1023}(M+1)

  11.2564位表示。

  • 将整数和小数部分转换成二进制,即11.25 = 1011.01
  • 移动小数点,使其位于第12位之间,规范化为1.01101 * 2 ^ 3
  • 11.25为正数,S = 0。另外指数为3,则E = 1023 + 3 = 1026,即100 0000 0010
  • 舍去整数部分1,剩下尾数部分01101,空位补0,即0110 1000 ... 000052位)
  • 11.2564位浮点数的二进制表示为0 10000000010 01101000...0000

在这里插入图片描述

  (图片来源)

规范化后的整数部分必然为1,存储时可以省略,只记录小数点之后的部分, 也就节约了一位内存

就近舍入

  在四舍五入中,0 ~ 9十个数,0比较特殊,不会存在舍去的情况,舍不舍去都是当前数。而剩下的9个数中,1 ~ 4舍去,5 ~ 9上入,概率上舍去为4 / 9,上入为5 / 9,因此并不是公平的。

  就近舍入,或者叫银行家舍入,是四舍六入五成双。即1 ~ 4舍去,6 ~ 9上入,5的情况则看前一位是奇数还是偶数,偶数则舍去,奇数则上入,因此概率都是50%,更加合理。

  二进制中的就近舍入,其中1001大于1000则上入10111小于1000则舍去,而对于1000的情况,看前一位是奇数还是偶数,若为0则舍去,为1则上入1

1.001 1001 // 1.010
1.001 0111 // 1.001
1.001 1000 // 1.010

1.100 1001 // 1.101
1.100 0111 // 1.100
1.100 1000 // 1.100

Number.MAX_VALUE

  Number.MAX_VALUEJavaScript中所能表示的最大数值。

  按照IEEE 75464位标准,可以明显想到以下表示。

0 11111111111 1111111111111111111111111111111111111111111111111111

  但是注意指数位全为1用来表示NaNInfinity,因此指数位最大为111 1111 1110

0 11111111110 1111111111111111111111111111111111111111111111111111

  转为十进制。

  1.1111111111111111111111111111111111111111111111111111 * 2 ^ (2046 - 1023)
= 1.1111111111111111111111111111111111111111111111111111 * 2 ^ 1023
= 1 1111111111111111111111111111111111111111111111111111 * 2 ^ (1023 - 52)
= 1 1111111111111111111111111111111111111111111111111111 * 2 ^ 971
= (2 ^ 53 - 1) * 2 ^ 971
= 1.7976931348623157e+308

  也即是Number.MAX_VALUE

(Math.pow(2, 53) - 1) * Math.pow(2, 971) // 1.7976931348623157e+308
(Math.pow(2, 53) - 1) * Math.pow(2, 971) === Number.MAX_VALUE // true

Number.MAX_SAFE_INTEGER

  Number.MAX_SAFE_INTEGER 表示JavaScript中最大的安全整数。

  Number.MAX_SAFE_INTEGER二进制表示,注意指数刚好为52

0 10000110011 1111111111111111111111111111111111111111111111111111

  转为十进制。

  1.1111111111111111111111111111111111111111111111111111 * 2 ^ (1075 - 1023)
= 1.1111111111111111111111111111111111111111111111111111 * 2 ^ 52
= 1 1111111111111111111111111111111111111111111111111111
= 2 ^ 53 - 1
= 9007199254740991

  然后来说说为什么叫安全整数,指的是当前整数转换为二进制时,可以完全存储在尾数位中,不会发生舍入。

  而Number.MAX_SAFE_INTEGER + 1Number.MAX_SAFE_INTEGER + 2都会存在舍去的情况。

  Number.MAX_SAFE_INTEGER + 29007199254740993的二进制表示。

100000000000000000000000000000000000000000000000000001

  规范化。

1.00000000000000000000000000000000000000000000000000001 * 2 ^ 53

  由于指数位只能容纳52位,低位为1且前一位为0,就近舍入时会被舍去。

0000000000000000000000000000000000000000000000000000(1)
0000000000000000000000000000000000000000000000000000

  64位浮点数表示。

0 10000110100 0000000000000000000000000000000000000000000000000000

  另外Number.MAX_SAFE_INTEGER + 1,也会存在舍去的情况。

  9007199254740992的浮点数表示。

0 10000110100 0000000000000000000000000000000000000000000000000000

  因此会造成以下结果。

Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2 // true

0.1 + 0.2 != 0.3

0.1

  先来将0.1转换为二进制,连续乘以2并取整。

0.1 * 2 = 0.2 ----- 整 00.2
0.2 * 2 = 0.4 ----- 整 00.4
0.4 * 2 = 0.8 ----- 整 00.8
0.8 * 2 = 1.6 ----- 整 10.6
0.6 * 2 = 1.2 ----- 整 10.2
0.2 * 2 = 0.4 ----- 整 00.4

  0.1的二进制表示,0011将无限循环下去。

0.0 0011 0011 0011 (0011)

  规范化,另外0.1为正数,S = 0,指数为-4 + 1023 = 1019,即011 1111 1011

1.1 0011 0011 (0011) * 2 ^ -4

  尾数位最多存储52位,且采用就近舍入模式,向前进1

1001100110011001100110011001100110011001100110011001(10011)
1001100110011001100110011001100110011001100110011010

  0.164位浮点数表示。

0 01111111011 1001100110011001100110011001100110011001100110011010

在这里插入图片描述

0.2

  0.2的二进制。

0.0011 0011 0011 (0011)

  规范化0.2S = 0,指数为-3 + 1023 = 1020,即011 1111 1100

1.1 0011 0011 (0011) * 2 ^ -3

  存储52位,其余舍去,向前进1

1001100110011001100110011001100110011001100110011001(10011) 
1001100110011001100110011001100110011001100110011010

  0.264位浮点数表示。

0 01111111100 1001100110011001100110011001100110011001100110011010

在这里插入图片描述

  因此实际上0.10.2转换为64位的双精度浮点数时,都存在精度损失。

0 01111111011 1001100110011001100110011001100110011001100110011010 // 0.1
0 01111111100 1001100110011001100110011001100110011001100110011010 // 0.2

浮点数运算三步骤,对阶、求和、规范化

对阶

  0.10.2的指数部分阶次不一致,要先统一阶次,且遵循小阶向大阶看齐的原则。

  比如十进制的1.5 * 10 ^ 10 + 1.23 * 10 ^ 13,保留小数点前后1位。

  • 大阶向小阶看齐时,即1.5 * 10 ^ 10 + 1230 * 10 ^ 10,结果为1231.5 * 10 ^ 10,规则舍弃后为1.5 * 10 ^ 10
  • 小阶向大阶看齐时,即0.0015 * 10 ^ 13 + 1.23 * 10 ^ 13,结果为1.2315 * 10 ^ 13,规则舍弃后为1.2 * 10 ^ 13

  明显看到小阶向大阶更能接近实际结果。另外阶次若加1,尾数位就要右移1位,阶次相同时对阶完成。

  为什么阶次加1尾数位就右移1位呢?

  0.164位浮点数表示,值为1.1001 1001 ... 1001 1001 1010 * 2 ^ -4

0 01111111011 (1.)1001100110011001100110011001100110011001100110011010

  此处若阶次加1,要保持值大小不变,小数点要左移1位,即0.1 1001 1001 ... 1001 1001 1010 * 2 ^ -3,也就相当于尾数位右移1位。右移后空位补1,也就是省略的整数部分的1。注意若还要右移,空位只能补0,原因在于整数部分后续都是0了。

0 01111111110 (0.)1100110011001100110011001100110011001100110011001101(0)

  因此对阶过程如下,由于低位为0,可以省去。

0 01111111011 1001100110011001100110011001100110011001100110011010 // 0.1
0 01111111100  100110011001100110011001100110011001100110011001101(0) // 阶次加 1,尾数位右移 1 位
0 01111111100 1100110011001100110011001100110011001100110011001101 // 空位补 1

注意尾数位右移是将低位移出,会损失一定的精度,为了减小误差,将保留若干移出的位,在以后的规范化时再做舍入

求和

  阶数相同,开始求和。

  0 01111111100 1100110011001100110011001100110011001100110011001101 // 0.1
+ 0 01111111100 1001100110011001100110011001100110011001100110011010 // 0.2

  更好理解的方式,注意尾数位产生了进位。

  0.1100110011001100110011001100110011001100110011001101
+ 1.1001100110011001100110011001100110011001100110011010
 10.0110011001100110011001100110011001100110011001100111

规范化

  求和结果为10.0 1100 1100 ... 1100 1100 111 * 2 ^ -3,即1.00 1100 1100 ... 1100 1100 111 * 2 ^ -2

1.00110011001100110011001100110011001100110011001100111

  由于指数位只能容纳52位,就近舍入后向前进1

0011001100110011001100110011001100110011001100110011(1)
0011001100110011001100110011001100110011001100110100

  IEEE 754的双精度64位表示。

0 01111111101 0011001100110011001100110011001100110011001100110100

  即1.0011 0011 ... 0011 0011 0100 * 2 ^ -2,转换为十进制也就是0.30000000000000004

小结

  要明确的是,诸如0.10.2之类的数,虽然在十进制中能非常清晰地表达,但是在二进制中却是无穷尽的,无法精确表示。而JavaScript遵循IEEE 754的双精度标准,仅64位,进制的转换中要舍弃低位来存储,因此必然存在精度丢失。

  另外在相加的过程中,对阶、求和、规范化都可能存在舍入,也会存在精度的丢失。

1.005.toFixed(2)

  1.005的二进制表示,0000 1010 0011 1101 0111将无限循环下去。

1.000 (0000 1010 0011 1101 0111)

  IEEE 754的双精度64位表示。

0 01111111111 0000000101000111101011100001010001111010111000010100(0)
0 01111111111 0000000101000111101011100001010001111010111000010100

  1.005截断后面位数后,已经小于1.005,利用 Number.prototype.toPrecision 来看下1.00520位精度。

在这里插入图片描述

  因此toFixed保留两位小数,四舍五入时。

1.005.toFixed(2) // 1.00

Number.MAX_VALUE + 1 不是 Infinity

Number.MAX_VALUE + 1 === Number.MAX_VALUE

  Number.MAX_VALUE164位浮点数表示为。

0 11111111110 1111111111111111111111111111111111111111111111111111 // Number.MAX_VALUE
0 01111111111 0000000000000000000000000000000000000000000000000000 // 1

  相加对阶时1的阶数将由0升到1023,尾数位将右移1023位。

  0 11111111110 1111111111111111111111111111111111111111111111111111 // Number.MAX_VALUE
+ 0 11111111110 0000000000000000000000000000000000000000000000000000(000...0001) // 1

  求和。

  1.1111111111111111111111111111111111111111111111111111
+ 0.0000000000000000000000000000000000000000000000000000(000...0001)
  1.1111111111111111111111111111111111111111111111111111(000...0001)

  规范化时低位将舍弃,实际相当于Number.MAX_VALUE0,所以会有以下结果。

Number.MAX_VALUE + 1 === Number.MAX_VALUE // true

2 ^ 970

  那到底Number.MAX_VALUE加上多少等于Infinity呢?

  按照IEEE 754的规范,只要大于等于以下数,就会被表示为Infinity

2Emax(221p2)2^{E_{max}}(2-\frac{2^{1-p}}{2})

  64位浮点数中,Emax1023p53

  2 ^ 1023 * (2 - 2 ^ (1 - 53) / 2)
= 2 ^ 1023 * (2 - 2 ^ -53)
= 2 ^ 970 * (2 ^ 54 - 1)

  此结果与Number.MAX_VALUE差值为。

  (2 ^ 54 - 1) * 2 ^ 970 - (2 ^ 53 - 1) * 2 ^ 971
= 2 ^ 970

  因此Number.MAX_VALUE至少加上2 ^ 970才能等于Infinity

Number.MAX_VALUE + 2 ^ 970 === Infinity

  Number.MAX_VALUE2 ^ 97064位浮点数表示。

0 11111111110 1111111111111111111111111111111111111111111111111111 // Number.MAX_VALUE
0 11111001001 0000000000000000000000000000000000000000000000000000 // 2 ^ 970

  相加对阶时2 ^ 970阶数将由970升到1023,差值为53,尾数位将右移53位。

  0 11111111110 1111111111111111111111111111111111111111111111111111 // Number.MAX_VALUE
+ 0 11111111110 0000000000000000000000000000000000000000000000000000(1) // 2 ^ 970

  求和,就近舍入时,低位舍入,向前进1

  1.1111111111111111111111111111111111111111111111111111
+ 0.0000000000000000000000000000000000000000000000000000(1)
  1.1111111111111111111111111111111111111111111111111111(1)
 10.0000000000000000000000000000000000000000000000000000

  注意尾数位产生了进位,指数位则加1,尾数位右移1位。

  结果的64位浮点数表示,而以下表示实际就是Infinity64位浮点数表示。

0 11111111111 0000000000000000000000000000000000000000000000000000

参考

🎉 写在最后

🍻伙伴们,如果你已经看到了这里,觉得这篇文章有帮助到你的话不妨点赞👍或 Star ✨支持一下哦!

手动码字,如有错误,欢迎在评论区指正💬~

你的支持就是我更新的最大动力💪~

GitHub / GiteeGitHub Pages掘金CSDN 同步更新,欢迎关注😉~