为什么0.1 + 0.2!==0.3?计算机的浮点数问题
- Published on
Introduction
当我们看到下面这段代码的时候,很容易想到如果调用addScore十次,应该就会输出You win!
,但是实际上并不会。
let score = 0
function addScore() {
score = score + 0.1
if (score == 1) {
console.log('You win!')
}
}
类似地,如果我们把0.1 + 0.2
的结果和0.3
比较,也会发现结果是false。
一个简单的回答是:计算机使用的格式(binary-floating-point)不能准确地表示 0.1
或 0.2
,当代码被编译或解释时,0.1
是四舍五入到该格式中最接近的数字。 事实上,这不是JavaScript
的问题,而是所有使用IEEE 754
浮点数标准的编程语言都会遇到的问题。
IEEE 754
标准
在JavaScript
中,整数和小数都只有一种类型,就是number
。JavaScript
使用IEEE 754
标准来表示数字,这个标准定义了两种表示数字的方式:32-bit
和64-bit
。32-bit
的叫做single precision
,64-bit
的叫做double precision
。JavaScript
使用64-bit
来表示数字。
这样的存储结构优点是可以归一化处理整数和小数,结构如下:
- 蓝色部分:用来存储符号位(
sign
,简写S
)用来区分正负数(0-正,1-负
) - 绿色部分:用来存储指数(
exponent
,简写E
)占用11
位; - 红色部分:用来存储小数(
fraction
,简写F
)占用52
位,多出的用0
补齐
浮点数的二进制表示
以十进制数字521.125
为例:
- 整数部分
521
转为二进制:依次除以2取余数的逆序;
521 / 2 = 260 ...... 1
260 / 2 = 130 ...... 0
130 / 2 = 65 ...... 0
65 / 2 = 32 ...... 1
32 / 2 = 16 ...... 0
16 / 2 = 8 ...... 0
8 / 2 = 4 ...... 0
4 / 2 = 2 ...... 0
2 / 2 = 1 ...... 0
1 / 2 = 0 ...... 1 // 到0结束
对余数取逆序,所以整数部分二进制为:1000001001
。
- 小数部分
0.125
转为二进制:小数部分依次乘以2
取结果整数的正序;
0.125 * 2 = 0.25
0.25 * 2 = 0.5
0.5 * 2 = 1 // 到1结束
对三次运算的结果取整,所以小数部分二进制为001。
合并,所以结果为1000001001.001
。
IEEE 754
标准表示
浮点数的接着上个例子,我们已经把十进制数字521.125
转为二进制1000001001.001
,接下来我们用IEEE 754
标准表示这个数字。
首先将1000001001.001
转为科学计数法,即1.000001001001 * 2^9
,然后将9
加上1023
(IEEE 754
规定指数E
的偏移量是1023
),得到1032
,转为二进制为10000001000
,这部分就是IEEE 754
标准中的指数部分。
对于Mantisssa部分,我们只需要取小数部分的9
位,然后补0
到52
位,所以Mantisssa
部分为000001001001
。
又因为521.125
是正数,所以符号位为0
。
所以521.125
的IEEE 754
表示为:
0 10000001000 000001001001
0.1 + 0.2 != 0.3
对于数字0.1
来说,按下图步骤进行转换,图中1100
是重复部分,它的二进制表示是0.00011001100110011001100110011001100110011001100110011010
,这个二进制小数是无限循环的,所以JavaScript
会四舍五入到最接近的64-bit
浮点数。
0.1 * 2 = 0.2
0.2 * 2 = 0.4
0.4 * 2 = 0.8
-------------
0.8 * 2 = 1.6
0.6 * 2 = 1.2
0.2 * 2 = 0.4
0.4 * 2 = 0.8
-------------
0.8 * 2 = 1.6
0.6 * 2 = 1.2
0.2 * 2 = 0.4
0.4 * 2 = 0.8
...
用科学记数法表示为1.1001100110011001100110011001100110011001100110011010 * 2^-4
,转为IEEE 754
标准表示为:
0 01111011 1001100110011001100110011001100110011001100110011010
这里因为无限循环,所以只取52
位,结果的精度丢失了。
同理,0.2
的IEEE 754
表示为:
0 01111100 1001100110011001100110011001100110011001100110011010
0.3
的IEEE 754
表示为:
0 01111101 0011001100110011001100110011001100110011001100110011
0.1+0.2
的计算过程如下:
0 01111011 1001100110011001100110011001100110011001100110011010
0 01111100 1001100110011001100110011001100110011001100110011010
---------------------------------------------------------------
0 01111100 0011001100110011001100110011001100110011001100110010
可以看到,最后计算的结果和0.3
的IEEE 754
表示不一样,所以0.1 + 0.2 != 0.3
。
如何解决这个问题?
对于开头我们用于引入的那个问题,
let score = 0
function addScore() {
score = score + 0.1
+ if(score >=1) {
- if(score == 1) {
console.log('You win!')
}
}
只需要把score == 1
改为score >= 1
,绕过score
不会精确等于1的问题。
小数计算
- 使用第三方库
Math.js
或number-precision.js
,这两个库都提供了小数计算的方法,可以避免精度丢失的问题。 - ES6提供的
Number.EPSILON
,可以用来比较两个浮点数是否相等。例如:
function isEqual(a, b) {
return Math.abs(a - b) < Number.EPSILON
}
EPSLION
,是一个极小的数,用来比较两个浮点数是否相等。
大数计算
- 使用
BigInt
,BigInt
是ES10
新增的数据类型,可以用来表示任意大的整数。例如:
let bigInt = BigInt(9007199254740991)
console.log(bigInt + BigInt(1)) // 9007199254740992n
结尾的n
表示这是一个BigInt
类型。
- 使用第三方库
big.js
,big.js
是一个用来处理大数的库,可以用来处理大数的加减乘除等运算。 例如:
let bigNum = new Big(9007199254740991)
console.log(bigNum.plus(1).toString()) // 9007199254740992
参考资料
- [1]“Decimal to IEEE 754 Floating Point Representation - YouTube,” www.youtube.com. https://www.youtube.com/watch?v=8afbTaA-gOQ
- [2]“从0.1+0.2 !== 0.3聊聊JavaScript精度问题 - 掘金,” juejin.cn. https://juejin.cn/post/7096425891816341512 (accessed Jun. 10, 2024).
- [3]Web Dev Simplified, “Why Every Computer Fails Basic Math,” YouTube, May 21, 2024. https://www.youtube.com/watch?v=qTXwRSksJPg&t=492s (accessed Jun. 10, 2024).