0.1 + 0.2 === 0.3 // false
/*
∵ 0.1 + 0.2 = 0.30000000000000004
∴ 0.1 + 0.2 ≠ 0.3
^_^
*/
要搞清楚这个问题,我们先来聊聊二进制浮点数算术标准(IEEE 754)
(补充思考)为啥是IEEE 754
你想呀,如果把数字8分别用10进制和2进制 写在一张纸上, 很显然10进制的一定会很更加节省墨水; 那么如何用2进制来表示10进制, 并且尽可能的节约墨水, 科学计数法是一个不错的思路, IEEE754其实就是一种2进制的科学计数法
别告诉我, 你没学过这个?(10进制的科学计数法)
IEEE 754
JavaScript就是采用了该浮点数运算标准(双精确度(64位))。这个标准的表现形式其实就是把一个 64bits 分成三段。
- 第一段占 1bit,表示符号位。代称为 S(sign)。
- 第二段占 11bits,表示指数。代称为 E(Exponent)。
- 第三段占 52bits,表示尾数。代称为 F(Fraction)。
如下图所示:
然后呢,一个小数的计算方式是下面这个算式:
看到这里, 我是一脸懵逼, 不知所云。但是,经过了2天的思考与研究,终于弄明白了;其实和传统的钟表记录时间的原理类似。
通过时钟来学习 IEEE 754
上过一年级的我们,应该都能认清楚这个钟表。我们首先看
- 短针: 它在10和11之间
- 长针: 它走了10个小格子, 1个表盘一共有60个小格子, 10 / 60 = 0.1666 占了60分钟的16.6% 就是10 分钟
- 那么现在的时间就是10点过10分.
3.14如何表示
< 3.14 < , 我们可以发现3.14在2和4之间; 这就类似 钟表中的短针, n = 1, 再由上述公式反推出 E, n = E - 1023, 所以 E = 1024, 其二进制的表示方式就是10000000000。
然后,3.14 - 2 = 1.14, 1.14 在 4 - 2 这里占了(1.14/2 = 57%), 这就类似钟表中的 长针, 只不过我们的表盘也就是尾数部分是52位, 即, 所以长针得出来的值就是 0.57 * = 2567051787601182.5。经过四舍五入(不四舍五入,可能永远算不完,这也是浮点数丢失精度的来源)得,F=2567051787601182,其二进制为1001000111101011100001010001111010111000010100011110
在内存中就用
0100000000001001000111101011100001010001111010111000010100011110来记录3.14这个数字, 然后使用的时候就用到了之前的计算公式带入得:3.1399999999999997
再来看小于1的数0.015
短针 : < 0.015 < , 同理可得, n = -7, n = E - 1023, E = 1016
长针 : (0.015 − ) / ( − ) = 0.0071875/0.0078125=0.92。0.92∗=4143311657180856.5,四舍五入,得到 M = 4143311657180856。
Playground
小数计算过程简化
推导过程(略) => 左耳听风 13 | 魔数 0x5f3759df
(3.14).toString(2) // 11.001000111101011100001010001111010111000010100011111
(1).1001000111101011100001010001111010111000010100011111 * 2^1 n=1 1023+1=1024
F = 1001000111101011100001010001111010111000010100011111
E = 10000000000(1024)
(0.015).toString(2) // 0.00000011110101110000101000111101011100001010001111010111
(1).1110101110000101000111101011100001010001111010111000 * 2^-7 n=-7 1023+(-7)=1016
F = 1110101110000101000111101011100001010001111010111(000) //不足52位需要补3个0
E = 01111111000(1016)
0.1 + 0.2 ≠ 0.3
根据上面的结论, 我们可以知道:
0.1 = 0 01111111011 1001100110011001100110011001100110011001100110011010
0.2 = 0 01111111100 1001100110011001100110011001100110011001100110011010
对阶运算
啥是对阶? 中学学过, 对阶的目的是为了使两个阶数不同的浮点数变换到为可以直接相加
0.1 = 0.0001100110011001100110011001100110011001100110011001101
0.2 = 0.001100110011001100110011001100110011001100110011001101
0.1 = 1.1001100110011001100110011001100110011001100110011010 * 2^-4
0.2 = 1.1001100110011001100110011001100110011001100110011010 * 2^-3
# 对阶
0.1 = 0.1100110011001100110011001100110011001100110011001101(0) * 2^-3
0.2 = 1.1001100110011001100110011001100110011001100110011010 * 2^-3
# 相加
10.0110011001100110011001100110011001100110011001100111 * 2^-3
# 右移一位(剔除溢出的位数)
1.0011001100110011001100110011001100110011001100110100 * 2^-2
# 带入公式得:
(1 + 0b0011001100110011001100110011001100110011001100110100/2**52)*2**-2 = 0.30000000000000004
∴ 0.1 + 0.2 = 0.30000000000000004
思考
- 为啥要设计成这个样子?
- 因为要用二进制表示10进制,如果类似hardcode的方式的话, 要占用更大的空间.
- 这个让我想起来,微信小程序二维码参数设计方面的应用? (32个字符长度)
- 例如缩短商品id的长度,可以有效的节省url的长度.
小结
- IEEE754就是二进制的科学计数法; 得到其
浮点数最快速的方法就是,先得其二进制数,然后用科学计算法(1.)xxx *2^n, n+1023=E; 小数部分就是其小数部分(需要补齐52位)
- 追寻问题过程比答案更加重要; 提问的过程也是解决问题的过程。
- 刚开始就是问 指数部分是什么, 尾数部分又是什么?通过钟表我理解了。
- 得到答案后, 又不明白 toString(2) 展示出来的数据为啥跟我的答案搭不上边。
- 后又发现原来是科学计数法,即 520 可以表示为 5.2*10^2; IEEE754只不过是一种二进制的科学计数法。
- 知道 0.1 和 0.2 是怎么表示后, 有遇到了新问题, 二进制如何运算, 也就是 对阶运算是什么?
- 然后又有疑问? 数字64bit 应该占8字节, 为啥说英文和数字 只占1个字节?