JS浮点数及精度问题
前言
总所周知,在JS
中进行某些浮点数的计算时会得到意想不到的结果,诸如0.1 + 0.2
和1 - 0.9
这种,得到的结果都不是我们预期的那样;这种现象实际上是由于浮点数存储时精度丢失时所导致的,但这并不是JS
独有的锅,而是采用IEEE 754
标准存储浮点数都会有的问题;
关于 IEEE 754 标准
IEEE 754规定了四种表示浮点数值的方式:单精确度(32位元)、双精确度(64位元)、延伸单精确度(43位元以上,很少使用)与延伸双精确度(79位元以上,通常以80位元实做)。[1]
在JS
中采用的是IEEE 754
中双精度(64
位)格式来存储浮点数,即每个数字类型的变量存储大小为64
位;而这64
位存储空间按照功能分成了3
个部分[2]:
- 符号位(
sign bit
):包含1
位,用来表示数值的符号;0
表示为正,1
表示为负;记为S
。 - 指数位(
exponent
):包含11
位,用来表示二进制科学计数法中的指数;记为E
。 - 尾数位(
mantissa
):包含52
位,用来表示二进制科学计数法的有效数字中的小数部分;记为M
。
因此,可以得出浮点数在双精度存储的二进制数值为:
不过,看到这个公式可能心里会有疑问;二进制科学记数法是啥?为啥指数部分要减去1023
?为啥有效数字中的整数部分不进行存储?
关于二进制科学记数法
可以联想一下我们熟知的十进制科学计数法:
所谓的有效数字就是介于这个区间的数字(当然有可能带负号),而这个指数实际上可以看做是小数点的移动:
- 当为正数时,小数点从有效数字往右移动相应的位数即可得到真实的数值;
- 同理,当指数位负数时,小数点从有效数字往左移相应的位数即可得到真实的数值;
按照这个原理,二进制科学记数法的指数也是用来移动有效数字小数点位置的,只不过这时有效数字用的是二进制来表示的。
为何只记录有效数字中的小数部分?
联系上面提到的二进制科学计数法,可以得到二进制的有效数字位于这个区间内,所以很明显这个区间内有效数字部分的整数始终为1
!既然为常量,也不需要再进行存储了,这也是为何尾数M
加上1
的原因,目的就是补充有效数字的整数部分。
指数部分为何减去1023?
答案就是因为指数也有负数,而指数部分最大值为(即2047
),因此取一个中间数1023
,使得区间的数正负对半(大致对半,实际上区间变成了)。
思考:为何不像sign bit
那样指定一个指数符号位?试想一下,如果把指数部分第一位变成表示符号的位,那么剩余位数只有10
位了,因此能表达的区间就是,即。
小数部分二进制的转换
整数部分十进制转其它进制的套路都很熟了,也就是辗转相除法;但是小数部分的转换好像有点陌生,不过大概的套路和辗转相除法有点相似,只不过变成了乘法;
- 将小数部分乘
2
,得到的数值其整数部分作为当前位进行存储; - 将上述得到的数值小数部分重复
1
步骤,直到小数部分为0
;
和整数部分进制转换相比,小数部分的进制转换是从高位到低位,因为小数部分的指数都是负数。下面是在js
中进行小数部分二进制的转换:
1 | /** |
事实上可以利用Number.prototype.toString()
方法转化二进制浮点数,因为该方法接受一个参数用来表示要转换的进制(默认进制就是10
);如:
1 | console.log(Number(0.1).toString(2)) // 0.0001100110011001100110011001100110011001100110011001101 |
经实践,通过toString
转化的浮点数最大位数为57
位。
如何将二进制小数转为十进制小数
这个方法跟整数部分的转换是一致的,只不过小数位的指数变成了负数;
精度丢失产生的原因
综上,由于双精度浮点数的尾数部分只有52
位;因此,当浮点数的实际尾数超出52
位时,就会进行截取,也就是第52
位之后的尾数都会被舍去,且如果取到的尾数的最后一位是1
时,还要进行进位(类似十进制的四舍五入)。
由于上面的原因,可能导致某些浮点数在存储时发生了偏差,然后还原成十进制后数字也就自然不符合预期了。
分析 0.1 + 0.2 的结果
0.1
的双精度存储二进制表示[3]如下:
1 | 00111111 10111001 10011001 10011001 |
0.2
的双精度存储二进制表示如下:
1 | 00111111 11001001 10011001 10011001 |
可以根据获得的二进制存储信息来还原浮点数:
1 | /** |
可以看出从0.1
和0.2
存储的二进制数据还原得到的结果是正确的,并没有精度丢失,但是为何相加之后得到的结果却不是0.3
呢?
由于0.1
和0.2
的尾数和符号都是相同的,不同的地方在指数部分(0.2
的指数比0.1
指数大1
),但是二进制数相加时,要保证指数相同;因此先将0.1
小数点左移一位得到:
1 | let n3 = { |
然后尾数部分进行相加,得到新的尾数:
1 | 10110011001100110011001100110011001100110011001100111 |
由于新的尾数超出了52
位(53
位),需要进1
位:
1 | 1 + 1.0110011001100110011001100110011001100110011001100111 = |
因此,最终0.1 + 0.2
得到的二进制结果就是:
1 | let n4 = { |
然而由于尾数部分超出了52
位,因此只能截留52
位,又因为截留的最后一位是1
,因此要进1
位,最终该结果的浮点数二进制表示为:
1 | let n4 = { |
所以,0.1 + 0.2
最终得到的结果却是0.30000000000000004
就是这么回事。
扩展:如何修复精度丢失导致的计算问题?
JS基础测试47期 · Issue #74 · zhangxinxu/quiz
相关文档
- JavaScript 浮点数陷阱及解法 · Issue #9 · camsong/blog
- js浮点数存储精度丢失原理 - 掘金
- Number.prototype.toString() - JavaScript | MDN