2021-02-10更新: 这篇文章我并非完全理解,有一些疏漏之处。我重写整理了思路,写了一篇,对浮点数机制进行了更完备的说明,传送门:0.1 加 0.2 不等于 0.3 ?从计算机角度深挖 JavaScript 浮点数存储机制
写在前面
之前面试遇到过一个问题:为什么 JavaScript 里面 0.1+0.2 !== 0.3
。当时我就回答到了浮点数精度有误差,显然面试官不满意,这不谁都知道吗?
小红书上也强调过不要进行 0.1+0.2 === 0.3
的判断,但是仅仅提到如下理由:
关于浮点数值计算会产生舍入误差的问题,有一点需要明确:这是使用基于 IEEE754 数值的浮点计算的通病,ECMAScript 并非独此一家;其他使用相同数值格式的语言也存在这个问题。
上面仅仅提到 ECMAScript 使用了基于 IEEE754 标准的浮点数存储机制导致了误差,但是深入 IEEE754 标准,甚至深入计算机层面,又是为什么?这篇文章会从计算机组成原理出发,来探讨这个问题。
什么是浮点数?
计算机中数的表示
计算机中数的表示有定点数和浮点数。
- 定点数:小数点位置确定。假设16位存储,小数点在符号位后面成为小数定点机(这类机器只能表示小数),小数点在末尾成为整数定点机(这类机器只能表示整数)。
- 浮点数表示类似于十进制的科学计数法表示。由阶数(阶码,阶符),尾数(尾码,尾符)构成。尾数的位数决定了浮点数的精度,阶数的位数决定了浮点数的范围。
浮点数的规格化
-
对于小数来说,转换为二进制是乘 2 取整操作,例如将 0.1 转换成二进制
运算过程 | 取整 | 小数部分 |
---|
0.1 * 2 = 0.2 | 0 | 0.2 |
0.2 * 2 = 0.4 | 0 | 0.4 |
0.4 * 2 = 0.8 | 0 | 0.8 |
0.8 * 4 = 1.6 | 1 | 0.6 |
0.6 * 2 = 1.2 | 1 | 0.2 |
... | ... | ... |
-
基于 IEEE74 标准(见下面部分)的双精度浮点数(尾数最多 52 位),我们可以得到如下结果:
0.1=0.00011001100110011001100110011001100110011001100110011001(52)
- 规格化处理
IEEE754 标准
- 数字存储格式:S(数符)+ 阶码(含阶符)+ 尾数
- 尾数为规格化表示
- 非“0”的有效位最高位为“1”(隐含)
| 符号位 S | 阶码 | 尾数 | 总位数 |
---|
短实数(单精度) | 1 | 8 | 23 | 32 |
长实数(双精度) | 1 | 11 | 52 | 64 |
临时实数 | 1 | 15 | 64 | 80 |
0.1 和 0.2 在计算机中是如何存储的
上面我们求出了 0.1 的二进制表示,同理可以求出 0.2 的(52 表示尾数为 52 位)
0.1=0.00010.2=0.0011001100110011001100110011001100110011001100110011001(52)1001100110011001100110011001100110011001100110011001(52)
计算机中浮点数阶码(P)一般使用移码表示,尾数(S)使用补码表示,小数点前一个 1 做隐含处理。求得 0.1 和 0.2 的阶码和尾数如下
S(0.1)=1.10011001100...1(1100×12)S(0.2)=1.100110011...001(0011×12)P(0.1)=1,0000000100(−4)P(0.2)=1,0000000011(−3)
将阶数使用移码表示,存入计算机(中括号中为隐含的 1)
0.1⇒0:01111111100:10011001100110011001100110011001100110011001100110100.2⇒0:01111111101:10011001100110011001100110011001100110011001100110100.1=2−4×[1].10011001100110011001100110011001100110011001100110100.2=2−3×[1].1001100110011001100110011001100110011001100110011010
0.1+0.2 在计算机中是如何运算的
首先需要対阶,小阶向大阶看齐(小阶的尾数减小,只需右移,损失精度而不会造成错误),这里阶差为 1
注意这里作为小阶的 0.1 右移添补的是隐含的“1”,而不是默认右移添 0
0.1=2−3×0.2=2−3×sum=2−3×10.1100110011001100110011001100110011001100110011001101(0)1.10011001100110011001100110011001100110011001100110100.0110011001100110011001100110011001100110011001100111
IEEE754 标准对浮点数进行舍入时,一共定义了四种模型
Round to Nearest - roundTiesToEven (Default): 向最近的数靠近,最近的数需满足最低有效位为 0 或者偶数
Round toward 0: 向 0 靠近
Round toward +∞: 向正无穷靠近
Round toward −∞: 向负无穷靠近
第一种模型解决了 50%的舍入情况,还有一种模型叫做Round to Nearest - tiesAwayFromZero,这种模型就和它的名字一样,远离 0 进行舍入
下面是 5 个舍入模型的例子,前四个模型用于 IEEE754 标准下的浮点数舍入,第一个为默认模型:
Mode / Example Value | +11.5 | +12.5 | −11.5 | −12.5 |
---|
to nearest, ties to even (默认模型) | +12.0 | +12.0 | −12.0 | −12.0 |
toward 0 | +11.0 | +12.0 | −11.0 | −12.0 |
toward +∞ | +12.0 | +13.0 | −11.0 | −12.0 |
toward −∞ | +11.0 | +12.0 | −12.0 | −13.0 |
to nearest, ties away from zero | +12.0 | +13.0 | −12.0 | −13.0 |
这时候我们再来看 sum 规格化后,是如何使用上述标准进行舍入的。
误差的产生
首先,sum 做规格化,并隐含 1 后如下(sum 此时位于 a,b 之间)
a=2−2×sum=2−2×b=2−2×1.0011001100110011001100110011001100110011001100110011(0)1.0011001100110011001100110011001100110011001100110011(1)1.0011001100110011001100110011001100110011001100110100(0)
按照上述第一个舍入模型,a 的最低有效位为 1,b 的最低有效位为 0,sum 将使用 b,然后存入计算机中。最后,我们将存入计算机中的 0.3 和 sum 进行一个比较:
0.1+0.2=2−2×0.3=2−2×0.1+0.2⇒0:011111111010.3⇒0:011111111011.00110011001100110011001100110011001100110011001101001.0011001100110011001100110011001100110011001100110011:0011001100110011001100110011001100110011001100110[100]:0011001100110011001100110011001100110011001100110[011]
最终结果相差了2−2×2−52=2−54!!!
如果再将上面两个数转换成我们熟悉的十次方:
0.1+0.2=0.3=0.300000000000000044408920985006...0.299999999999999988897769753748...
控制台输出:
var ll = 0.300000000000000044408920985006;
var lll = 0.299999999999999988897769753748;
console.log(ll, lll);
总结
参考
-
[Is floating point math broken? -- stack overflow(answer by Wai Ha Lee)](stackoverflow.com/a/28679423)
-
[Rounding floating-point numbers -- Wikipedia](en.wikipedia.org/wiki/IEEE_7…
-
[IEEE 754: Rounding Rules -- Wikipedia](en.wikipedia.org/wiki/IEEE_7…