Why 0.1+0.2 not equal to 0.3 in JavaScript?

August 17, 2018 by Tian Zhi

为什么JavaScript里面0.1加0.2不等于0.3,这是一个值得深入思考的问题

写在前面

之前面试遇到过一个问题:为什么JavaScript里面0.1+0.2 !== 0.3。当时我就回答到了浮点数精度有误差,显然面试官不满意,这不谁都知道吗

小红书上也强调过不要进行0.1+0.2 === 0.3的判断,仅仅提到如下理由

关于浮点数值计算会产生舍入误差的问题,有一点需要明确:这是使用基于IEEE754数值的浮点计算的通病,ECMAScript并非独此一家;其他使用相同数值格式的语言也存在这个问题

因此,也仅仅能答出这是因为ECMAScript使用了基于IEEE754标准的浮点数存储导致,但是深入计算机层面,又是为什么?

计算机角度

浮点数

计算机中数的表示有定点数和浮点数

  • 定点数小数点位置确定。小数点在符号位后面成为小数定点机(这类机器只能表示小数),小数点在末尾成为整数定点机(这类机器只能表示整数)
  • 浮点数表示类似于十进制的科学计数法表示。由阶数(阶码,阶符),尾数(尾码,尾符)构成。尾数的位数决定了浮点数的精度,阶数的位数决定了浮点数的范围

浮点数的规格化

  1. 对于小数来说,转换为二进制是乘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
... ... ...
  1. 基于IEEE74标准的双精度浮点数(尾数最多52位),我们可以得到如下结果:

    0.1=0.0001(1001100110011001100110011001100110011001100110011001)(52)0.1 = 0.0001 (1001100110011001100110011001100110011001100110011001) (52)
  2. 规格化处理

    • 规格化定义(r表示阶基值,这里为2,S表示尾数)
    12S <1\frac{1}{2} \leq \vert{S}\vert\ < 1
    • 对于负数形式的补码,规格化的定义不适用(下面负数补码表示-1,不满足规格化定义)
S>0 规格化形式 S<0 规格化形式
真值 0.1XX...X 真值 -0.1XX...X
原码 0.1XX...X 原码 1.1XX...X
补码 0.1XX...X 补码 1.0XX...X
反码 0.1XX...X 反码 1.0XX...X
  • 因此通常来说

    对于原码,不论整数,负数,第一数位为1即为规格化

    对于补码,符号位和第一数位不同为规格化

    在计算机中我们通常使用异或电路,当符号位和第一数位不同时,表示规格化完成

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.00011001100110011001100110011001100110011001100110011001(52)0.2=0.0011001100110011001100110011001100110011001100110011001(52)\begin{aligned} 0.1 = 0.0001\quad&1001100110011001100110011001100110011001100110011001 (52)\\ 0.2 = 0.001\quad&1001100110011001100110011001100110011001100110011001 (52) \end{aligned}

计算机中浮点数阶码(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)\begin{aligned} S(0.1) = 1.10011001100...1 (1100\times12)\\ S(0.2) = 1.100110011...001 (0011\times12)\\ P(0.1) = 1,0000000100(-4)\\ P(0.2) = 1,0000000011(-3) \end{aligned}

将阶数使用移码表示,存入计算机(括号中为隐含的1)

0.10:01111111100:10011001100110011001100110011001100110011001100110100.20:01111111101:10011001100110011001100110011001100110011001100110100.1=24×[1].10011001100110011001100110011001100110011001100110100.2=23×[1].1001100110011001100110011001100110011001100110011010\begin{aligned} &0.1 \Rightarrow 0:01111111100:1001100110011001100110011001100110011001100110011010\\ &0.2 \Rightarrow 0:01111111101:1001100110011001100110011001100110011001100110011010\\ \\ &0.1 = 2^{-4} \times [1].1001100110011001100110011001100110011001100110011010\\ &0.2 = 2^{-3} \times [1].1001100110011001100110011001100110011001100110011010 \end{aligned}

0.1+0.2在计算机中是如何运算的

首先需要対阶,小阶向大阶看齐(小阶的尾数减小,只需右移,损失精度而不会造成错误),这里阶差为1

注意这里作为小阶的0.1右移添补的是隐含的“1”,而不是默认右移添0

0.1=23×0.1100110011001100110011001100110011001100110011001101(0)0.2=23×1.1001100110011001100110011001100110011001100110011010sum=23×10.0110011001100110011001100110011001100110011001100111\begin{aligned} 0.1 = 2^{-3}\times&0.1100110011001100110011001100110011001100110011001101(0)\\ 0.2 = 2^{-3}\times&1.1001100110011001100110011001100110011001100110011010\\ sum = 2^{-3}\times1&0.0110011001100110011001100110011001100110011001100111\\ \end{aligned}

IEEE754标准浮点数舍入模型

IEEE754标准对浮点数进行舍入时,一共定义了四种模型

Round to Nearest - roundTiesToEven (Default): 向最近的数靠近,最近的数需满足最低有效位为0或者偶数

Round toward 0: 向0靠近

Round toward +∞: 向正无穷靠近

Round toward −∞: 向负无穷靠近

第一种模型解决了50%的舍入情况,还有一种模型叫做Round to Nearest - tiesAwayFromZero,这种模型就和它的名字一样,远离0进行舍入

下面是5个舍入模型的例子,前四个模型用于IEEE 754标准下的浮点数舍入,第一个为默认模型

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=22×1.0011001100110011001100110011001100110011001100110011(0)sum=22×1.0011001100110011001100110011001100110011001100110011(1)b=22×1.0011001100110011001100110011001100110011001100110100(0)\begin{aligned} a = 2^{-2}\times&1.0011001100110011001100110011001100110011001100110011(0)\\ sum = 2^{-2}\times&1.0011001100110011001100110011001100110011001100110011(1)\\ b=2^{-2}\times&1.0011001100110011001100110011001100110011001100110100(0)\\ \end{aligned}

按照上述第一个舍入模型,a的最低有效位为1,b的最低有效位为0,sum将使用b,然后存入计算机中。最后,我们将存入计算机中的0.3和sum进行一个比较

0.1+0.2=22×1.00110011001100110011001100110011001100110011001101000.3=22×1.00110011001100110011001100110011001100110011001100110.1+0.20:01111111101:0011001100110011001100110011001100110011001100110[100]0.30:01111111101:0011001100110011001100110011001100110011001100110[011]\begin{aligned} 0.1+0.2=2^{-2}\times&1.0011001100110011001100110011001100110011001100110100\\ 0.3 = 2^{-2}\times&1.0011001100110011001100110011001100110011001100110011\\ \\ 0.1 + 0.2 \Rightarrow 0:01111111101&:0011001100110011001100110011001100110011001100110[100]\\ 0.3 \Rightarrow 0:01111111101&:0011001100110011001100110011001100110011001100110[011] \end{aligned}

最终结果相差了22×252=2542^{-2}\times2^{-52} = 2^{-54}!!!

如果再将上面两个数转换成我们熟悉的十次方

0.1+0.2=0.300000000000000044408920985006...0.3=0.299999999999999988897769753748...\begin{aligned} 0.1 + 0.2 = &0.300000000000000044408920985006...\\ 0.3 = &0.299999999999999988897769753748... \end{aligned}

控制台输出一下

var ll  = 0.300000000000000044408920985006; // 中间有15个0
var lll = 0.299999999999999988897769753748;
console.log(ll,lll); //answer: 0.30000000000000004 0.3

总结

  • 一道涉及JS基础数据结构—浮点数的问题,深入探究起来,复习了一波计算机组成原理知识
  • 当时书上对IEEE754标准说得也很少,甚至不知道IEEE754的舍入标准。以至于之前的笔记我都认为sum的舍入使用的是学过的0舍1入法。直到写下这篇文章,揭开IEEE 754的面纱,才发现没那么简单
  • 作为自己的第一篇文章,今后还有很多值得学习和努力的地方

参考

  1. Is floating point math broken? -- stack overflow(answer by Wai Ha Lee)
  2. Rounding floating-point numbers -- Wikipedia
  3. IEEE 754: Rounding Rules -- Wikipedia

Feel free to leave me a message @tianzhich