Abo

0.1 + 0.2 != 0.3 ? 该如何正确回答?(8.22更新)

关于JavaScript中0.1 + 0.2 != 0.3的结果深入人心,但是原理 🐴


wallhaven-g89vrl

像这种变态的问题,在 JS 中存在很多


在解决0.1+0.2的问题之前,我们先来聊一聊js中的基本类型

通俗易懂解释(8.22)

JavaScript 引擎会先将十进制数转换为二进制,然后进行加法运算,再将所得结果转换为十进制。在进制转换过程中如果小数位是无限的,就会出现误差。

基本类型的分类

JS 的基本类型分为两大类型,分别为原始类型对象类型

原始类型包括哪些?对象类型包括哪些?为什么要这样分类?他们的区别又是什么?

原始类型存储的都是值,而对象类型存储的都是地址。如下图:

1
2
3
4
let i = 2;
let a = 'hello';
let j = i;
let b = a;

11

1
2
let obj1 = new Object();
let obj2 = obj1;

22

如上图所示,我们清楚了原始类型和对象类型最根本的区别,那么问题又来了,当我们进行变量之间赋值的时候,原始类型直接赋值的是值,而对象类型赋值的是地址。

所以,当我们将对象作为参数进行传递的时候,在函数内改变的是该地址指向的对象,而传递的参数是原始类型,则改变的是一个副本(复制的另一个值)。也就是说,如果是对象,则改变了原始的值,而不能改变原始类型原始的值。

原始类型共六种,分别为StringNumberBooleannullundefinedsymbol

null

对于null有一个历史存留的 bug,null是对象类型吗?虽然我们使用 typeof 检测null对象类型,这其实是一个 bug

typeof null == Object 😑

bug的原因

因为 JS 最初的版本是 32 位系统的,为了将性能将用低位存储变量类型信息,000 开头表示代表的是对象,此时null表示全零,所以系统就错误的将null判断为对象类型。虽然 JS 内部判断代码已经更改,但是这个 bug 一直留存下来。

null 出现的情况汇总

  1. 手动设置变量的值或者对象某一个属性值为null(此时不赋值,后边会赋值)。

  2. 在 JS 的 DOM 元素获取中,如果没有获取到指定的元素对象,结果一般都是null

  3. Object.prototype._proto_的值也是null

  4. 正则捕获的时候,如果没有获取到结果,默认的也是null

symbol

有关symbol使用的比较少,但是它的存在是有原因的。

symbol是什么

symbol表示独一无二的值,因为由于对象的属性都是字符串类型,我们避免不了相同字符串冲突的问题。所以为了防止对象的属性都是字符串类型而冲突引入的。

symbol的使用

1
2
3
4
5
6
7
8
9
10
11
 // 通常参数是字符串类型,如果为对象类型,就会调用 toString 方法
let s1 = Symbol(参数);// 这个参数可以认为是 Symbol 实例的一个描述,用于区分

// 第一种写法
let a = {};
a[s1] = 'Hello!';

// 第二种写法
let a = {
[s1] = 'Hello';
}

undefined

undefined出现的情况

  1. 变量提升: 只声明未定义默认值就是undefined

  2. 严格模式下:没有明确的执行主体,this就是undefined

  3. 对象没有这个属性名,属性值是undefined

  4. 函数定义形参不传值,默认就是undefined

  5. 函数没有返回值(没有return或者return;),默认返回的就是 undefined

为什么有的编程规范要求用 void 0 代替 undefined

Undefined 类型表示未定义,它的类型只有一个值,就是 undefined。

任何变量在被赋值前它的值都是 undefined,但是在 JavaScript 引擎中,undefined 是一个变量而非关键字。在旧的浏览器中,全局变量 undefined 可以被重写

1
2
3
4
5
6
7
8
/**
* IE8 浏览器
*/
var value;
console.log(value === undefined); //true
// 重写 undefined
undefined = 'hi';
console.log(value === undefined); //false

从上面的例子可知,在旧的浏览器下如果 undefined 被重写为非 undefined 值,那么它就无法用来正确检测一个变量是否被赋值。

这个问题在 ECMAScript 5 中被修复了,在实现了 ECMAScript 5 规范的浏览器中运行以下代码。

1
2
3
4
/**
* Chrome 浏览器
*/
Object.getOwnPropertyDescriptor(window, undefined); //运行结果:{"writable":false,"enumerable":false,"configurable":false}

从上面的运行结果可知,全局变量 undefined 的 writable 属性是 false,也就是不可写

void

void 运算符对给定的表达式进行求值,会忽略计算结果并始终返回 undefined。

1
2
3
4
5
var value1 = 'hi';
console.log(value1); //"hi"

var value2 = void 'hi';
console.log(value2); //undefined

因此,我们可以利用这一点准确地获取预期的 undefined 值。

1
2
3
var value;
console.log(value === void 0); //true
console.log(value === void(0)); //true

为什么 0.1 + 0.2 != 0.3 ?

1
2
0.1 + 0.2 > 0.3 // true
0.1 * 0.1 = 0.010000000000000002

是什么导致了这种情况?

原因很简单,JS 采用的是双精度版本,这个版本就存在精度问题,就导致了上边这种情况。

内部的原理是什么?

我们计算机的信息全部转化为二进制进行存储的,那么0.1的二进制表示的是一个无限循环小数,该版本的 JS 采用的是浮点数标准需要对这种无限循环的二进制进行截取,从而导致了精度丢失,造成了0.1不再是0.1,截取之后0.1变成了 0.100...0010.2变成了0.200...002。所以两者相加的数大于0.3

那好,既然0.1不等于0.1了,那为什么我在控制台上输出console.log(0.1)还等于0.1呢?

因为在输入内容进行转换的时候,二进制转换成十进制,然后十进制转换成字符串,在这个转换的过程中发生了取近似值,所以打印出来的是一个近似值。

深入原理

计算机内部如何表示数

1. 计算机内部如何表示整数

这里以十进制数13来展示“按位计数法”如何表示整数:

十进制值 进制 按位格式 描述
13 10 13 1x10^1 + 3x10^0 = 10 + 3
13 2 1101 1x2^3 + 1x2^2 + 0x2^1 + 1x2^0 = 8 + 4 + 0 + 1
2. 计算机内部如何表示小数

再看小数怎么用按位计数法表示,以十进制数0.625为例:

十进制值 进制 按位格式 描述
0.625 10 0.625 6x10^-1 + 2x10^-2 + 5x10^-3 = 0.6 + 0.02 + 0.005
0.625 2 0.101 1x2^-1 + 0 x2^-2 + 1x2^-3 = 1/2 + 0 + 1/8
3. 如何用二进制表示0.1

关于十进制与二进制间如何转换,这里不细说,直接给出结论:

十进制整数转二进制方法:除2取余;十进制小数转二进制方法:乘2除整

十进制0.1转换成二进制,乘2取整过程:

1
2
3
4
5
6
7
8
0.1 * 2 = 0.2 # 0
0.2 * 2 = 0.4 # 0
0.4 * 2 = 0.8 # 0
0.8 * 2 = 1.6 # 1
0.6 * 2 = 1.2 # 1
0.2 * 2 = 0.4 # 0

.....

从上面可以看出,0.1的二进制格式是:0.0001100011….。这是一个二进制无限循环小数,但计算机内存有限,我们不能用储存所有的小数位数。那么在精度与内存间如何取舍呢?

答案是:在某个精度点直接舍弃。当然,代价就是,0.1在计算机内部根本就不是精确的0.1,而是一个有舍入误差的0.1。当代码被编译或解释后,0.1已经被四舍五入成一个与之很接近的计算机内部数字,以至于计算还没开始,一个很小的舍入错误就已经产生了。这也就是 0.1 + 0.2 不等于0.3 的原因。

有误差的两个数,其计算的结果,当然就很可能与我们期望的不一样了。注意前面的这句话中的“很可能”这三个字?为啥是很可能昵?

20180814185755910

0.1 + 0.1 为什么等于0.2

答案是:两个有舍入误差的值在求和时,相互抵消了,但这种“负负得正,相互抵消”不一定是可靠的,当这两个数字是用不同长度数位来表示的浮点数时,舍入误差可能不会相互抵消。

又如,对于 0.1 + 0.3 ,结果其实并不是0.4,但0.4是最接近真实结果的数,比其它任何浮点数都更接近。许多语言也就直接显示结果为0.4了,而不展示一个浮点数的真实结果了。

另外要注意,二进制能精确地表示位数有限且分母是2的倍数的小数,比如0.5,0.5在计算机内部就没有舍入误差。所以0.5 + 0.5 === 1

前方高能

IEEE754浮点数

上图是IEEE对浮点数表示的说明,这里分单精度与双精度之分,如下图:
单精度与双精度

对于单精度浮点数,采用32位存储,最高的1位是符号位s,接着的8位是指数E,剩下的23位为有效数字M。
对于双精度浮点数,采用64位存储,最高的1位是符号位S,接着的11位是指数E,剩下的52位为有效数字M。
在单精度浮点格式中,s、exp和frac字段分别为 1 位、k = 8 位和 n = 23 位,得到一个 32 位的表示。 在双精度浮点格式(C 语言中的 double)中,s、exp 和 frac 字段分别为 1 位、k = 11 位和 n = 52 位,得到一个 64 位的表示。

根据 exp 的值,被编码的值可以分成三种不同的情况(最后一种情况有两 个变种)。下图说明了对单精度格式的情况。
单精度浮点数

情况1:格式化的值

格式化的值

情况2:非格式化的值

非格式化的值

情况3:特殊值

特殊值

好了,下面我们重点关注一下情况1,并举例来看,不然实在头大啊。以单精度举例。

浮点数转换为二进制

浮点数转换成二进制,我们要将整数部分和小数部分分开,整数部分采用除2取余,小数部分采用乘2取整。
例如,13.125 转换为二进制:
1.整数部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14

13
÷ 2
-------------- ↑
6 1 |
÷ 2 |
-------------- |
3 0 |
÷ 2 |
-------------- |
1 1 |
÷ 2 |
-------------- |
0 1 ----

逆序将余数拼上得到13的二进制:1101

2.小数部分

1
2
3
4
5
6
7
8
9
10
        0.125
x 2 ----
----------------- |
0.25 0 |
x 2 |
----------------- |
0.5 0 |
x 2 |
----------------- |
1.0 1

得到小数部分的二进制:0.001

两部分相加,得到13.125的二进制:1101.001

好了,到现在,我们知道了如何将浮点数转换为二进制表示,也知道了IEEE中浮点数的存储方式,那么,我们接下来用13.125这个例子来看看计算机中具体是如何表示的呢。

二进制 1101.001可以写成 1.101001 * 2^3,即这里 M 为 1.101001,E为3,s为0。
单精度下,符号位s即为0,阶码字段exp的值e=E+127,即e=3+127=130,130的二进制表示为10000010
小数字段,frac为尾数M的二进制,即1.101001
那么,在单精度下,计算机中的表示为:

1
2
3
0  10000010  101001 00000000000000000
-------------------------------------
s exp frac

0.1+0.2

好了,关于浮点数转换二进制,以及浮点数的表示我们都知道了,那么,现在我们来看看,为什么 0.1+0.2!=0.3的吧。首先,我们还是先看看js里到底输出多少吧:

1
2
> 0.1+0.2
0.30000000000000004

0.1的二进制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    0.1
x 2
-------
0.2 0
x 2
-------
0.4 0
x 2
-------
0.8 0
x 2
-------
0.6 1
x 2
-------
0.2 1
------- 又从0.2开始循环了

于是,我们得到了0.1的二进制表示,即为0.0001100110011(0011循环),即1.100110011(0011)*2^-4
即,M 1.100110011(0011),E -4,
那么,s=0,e=-4+1023=1019,
那么,js中由于是双精度的,那么0.1的表示为:

1
2
3
0  01111111011  1001100110011001100110011001100110011001100110011001
--------------------------------------------------------------------
s exp(11位) frac(52位)

0.2的二进制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    0.2
x 2
-------
0.4 0
x 2
-------
0.8 0
x 2
-------
0.6 1
x 2
-------
0.2 1
------- 又从0.2开始循环了

0.2的二进制表示:0.001100110011(0011循环),即1.100110011(0011)*2^-3
那么,js双精度0.2的表示:

1
0  01111111100  1001100110011001100110011001100110011001100110011001

浮点数运算

浮点数的加减运算一般由以下五个步骤完成:

  • 对阶
  • 尾数运算
  • 结果规格化
  • 舍入处理
  • 溢出判断
对阶

将两个进行运算的浮点数的阶码对齐的操作。对阶的目的是为使两个浮点数的尾数能够进行加减运算。因为,当进行Mx·2Ex与My·2Ey加减运算时,只有使两浮点数的指数值部分相同,才能将相同的指数值作为公因数提出来,然后进行尾数的加减运算。
对阶的具体方法是:首先求出两浮点数阶码的差,即⊿E=Ex-Ey,将小阶码加上⊿E,使之与大阶码相等,同时将小阶码对应的浮点数的尾数右移相应位数,以保证该浮点数的值不变。几点注意:

(1)对阶的原则是小阶对大阶,之所以这样做是因为若大阶对小阶,则尾数的数值部分的高位需移出,而小阶对大阶移出的是尾数的数值部分的低位,这样损失的精度更小。

(2)若⊿E=0,说明两浮点数的阶码已经相同,无需再做对阶操作了。

(3)采用补码表示的尾数右移时,符号位保持不变。

(4)由于尾数右移时是将最低位移出,会损失一定的精度,为减少误差,可先保留若干移出的位,供以后舍入处理用。

尾数运算

尾数运算就是进行完成对阶后的尾数相加减。这里采用的就是我们前面讲过的纯小数的定点数加减运算。

结果规格化

在机器中,为保证浮点数表示的唯一性,浮点数在机器中都是以规格化形式存储的。对于IEEE754标准的浮点数来说,就是尾数必须是1.M的形式。由于在进行上述两个定点小数的尾数相加减运算后,尾数有可能是非规格化形式,为此必须进行规格化操作。 规格化操作包括左规和右规两种情况。 左规操作:将尾数左移,同时阶码减值,直至尾数成为1.M的形式。例如,浮点数0.0011·25是非规格化的形式,需进行左规操作,将其尾数左移3位,同时阶码减3,就变成1.1100·22规格化形式了。 右规操作:将尾数右移1位,同时阶码增1,便成为规格化的形式了。要注意的是,右规操作只需将尾数右移一位即可,这种情况出现在尾数的最高位(小数点前一位)运算时出现了进位,使尾数成为10.xxxx或11.xxxx的形式。例如,10.0011·25右规一位后便成为
1.00011·26的规格化形式了。

舍入处理

浮点运算在对阶或右规时,尾数需要右移,被右移出去的位会被丢掉,从而造成运算结果精度的损失。为了减少这种精度损失,可以将一定位数的移出位先保留起来,称为保护位,在规格化后用于舍入处理。 IEEE754标准列出了四种可选的舍入处理方法:

(1)就近舍入(round to nearest) 这是标准列出的默认舍入方式,其含义相当于我们日常所说的“四舍五入”。例如,对于32位单精度浮点数来说,若超出可保存的23位的多余位大于等于100…01,则多余位的值超过了最低可表示位值的一半,这种情况下,舍入的方法是在尾数的最低有效位上加1;若多余位小于等于011…11,则直接舍去;若多余位为100…00,此时再判断尾数的最低有效位的值,若为0则直接舍去,若为1则再加1。

(2)朝+∞舍入(round toward +∞) 对正数来说,只要多余位不为全0,则向尾数最低有效位进1;对负数来说,则是简单地舍去。

(3)朝-∞舍入(round toward -∞) 与朝+∞舍入方法正好相反,对正数来说,只是简单地舍去;对负数来说,只要多余位不为全0,则向尾数最低有效位进1。

(4)朝0舍入(round toward 0) 即简单地截断舍去,而不管多余位是什么值。这种方法实现简单,但容易形成累积误差,且舍入处理后的值总是向下偏差。

溢出判断

与定点数运算不同的是,浮点数的溢出是以其运算结果的阶码的值是否产生溢出来判断的。若阶码的值超过了阶码所能表示的最大正数,则为上溢,进一步,若此时浮点数为正数,则为正上溢,记为+∞,若浮点数为负数,则为负上溢,记为-∞;若阶码的值超过了阶码所能表示的最小负数,则为下溢,进一步,若此时浮点数为正数,则为正下溢,若浮点数为负数,则为负下溢。正下溢和负下溢都作为0处理。

计算0.1+0.2

0.1的阶码-4,0.2的阶码-3,对阶阶段,将0.1的阶码变为-3,然后0.1的尾数部分:

1
1100110011001100110011001100110011001100110011001100

可能会有人问,这里最高位怎么是1,移位后不应该是0么,别忘了,尾数部分我们隐含了一个最高位是1的条件,因此,移位后,会将该位一并移过来。
将其与0.2的尾数部分进行相加:

1
2
3
4
    1100110011001100110011001100110011001100110011001100
+ 1001100110011001100110011001100110011001100110011001
----------------------------------------------------
100110011001100110011001100110011001100110011001100111

注意,这里计算时,进位2位,去除原来最高位默认的1,相当于阶码部分加1,即由原来的-3变为-2,那么,阶码部分的表示:

1
1111111101

而尾数部分,去除最高位1,最后一位1,进行舍入,得到52位新的二进制表示:

1
0011001100110011001100110011001100110011001100110100

即,最后计算的结果如下:

1
0  01111111101  0011001100110011001100110011001100110011001100110100

该数表示的即0.1+0.2的结果 2^-2 * 1.0011001100110011001100110011001100110011001100110100
将其转换成十进制数为:0.3000000000000000444089209850062616169452667236328125
由于精度问题,只取到0.30000000000000004

到这里,就把所有的推演过程所需要的知识补充完整了,在推演的过程中,真心觉得,人工推演二进制真累啊,十分感谢计算机前辈,设计出方案并实践于计算机,感谢。

Number.EPSILON

Number.EPSILON是在Number对象上面,新增一个极小的常量。根据规格,它表示 1 与大于 1 的最小浮点数之间的差。

对于 64 位浮点数(double)来说,大于 1 的最小浮点数相当于二进制的 1.00..001 ,小数点后面有连续 51 个零。这个值减去 1 之后,就等于 2 的-52 次方。
Number.EPSILON 实际上是 JavaScript 能够表示的最小精度。误差如果小于这个值,就可以认为已经没有意义了,即不存在误差了。

引入一个这么小的量的目的,在于为浮点数计算,设置一个误差范围。