JS浮点数及精度问题

前言

总所周知,在JS中进行某些浮点数的计算时会得到意想不到的结果,诸如0.1 + 0.21 - 0.9这种,得到的结果都不是我们预期的那样;这种现象实际上是由于浮点数存储时精度丢失时所导致的,但这并不是JS独有的锅,而是采用IEEE 754标准存储浮点数都会有的问题;

关于 IEEE 754 标准

IEEE 754规定了四种表示浮点数值的方式:单精确度(32位元)、双精确度(64位元)、延伸单精确度(43位元以上,很少使用)与延伸双精确度(79位元以上,通常以80位元实做)。[1]

JS中采用的是IEEE 754中双精度(64位)格式来存储浮点数,即每个数字类型的变量存储大小为64位;而这64位存储空间按照功能分成了3个部分[2]

img

  • 符号位(sign bit):包含1位,用来表示数值的符号;0表示为正,1表示为负;记为S
  • 指数位(exponent):包含11位,用来表示二进制科学计数法中的指数;记为E
  • 尾数位(mantissa):包含52位,用来表示二进制科学计数法的有效数字中的小数部分;记为M

因此,可以得出浮点数在双精度存储的二进制数值为:

V=(1)S2E1023(M+1)V = (-1)^{S}*2^{E - 1023}*(M + 1)

不过,看到这个公式可能心里会有疑问;二进制科学记数法是啥?为啥指数部分要减去1023?为啥有效数字中的整数部分不进行存储?

关于二进制科学记数法

可以联想一下我们熟知的十进制科学计数法

N=N10EN = N_{有效数字}*10^{E}

所谓的有效数字就是介于[1,10)[1, 10)这个区间的数字(当然有可能带负号),而这个指数实际上可以看做是小数点的移动

  • 当为正数时,小数点从有效数字往右移动相应的位数即可得到真实的数值;
  • 同理,当指数位负数时,小数点从有效数字往左移相应的位数即可得到真实的数值;

按照这个原理,二进制科学记数法的指数也是用来移动有效数字小数点位置的,只不过这时有效数字用的是二进制来表示的

为何只记录有效数字中的小数部分?

联系上面提到的二进制科学计数法,可以得到二进制的有效数字位于[1,2)[1, 2)这个区间内,所以很明显这个区间内有效数字部分的整数始终为1!既然为常量,也不需要再进行存储了,这也是为何尾数M加上1的原因,目的就是补充有效数字的整数部分。

指数部分为何减去1023?

答案就是因为指数也有负数,而指数部分最大值为21112^{11} - 1(即2047),因此取一个中间数1023,使得[0,2047][0, 2047]区间的数正负对半(大致对半,实际上区间变成了[1023,1024][-1023, 1024])。

思考:为何不像sign bit那样指定一个指数符号位?试想一下,如果把指数部分第一位变成表示符号的位,那么剩余位数只有10位了,因此能表达的区间就是[(2101),2101)][-(2^{10} - 1), 2^{10} - 1)],即[1023,1023][-1023, 1023]

小数部分二进制的转换

整数部分十进制转其它进制的套路都很熟了,也就是辗转相除法;但是小数部分的转换好像有点陌生,不过大概的套路和辗转相除法有点相似,只不过变成了乘法;

  1. 将小数部分乘2,得到的数值其整数部分作为当前位进行存储;
  2. 将上述得到的数值小数部分重复1步骤,直到小数部分为0

和整数部分进制转换相比,小数部分的进制转换是从高位到低位,因为小数部分的指数都是负数。下面是在js中进行小数部分二进制的转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 将浮点数的小数部分转为二进制小数表示
* @param {number} f 浮点数
* @param {number} maxLength 小数最大长度
*/
function decimalToBinary (f, maxLength = 57) {
let res = '0.'
let cur = 0 // 小数位
let decimal = f > 1 ? f - Math.floor(f) : f // 当前位对应的小数

while (cur < maxLength && decimal !== 0) {
cur++
let n = decimal * 2
let intNum = Math.floor(n) // 取整数部分
decimal = n - intNum // 去小数部分
res += intNum // 将整数位补充到对应位上
}

return res
}

console.log(decimalToBinary(0.1)) // 0.0001100110011001100110011001100110011001100110011001101

事实上可以利用Number.prototype.toString()方法转化二进制浮点数,因为该方法接受一个参数用来表示要转换的进制(默认进制就是10);如:

1
console.log(Number(0.1).toString(2)) // 0.0001100110011001100110011001100110011001100110011001101

经实践,通过toString转化的浮点数最大位数57位。

如何将二进制小数转为十进制小数

这个方法跟整数部分的转换是一致的,只不过小数位的指数变成了负数

D=i=1nki2i, (ki为第i位小数)D = \displaystyle\sum_{i=1}^{n}k_i * 2^{-i},\ (k_i\text{为第i位小数})

精度丢失产生的原因

综上,由于双精度浮点数的尾数部分只有52位;因此,当浮点数的实际尾数超出52位时,就会进行截取,也就是第52位之后的尾数都会被舍去,且如果取到的尾数的最后一位是1时,还要进行进位(类似十进制的四舍五入)。

由于上面的原因,可能导致某些浮点数在存储时发生了偏差,然后还原成十进制后数字也就自然不符合预期了。

分析 0.1 + 0.2 的结果

0.1的双精度存储二进制表示[3]如下:

img

1
2
00111111 10111001 10011001 10011001
10011001 10011001 10011001 10011010

0.2的双精度存储二进制表示如下:

img

1
2
00111111 11001001 10011001 10011001
10011001 10011001 10011001 10011010

可以根据获得的二进制存储信息来还原浮点数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 根据双精度浮点数存储信息还原出原浮点数(十进制表示)
* @param {*} info 双精度浮点数存储信息
*/
function getFloat (info) {
let e = info.e.toString(10) - 1023 // 计算指数
let res = Math.pow(2, e) * (info.i !== undefined ? info.i : 1) // 有效数字的整数部分始终为1

info.m.split('').forEach((item ,idx) => {
res += Number(item) * Math.pow(2, -idx - 1 + e) // 计算尾数部分
})

return info.s === 0 ? res : -res // 判断符号
}

let n1 = {
s: 0,
e: 0b01111111011,
m: '1001100110011001100110011001100110011001100110011010'
} // 0.1的表示

let n2 = {
s: 0,
e: 0b01111111100,
m: '1001100110011001100110011001100110011001100110011010'
} // 0.2的表示

console.log(getFloat(n1)) // 0.1
console.log(getFloat(n2)) // 0.2

可以看出从0.10.2存储的二进制数据还原得到的结果是正确的,并没有精度丢失,但是为何相加之后得到的结果却不是0.3呢?

由于0.10.2的尾数和符号都是相同的,不同的地方在指数部分(0.2的指数比0.1指数大1),但是二进制数相加时,要保证指数相同;因此先将0.1小数点左移一位得到:

1
2
3
4
5
6
7
8
9
let n3 = {
s: 0,
e: 0b01111111100,
i: 0, // 有效数字整数部分,仅作演示,实际只能是1
// 由于小数点左移一位,原本的有效数整数部分1变成了尾数部分的第一位,尾数其他位的也依次右移一位
m: '1100110011001100110011001100110011001100110011001101'
} // 0.1的另一种表示

console.log(getFloat(n3)) // 0.1

然后尾数部分进行相加,得到新的尾数:

1
10110011001100110011001100110011001100110011001100111

由于新的尾数超出了52位(53位),需要进1位:

1
2
1 + 1.0110011001100110011001100110011001100110011001100111 =
10.0110011001100110011001100110011001100110011001100111

因此,最终0.1 + 0.2得到的二进制结果就是:

1
2
3
4
5
let n4 = {
s: 0,
e: 0b01111111101, // 指数加了1
m: '00110011001100110011001100110011001100110011001100111' // 尾数右移了,因此多了一位
}

然而由于尾数部分超出了52位,因此只能截留52位,又因为截留的最后一位是1,因此要进1位,最终该结果的浮点数二进制表示为:

1
2
3
4
5
6
7
let n4 = {
s: 0,
e: 0b01111111101,
m: '0011001100110011001100110011001100110011001100110100'
} // 0.1 + 0.2 的二进制表示

console.log(getFloat(n4)) // 0.30000000000000004

所以,0.1 + 0.2最终得到的结果却是0.30000000000000004就是这么回事。

扩展:如何修复精度丢失导致的计算问题?

JS基础测试47期 · Issue #74 · zhangxinxu/quiz

相关文档


  1. IEEE 754 - 维基百科,自由的百科全书 ↩︎

  2. 双精度浮点数 - 维基百科,自由的百科全书 ↩︎

  3. Double (IEEE754 Double precision 64-bit):双精度浮点数存储计算可视化 ↩︎