众所周知,在Javascript中,0.1 + 0.2 不等于0.3,但是如果让你把这个问题解释清楚,或者再举出其他类似的例子时,可能只能缓缓打出三个字:寄
。
在入门经典书籍红宝书中,在介绍基础类型Number类型
时,是这样对这个问题作出解释的:
浮点值的精确度最高可达 17 位小数,但在算术计算中远不如整数精确。例如,0.1 加 0.2 得到的不是 0.3,而是 0.300 000 000 000 000 04。由于这种微小的舍入错误,导致很难测试特定的浮点值。
之所以存在这种舍入错误,是因为使用了
IEEE754
数值,这种错误并非ECMAScript 所独有。其他使用相同格式的语言也有这个问题。
上面的红宝书引用里,提到了一个关键词:IEEE754
。那么什么是IEEE754
?JavaScript又是如何处理浮点数据的呢?
IEEE754
扔上维基百科的链接,自行了解一下。
这里用一句话概述,IEEE754
是一种二进制浮点数算术标准。它规定了四种表示浮点数值的方式:单精确度(32位)、双精确度(64位)、延伸单精确度(43比特以上,很少使用)与延伸双精确度(79比特以上,通常以80位实现)。
这里出现了我们熟悉的关键词:单精度和双精度。没学过Java的都知道,在Java里用float类型
定义单精度浮点数,用double类型
定义双精度浮点数。
Javascript的Number类型,使用的就是IEEE754
标准中的64位的双精度浮点数。
浮点数的二进制转换
我们在计算数学问题的时候,使用的是十进制,计算0.1 + 0.2的结果等于0.3,没有任何问题。但在计算机中,存储数据使用的是二进制,数据由0和1组成。所以在对数据进行计算时,需要将数据全部转换成二进制,再进行数据计算。
十进制转二进制
-
十进制整数转换为二进制整数
- 除2取余,逆序排列
-
十进制小数转换为二进制小数
- 乘2取整,顺序排列
这里拿十进制的浮点数78.375转换成二进制举例:
78.375的整数部分计算:
小数部分计算:
所以,78.375的二进制形式就是1001110.011
然后,使用二进制科学记数法,可以得到:
注意,转换后用二进制科学记数法表示的这个数,有底、有指数、有小数部分,这个就称之为浮点数。
浮点数在计算机中的存储
还是使用上面的78.375的二进制换算浮点数 1.001110011×2^6 来举例:
在计算机中,保存这个数使用的是双精度浮点表示法,分为三大部分:
第一部分用来存储符号位(sign),占用1位,用来区分正负数,这里是0,表示正数。
第二部分用来存储指数(exponent),占用11位,用来表示指数,这里的指数是十进制的6。
第三部分用来存储小数(mantissa),占用52位,用来表示小数。这里的小数部分是001110011。
如下图所示:
可以看出: 指数位决定了大小范围,小数位决定了计算精度。
有两个点需要注意:
IEEE 754标准规定,在保存小数
mantissa
时,第一位默认是1,因此可以被舍去,只存储后边的部分。例如,1.01001保存的时候,只保存01001,等到用的时候再把1加上去。这样,就可以节省一个位的有效数字。指数E在存储的时候也有些特殊。为64位浮点数时,指数占11位,范围为0-2047 。但是,指数是有正有负的,因此实际值需要在此基础上减去一个中间数。对于64位,中间数为1023 。
故78.375最后保存在计算机里,成为了以下形式(可以使用这个网站来验证一下计算结果):
符号位: 0
指数位: 6+1023 = 1029,二进制表示为:10000000101
小数位:1.001110011 ,舍弃第一位的1,不足补0,表示为:0011100110000000000000000000000000000000000000000000
0 10000000101 0011100110000000000000000000000000000000000000000000
S E指数 M尾数
[图片上传失败...(image-ab2cc6-1639234718723)]
0.1 + 0.2时,到底发生了什么?
在了解了计算机对浮点数的转换及存储的基础之后,我们再来看0.1 + 0.2 这个问题。
首先我们将十进制数0.1换算成二进制:
十进制0.1转为二进制为0.0001100110011(0011循环),即 1.100110011(0011)2^-4*。
符号位: 0
指数位: -4,实际存储为 -4 + 1023 = 1019 的二进制01111111011。
小数位:1.100110011(0011循环),由于IEEE 754尾数位数限制,需要将后面多余的位截掉(0舍1入,精度损失的原因之一),舍弃掉首位后为1001100110011001100110011001100110011001100110011010。
0 01111111011 1001100110011001100110011001100110011001100110011010
S E指数 M尾数
十进制0.2转为二进制为0.001100110011(0011循环),即 1.100110011(0011)2^-3* ,存储时:
符号位: 0
指数位:-3,实际存储为 -3 + 1023 = 1020 的二进制 01111111100。
小数位: 1.100110011(0011循环),舍弃首位,截掉多余位后为1001100110011001100110011001100110011001100110011010
0 01111111100 1001100110011001100110011001100110011001100110011010
S E指数 M尾数
对阶运算
接下来,计算 0.1 + 0.2 。
浮点数进行计算时,需要对阶。即把两个数的指数阶码设置为一样的值,然后再计算小数部分。其实对阶很好理解,就和我们十进制科学记数法加法一个道理,先把指数部分化成一样,再计算小数。
另外,需要注意一下,对阶时需要小阶对大阶。因为,这样相当于,小阶指数乘以倍数,小数部分相对应的除以倍数,在二进制中即右移倍数位。这样,不会影响到小数的高位,只会移出低位,损失相对较少的精度。
因此,0.1的指数阶码为 -4 , 需要对阶为 0.2的指数阶码 -3 。尾数部分整体右移一位。
1.100110011(0011)2^-4* 变成 0.1100110011(0011)2^-3*
符号位: 0
指数位:-3,实际存储为 -3 + 1023 = 1020 的二进制 1111111100。
小数位: 0.1100110011(0011循环),截掉多余位(0舍1入)后为1100110011001100110011001100110011001100110011001101
原来的0.1
0 01111111011 1001100110011001100110011001100110011001100110011010
对阶后的0.1
0 01111111100 1100110011001100110011001100110011001100110011001101
然后进行尾数部分相加 ,做加法时我们带上整数位进行计算:
0 01111111100 0.1100110011001100110011001100110011001100110011001101
+ 0 01111111100 1.1001100110011001100110011001100110011001100110011010
= 0 01111111100 10.0110011001100110011001100110011001100110011001100111
可以看到,产生了进位。因此,阶码需要 +1,即为 -2,尾数部分进行低位0舍1入处理(精度损失的原因之二)。因尾数最低位为1,需要进位。所以存储为:
0 1111111101 0011001100110011001100110011001100110011001100110100
最后把二进制转换为十进制,计算结果的二进制表示为:1.0011001100110011001100110011001100110011001100110100 * 2^-2
转为十进制,最终结果为:
0.30000000000000004
所以 0.1 + 0.2 !== 0.3 这个问题就这样产生了...
提问
这算是Bug吗?
这不是bug,原因在与十进制到二进制的转换导致的精度问题。其次这几乎出现在很多的编程语言中:C、C++、Java、Javascript、Python中。
准确来说:“使用了IEEE754浮点数格式”来存储浮点类型(float 32,double 64)的任何编程语言都有这个问题。
如何解决这个问题?
number-precision
function plus(...nums: numType[]): number {
if (nums.length > 2) {
return iteratorOperation(nums, plus);
}
const [num1, num2] = nums;
// 取最大的小数位
const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2)));
// 把小数都转为整数然后再计算
return (times(num1, baseNum) + times(num2, baseNum)) / baseNum;
}
取小数点最长的位数,得出等比放大的基数,将小数放大成整数,相加后除以这个基数
舍入
- 首先是向最近的有效数舍入
- 如果它与两个相邻的有效数距离一样时(即它是中间数,halfway),那么舍入到最近的偶数有效数。
以二进制数为例,假定有效数位(也称保留位)为 4. 那舍入举例如下: 1.001 011 舍入结果为 1.001, 因为 1.001 011 与 1.001 的距离是 0.000 011,而与 1.010 距离是 0.000 101,显示与前者近 1.001 101 舍入结果为 1.010, 因为 1.001 101 与 1.001 的距离是 0.000 101,而与 1.010 的距离是 0.000 010, 显示与后者近 其实从二进制上可以发现规律,当有效位的后一位是 0 时,那么即将被舍去的值 小于最后一位有效位数值的一半(说得有点拗口) ,那么应该向下舍入;而当有效位的后一位是 1 时,而且后面数位不全为零,那即将被舍去的值 大于最后一位有效位数值的一半 ,那么应该向上舍入。 但有一种殊情况就是,有效位后一位是 1,后面数位全是零,刚好是最后一位有效位数值的一半,这种情况下是向最近的偶数舍入。比如: 1.001 100 : 它相近两个偶数分别是 1.000 和 1.010,显然是 1.010 离它近一些,故舍入到 1.010 1.100 100 : 它相近两个偶数分别是 1.100 和 1.110,显然是 1.100 离它近一些,故舍入到 1.100 这里也可以发现规律:如果即将被舍的值刚好等于一半,如果最低有效位为奇,则向上舍入,如果为偶,则向下舍入。 综上所述,如果以形式 1.RR..RDD..D 表示浮点数(R 表示有效位,或保留位,而 D 表示舍去位),Roundings to nearest even 舍入规则就是:
- 如果 DD..D < 10..0,则向下舍入
- 如果 DD..D > 10..0,则向上舍入
- 如要 DD..D = 10..0,则向最近偶数舍入,细则如下 : a. 如果 RR..R = XX..0 (X 表示任意值,0 或 1),则向下舍入 b. 如果 RR..R = XX..1,则向上舍入
规格化与非规格化,以及 Bias (CSAPP 2.4.2)
规格化的值,阶码被解释为以偏置形式表示的有符号整数,阶码 = E - Bias,Bias 为 2^(k-1) -1,其中 k 为阶码的尾数,这样 E 就可以存储为无符号数,但可以表示正负值,有符号数比较复杂
非规格化的值,阶码全是 0,阶码固定= 1 - Bias,小数不默认补开头的 1,这样可以平滑过渡到规格化的值 考虑如下场景,Bias = 2^(4-1) - 1 = 7
# 非规格化,阶码固定为 -6(1-7)
0 0000 001 = 2^(-6) * 1/8
...
0 0000 110 = 2^(-6) * 6/8
0 0000 111 = 2^(-6) * 7/8
# 规格化
0 0001 000 = 2^(1 - 7) * 1
0 0001 001 = 2^(1 - 7) * (1 + 1/8)
...
还有哪些类似0.1 + 0.2的场景
换算成二进制后无穷循环的都可能出现这种场景。下面举几个例子:
参考链接
https://juejin.cn/post/6844903680362151950
https://www.cnblogs.com/starry-skys/p/11824852.html
https://zh.wikipedia.org/wiki/IEEE_754
https://zh.wikipedia.org/wiki/%E9%9B%99%E7%B2%BE%E5%BA%A6%E6%B5%AE%E9%BB%9E%E6%95%B8