做前端开发的朋友,应该大部分都知道这么一个奇怪的现象:0.1 + 0.2 不等于 0.3。

需要说明的是,这不只是 js  一门语言的问题,只要是使用了 IEEE 754标准来表示数值的语言,都有这个问题。那么 IEEE 754 是怎么一回事呢,我们一起来看看。

1、浮点数

在生活中,我们用到的有整数和小数,计算机中也是如此。整数没有小数,所以在计算机中用定点数表示,而有小数的数,则用浮点数表示。

浮点数:简单的说就是小数点位置可以浮动的数。它采用了科学计数法来表达实数。即用一个有效数字,一个基数(Base)、一个指数(Exponent)以及一个表示正负的符号来表达实数。比如,666.66 用十进制科学计数法可以表达为 6.6666×10^2(其中,6.6666 为有效数字,10 为基数,2 为指数),也可以表示为 66.666×10^1。浮点数利用指数达到了浮动小数点的效果,从而可以灵活地表达更大范围的实数。

因为这种表达的多样性,因此有必要对其加以规范化以达到统一表达的目标。规范的浮点数表达方式具有如下形式:
 
 
其中,d.dd…d 为有效数字,β 为基数,e 为指数。有效数字中数字的个数称为精度,我们可以用 p 来表示,即可称为 p 位有效数字精度。每个数字 d 介于 0 和基数 β 之间,包括 0。更精确地表示如下:
 
 
以666.66为例,规范后的浮点数表达式为:6.6666×10^2 ,β 为10,e 为2,每个d  都是6, 大于等于0 小于10。
 
 
2、IEEE 754
 
上面我们已经讲了浮点数的表示方法,但是是针对十进制的浮点数来说的,即基数 β 等于 10 。
但我们都知道,计算机只识别0和1,所以我们在计算机中得用二进制。对二进制来说,上面的表达式同样可以简单地表达。唯一不同之处在于:二进制的 β 等于 2,而每个数字 d 只能在 0 和 1 之间取值。如二进制数 1001.101,我们可以根据上面的表达式表达为:1×2^3 + 0×2^2 + 0×2^1 +1 ×2^0 + 1×2^-1 + 0×2^-2 + 1×2^-3,其规范浮点数表达为 1.001101×2^3。为了规范计算机里的浮点数表示标准,便有了IEEE 754标准,即 IEEE Standard for Binary Floating-Point Arithmetic,ANSI/IEEE Std 754-1985,该标准限定指数的底为 2,并于同年被美国引用为 ANSI 标准。目前,几乎所有的计算机都支持 IEEE 754 标准,它大大地改善了科学应用程序的可移植性。IEEE 浮点数标准是从逻辑上用三元组{S,E,M}来表示一个数 V 的,即 V=(-1)S×M×2E,如下图所示。
 
 
SEM解释如下:
S:符号位(Sign),决定数是正数(s=0)还是负数(s=1),而对于数值 0 的符号位解释则作为特殊情况处理。
E:指数位(Exponent)是 2 的幂(可能是负数),它的作用是对浮点数加权。
M:有效数字位(Significand),是二进制小数,它的取值范围为 1~2-ε,或者为 0~1-ε。它也被称为尾数位(Mantissa)、系数位(Coefficient),甚至还被称作“小数”。
 

同时,IEEE 754标准准确地定义了单精度和双精度浮点格式,并为这两种基本格式分别定义了扩展格式,如下所示:

  • 单精度浮点格式(32 位)。
  • 双精度浮点格式(64 位)。
  • 扩展单精度浮点格式(≥43 位,不常用)。
  • 扩展双精度浮点格式(≥79 位,一般情况下,Intel x86 结构的计算机采用的是 80 位,而 SPARC 结构的计算机采用的是 128 位)。

其中,只有 32 位单精度浮点数是本标准强烈要求支持的,其他都是可选部分。下面就来对单精度浮点与双精度浮点的存储格式做一些简要的阐述。

 
IEEE 754 单精度版本(32位)与双精度版本(64位) 比较。
 
 
指数有正有负,为了既能表示正,也能表示负,所以实际的指数值按要求需要加上一个偏置(Bias)值作为保存在指数段中的值。为了正负的范围差不多,故用一半的数来表示正,一半表示负,但是因为还有一个0,所以正数就比负数少了一个。
单精度中,指数位为8位,一半就是 2^7,2^7 – 1 = 127,所以指数范围在 -126 ~ +127。
双精度中,指数位为11位,一半就是 2^10,2^10 – 1 = 1023,所以指数范围在 -1022 ~ +1023。
 
同时,因为是二进制表示,第一位只能是 0 或 1,而如果是 0 时,我们可以把小数点往后移,直到第一位为 0。所以 IEEE754 规定,有效数字第一位默认总是1(因此,在表示精度的位数前面,还存在一个 “隐藏位” ,固定为 1 ,但它不保存在 64 位浮点数之中。也就是说,有效数字总是 1.xx…xx 的形式,其中 xx..xx 的部分保存在 64 位浮点数之中,最长为52位 。所以,JavaScript 提供的有效数字最长为 53 个二进制位,其内部实际的表现形式为:(-1)^符号位 * 1.xx…xx * 2^指数位。这意味着,JavaScript 能表示并进行精确算术运算的整数范围为:[-2^53-1,2^53-1],即从最小值 -9007199254740991 到最大值 9007199254740991 之间的范围 。
 
 
3、舍入误差
 
就像我们平时用得最多的四舍五入法一样,当我们在做保留精度计算时,超出精度后面的部分,不能直接截掉就算了,还得看一下截掉的第一位数字大小,决定要不要进1的问题。比如:4.12345保留三位小数是4.123,但保留四位小数则是4.1235。
 
在浮点数的舍入问题上,IEEE 浮点格式定义了 4 种不同的舍入方式,如下表所示。其中,默认的舍入方法是向偶数舍入,而其他三种可用于计算上界和下界。
 
名称 | 描述
向偶数舍入 | 也称为向最接近的值舍入,会将结果舍入为最接近且可表示的值
向0舍入 | 将结果朝零的方向舍入
向上舍入 | 向+无穷方向舍入,会将结果朝正无穷大的方向舍入
向下舍入 | 向-无穷方向舍入,会将结果朝负无穷小的方向舍入

下表是 4 种舍入方式的应用举例。这里需要特别说明的是,向偶数舍入(向最接近的值舍入)方式会试图找到一个最接近的匹配值。因此,它将 1.4 舍入成 1,将 1.6 舍入成 2,而将 1.5 和 2.5 都舍入成 2。

或许看了上面的内容你会问:为什么默认是采用向偶数舍入,而不直接使用我们已经习惯的“四舍五入”呢?

其原因我们可以这样来理解:在进行舍入的时候,最后一位数字从 1 到 9,舍去的有 1、2、3、4;它正好可以和进位的 9、8、7、6 相对应,而 5 却被单独留下。如果我们采用四舍五入每次都将 5 进位的话,在进行一些大量数据的统计时,就会累积比较大的偏差。而如果采用向偶数舍入的策略,在大多数情况下,5 舍去还是进位概率是差不多的,统计时产生的偏差也就相应要小一些。

同样,针对浮点数据,向偶数舍入方式只需要简单地考虑最低有效数字是奇数还是偶数即可。例如,假设我们想将十进制数舍入到最接近的百分位。不管用哪种舍入方式,我们都将把 1.2349999 舍入到 1.23,而将 1.2350001 舍入到 1.24,因为它们不是在 1.23 和 1.24 的正中间。另一方面我们将把两个数 1.2350000 和 1.2450000 都舍入到 1.24,因为 4 是偶数。

 

对二进制而言,这里的向偶数舍入可能不是很好理解,故举例特意说明一下。

· 1.001 011经舍入处理后的结果为1.001。为什么呢?我们可以计算一下舍入后的结果与1.001和1.010的距离。
|1.001011-1.001|=0.000011,而|1.001011-1.010|=0.00101。很明显前者更接近真实值,因此我们选择舍入后的结果为1.001,也即是向下舍入(此时舍入后的结果比真实值小)。

· 1.001101经舍入处理后的结果为1.010。同样的道理,我们来看一下这个1.001和1.010与这个值的距离。

|1.001101-1.001|=0.000101,而|1.001101-1.010|=0.000011。很明显后者更接近真实值,因此这里我们选择舍入后的结果为1.010,也即是向上舍入(此时舍入后的结果比真实值大)。

现在,我们差不多可以发现,当有效位的后一位是0时,此时即将被舍去的值小于最后一位有效位数值的一半,那么应该向下舍入;当有效位的后一位是1时,而且后面的数位不全为0,此时即将被舍去的值大于最后一位有效数值的一半,那么应该向上舍入

讨论完这两种情况,我们再来看一种特殊情况:有效位后一位是1,后面数位全是0,此时即将被舍去的值刚好是有效位数值的一半,那么应该怎么进行舍入呢?如果始终选择向上或者向下舍入都会使结果比真实值大或者小。因此,这里我们需要选择向偶数舍入,也即是将数字向上或者向下舍入,使得结果的最低有效位是偶数。这样,在50%的时间里,它将向上舍入,而在50%的时间里,它将向下舍入。

·1.001100:距它最近的两个偶数分别是1.000和1.010。|1.001100-1.000|=0.001100,而|1.001100-1.010|=0.000100,很明显后者距离更近。因此我们选择舍入后的结果为1.010。

·1.100100:据它最近的两个偶数分别是1.100和1.110。|1.100100-1.100|=0.000100,而|1.100100-1.110|=0.001100,显然前者距离更近。因此我们选择舍入后的结果为1.100。

从这个例子我们可以归纳出,如果即将被舍的值刚好等于一半,如果最低有效位为奇,则向上舍入,如果为偶,则向下舍入,从而实现使最低有效位始终为偶数。

 
4、十进制和二进制的转换
 
十进制整数转换为二进制整数采用”除2取余,逆序排列”法。具体做法是:用2整除十进制整数,可以得到一个商和余数;再用2去除商,又会得到一个商和余数,如此进行,直到商为小于1时为止,然后把先得到的余数作为二进制数的低位有效位,后得到的余数作为二进制数的高位有效位,依次排列起来。

所以十进制的 255 转为二进制就是 11111111。

十进制小数转换成二进制小数采用”乘2取整,顺序排列”法。具体做法是:用 2 乘十进制小数,可以得到积,将积的整数部分取出,再用 2 乘余下的小数部分,又得到一个积,再将积的整数部分取出,如此进行,直到积中的小数部分为零,此时 0 或 1 为二进制的最后一位,或者达到所要求的精度为止。

当小数的最后一位不是5,不管我们乘多少个2,都无法达到最后小数刚好为0的情况,这时候只需达到精度即可。
二进制转十进制就不用说了,应该都会。正数部分是从右到左用二进制的每个数去乘以2的相应次方,小数点后的则是从左往右。
例如:二进制数1101.01转化成十进制
1101.01(2)=1*20+0*21+1*22+1*2+0*2-1+1*2-2=1+0+4+8+0+0.25=13.25(10)。
 
好了,有了上面的这些知识点做铺垫,就能很好的跟大家解释 0.1 + 0.2 为什么不等于 0.3 了。首先,计算机跟我们人不一样,它所有的加减乘除法都只能通过二进制数来进行,所以我们在计算 0.1 + 0.2 的时候,其实计算机是先分别把 0.1 和 0.2 转换成二进制数,再把他们的二进制值进行加运算(二进制的运算有一套自己的规则),运算得到的结果再转成十进制输出给我们。而 0.1 和 0.2 在转成二进制的过程中,因为有效位出现了无限循环,所以必然要进行舍入处理,导致精度丢失,最终得到的二进制数再转换为十进制,恰好等于了 0.30000000000000004。
所以,0.1 + 0.2 不等于 0.3 的问题,只是精度丢失中的一个,像这样的问题还有很多很多。比如:

上面这些式子之所以成立,就是因为它们在转换成二进制后出现了无限循环,进行舍入后,得到了相同的浮点数表示,所以被判断为相等。

 
IEEE 754  精度丢失的一些典型问题:

像这样的问题数不胜数,如果你的程序里面小数操作比较频繁,那么你可一定得要小心了。

 
如何解决判断?
 
那么应该怎样来解决0.1+0.2等于0.3呢? 最好的方法是设置一个误差范围值,通常称为”机器精度“,而对于Javascript来说,这个值通常是2^-52,而在ES6中,已经为我们提供了这样一个属性:Number.EPSILON,而这个值正等于2^-52。这个值非常非常小,在底层计算机已经帮我们运算好,并且无限接近0,但不等于0,这个时候我们只要判断(0.1+0.2)-0.3小于Number.EPSILON,在这个误差的范围内就可以判定0.1+0.2===0.3为true。
 
如何解决计算?
 
通常的解决办法就是把计算数字提升 10 的 N 次方, 最后再除以 10的 N 次方。N 的取值根据自己的需求来定。