从javascript的0.1 + 0.2说起

众所周知,在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尾数
image-20211123220607989.png

对阶运算

接下来,计算 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;
}

取小数点最长的位数,得出等比放大的基数,将小数放大成整数,相加后除以这个基数

舍入

  1. 首先是向最近的有效数舍入
  2. 如果它与两个相邻的有效数距离一样时(即它是中间数,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 舍入规则就是:

  1. 如果 DD..D < 10..0,则向下舍入
  2. 如果 DD..D > 10..0,则向上舍入
  3. 如要 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

https://www.zhihu.com/question/46432979/answer/221485161

https://baike.baidu.com/item/%E5%8D%81%E8%BF%9B%E5%88%B6%E8%BD%AC%E4%BA%8C%E8%BF%9B%E5%88%B6/393189#2

你可能感兴趣的:(从javascript的0.1 + 0.2说起)