0.1+0.2!=0.3看计算机的数字存储

347 阅读10分钟

前导

前端的面试八股文必有一道题:0.1+0.2 === 0.3吗?想必大家都知道不等于,而且很多人都知道是因为进制的原因,导致在舍入的时候造成了精度丢失。但是也有一部分可能会疑惑了。为什么会精度丢失呢?计算机是怎么存储0.1和0.2的呢?如果从计算机基础去解释这个问题的话,这还和js最大安全数中2^53-1中的53有关。到底什么关系呢?我们一起来看看吧!

首先要解释0.1+0.2的问题,就必须知道0.1和0.2在计算机中是怎么存储的。那我们首先要解释一下定点数和浮点数这两个基础概念

定点数

要想理解什么是「定点数」,首先,我们需要理解「定点」究竟是什么意思?

我们都知道,数字既包括整数,又包括小数,而小数的精度范围要比整数大得多,所以如果我们想在计算机中,既能表示整数,也能表示小数,关键就在于这个小数点如何表示?

于是人们想出一种方法,即约定计算机中小数点的位置,且这个位置固定不变,小数点前、后的数字,分别用二进制表示,然后组合起来就可以把这个数字在计算机中存储起来,这种表示方式叫做「定点」表示法,用这种方法表示的数字叫做「定点数」。

也就是说「定」是指固定的意思,「点」是指小数点,小数点位置固定即定点数名字的由来。

那么如果使用定点数来表达,我们面对不论是整数、小数、等都可以准确表示了,比如(下文中均用D表示十进制,B表示二进制)

  1. 纯整数:例如整数100,小数点其实在最后一位,所以忽略不写

    1. 整数25(D) <==> 0001 1001 (B) 我们使用一个字节(8bit)来表示
  2. 纯小数:例如:0.125,小数点固定在最高位

    1. 小数0.125(D) <==> 0.0010 0000(B) 我们使用一个字节(8bit)来表示

我们准确的表示了整数和小数,那么整数+小数呢?使用定点数的表示方法,我们将126.125的小数点前后的十进制数转成二进制在拼接在一起就行了,但是我们最终是要用一个有精度的数字来表达结果的,所以我们需要约定一个精度,比如还是1个字节,那么我们使用前5个字节表示整数,后三个字节表示小数(这里小数中0.001的0.的0可以忽略不计,因为小数的这个位数必定是0,所以可以约定不计)进过这样的转换就是

25.125(D) <==> 00011001取五位 + 00100000取三位 <==> 11001 001 <==> 1100 1001

1.5(D) <==> 00001 100(B)

这样一个整数+小数就表示好了!!!!

总结下来就是

  1. 在有限的 bit 宽度下,先约定小数点的位置
  2. 整数部分和小数部分,分别转换为二进制表示
  3. 两部分二进制组合起来,即是结果

那现在我想表达125.8125(D)呢?,还是用刚才的定点数约定方法,

整数部分: 125(D) <==> 0111 1101

小数部分:0.8125 <==> 0.1101 0000

在将两者组合在一起还要是一个字节,这时候就有问题了,我们回发现不管是整数还是小数的位数都超出了约定,我们约定整数5个bit,但是现在有7个bit;我们约定小数3个bit,但是现在有4个bit,也就是我们约定的方式最都只能表达出31.875

大家在看到这里可能会想,我们用五位数来表示整数部分是不是太少了,对没错,这就是这个方法的弊端,要使用这个定点数表示法还要解决这个弊端,我们有两种方法

  • 增加位数,刚才我们使用的1个字节,也就是8位,那我们可以使用2字节、4字节表示更大的数
  • 我们还可以改变小数点的位置:小数点向后移动,整个整数表达范围就会扩大,数字就会变大,但是小数部分的精度就会越来越低,没有办法表示类似 0.00001(D) 这种高精度的值

突然发现这种方法在表示整数和小数都还行,但是表示整数+小数就肯定不合适了。。。。

浮点数

既然定点数不行,那我们需要一种新的表示方法来表示整数+小数,浮点数的意思就是浮动的小数点,那小数点怎么浮动的呢?

浮点数其实是用科学计数法来表示的,在我们学习的科学计数法都是a * 10^b

比如现在一个整数+小数是125.348,可以表示如下

12.5348 * 10^1 、1.25348 * 10^2 、0.125348 * 10^3

可以看到小数点是来回浮动的,所以叫做浮点数。

浮点数的科学计数法就是

V = (-1)^S * M * R^E

这里就是

  • S:符号位,取值 0 或 1,决定一个数字的符号,0 表示正,1 表示负
  • M:尾数,用小数表示,例如前面所看到的 1.25348 * 10^2,1.25348 就是尾数
  • R:基数,表示十进制数 R 就是 10,表示二进制数 R 就是 2
  • E:指数,用整数表示,例如前面看到的 10^1,1 即是指数

假设我们定义如下规则来填充这些 bit:

  • 符号位 S 占 1 bit
  • 指数 E 占 10 bit
  • 尾数 M 占 21 bit

图示如下

image-20220825134308866

如果使用这个表示方法,我们就可以将之前使用定点数表示的数字再使用浮点数表示25.125

  • 25.125是正数,所以符号位s是0
  • 25整数部分 <==> 1 1001(B)
  • 0.125小数部分 <==> 0.001(B)
  • 用科学计数法就是25.125(D) <==> 11001.001 <==> 1.1001001 * 2^4
  • 所以s = 0 、M = 1.1001001 (但是1<= M <2得,所以这个1.我们可以省略掉,所以 M= 1001001 ) 、E = 4 、R = 2

使用图示方法就是

preview

这样我们一个浮点数就做好了,但是前面我们提到了这个规则是我们假设的,那就是说S E M 我们想定义几个bit就是几个bit,都可以,只要厂商的计算机也这样实现就行了。我们可以得出结论

1.E越大,M就会越小,这就会导致数值的范围越大,但是小数的精度越小,反之亦然!

2.一个数字的浮点数格式,会因为定义的规则不同,得到的结果也不同,表示的范围和精度也有差异

早期人们提出浮点数定义时,就是这样的情况,当时有很多计算机厂商,例如IBM、微软等,每个计算机厂商会定义自己的浮点数规则,不同厂商对同一个数表示出的浮点数是不一样的。那为了解决这个问题,我们就有一个统一的规则,于是IEEE 754就出示了。

行业标准

  • 一个浮点数 (Value) 的表示其实可以这样表示:

    {\displaystyle {\texttt {Value}}={\texttt {sign}}\times {\texttt {exponent}}\times {\texttt {fraction}}}

    也就是浮点数的实际值,等于符号位(sign bit)乘以指数偏移值(exponent bias)再乘以分数值(fraction)。

    以下内容是IEEE 754对浮点数格式的描述。

    img

    首先就是定了名称,指数偏移值 = 基数^指数的十进制 分数值 相当于 尾数的十进制

  • 这个标准统一了浮点数的表示形式,并提供了 2 种浮点格式:

    • 单精度浮点数 float:32 位,符号位 S 占 1 bit,指数偏移值 E 占 8 bit,分数值 M 占 23 bit
    • 双精度浮点数 float:64 位,符号位 S 占 1 bit,指数偏移值 E 占 11 bit,分数值 M 占 52 bit
  • 以32位为例,由于指数偏移值是8位,所以可以表示0-255,但是指数偏移值没有符号位,但是指数是有负数的,所以我们需要把0-255分成正负两部分,IEEE754就定义了一个中间值2^(e-1)-1 也就是2^(8-1)-1 = 127,这样就分成了-127~128,我们将要表示的指数的十进制+127,也刚好是0-255。

    64位的也是同理,所以其中间值是1023,指数偏移量范围是-1023 ~ 1024

  • 分数值 M 的第一位总是 1(因为 1 <= M < 2),因此这个 1 可以省略不写,它是个隐藏位,这样单精度 23 位尾数可以表示了 24 位有效数字,双精度 52 位尾数可以表示 53 位有效数字,再加上符号位的正负表示,这也是js的安全数是-2^53-1~+2^53-1的原因。

  • 除了规定尾数和指数位,还做了以下规定:

    • 指数 E 非全 0 且非全 1:规格化数字,按上面的规则正常计算

    • 指数 E 全 0,分数值非 0:非规格化数,分数值隐藏位不再是 1,而是 0(M = 0.xxxxx),这样可以表示 0 和很小的数

      问:怎么表示0?

      如果指数是0并且尾数的小数部分是0,这个数0

      问:为什么可以表示更小的数?

      IEEE 754标准规定:非规约形式的浮点数的指数偏移值比规约形式的浮点数的指数偏移值小1。例如,最小的规约形式的单精度浮点数的指数部分编码值为1,指数的实际值为-126;而非规约的单精度浮点数的指数域编码值为0,对应的指数实际值也是-126而不是-127。

    • 指数 E 全 1,分数值全 0:正无穷大/负无穷大(正负取决于 S 符号位)

    • 指数 E 全 1,分数值非 0:NaN(Not a Number)

preview

浮点数精度丢失

通过前面我们知道了怎么表示一个浮点数,js中没有float和double类型,一个number就是64位的浮点数,而我们在做0.1+0.2的时候首先会将0.2转成二进制。

如果我们现在想用浮点数表示 0.2,它的结果会是多少呢?

0.2 转换为二进制数的过程为,不断乘以 2,直到不存在小数为止,在这个计算过程中,得到的整数部分从上到下排列就是二进制的结果。

0.2 * 2 = 0.4 -> 0
0.4 * 2 = 0.8 -> 0
0.8 * 2 = 1.6 -> 1
0.6 * 2 = 1.2 -> 1
0.2 * 2 = 0.4 -> 0(发生循环)
...

同理,0.1在转换的时候也发生了这个情况

0.1 * 2 = 0.2 -> 0   //跟前面比就多了这一行
0.2 * 2 = 0.4 -> 0
0.4 * 2 = 0.8 -> 0
0.8 * 2 = 1.6 -> 1
0.6 * 2 = 1.2 -> 1
0.2 * 2 = 0.4 -> 0(发生循环)
...

大家可以在这个网站直观看到进制的转换

重复此操作至 64 位。然后把它们按升序排列,获取尾数,再根据双精度标准,我们将把其四舍五入到 52 位。

img

尾数

用科学计数法表示二进制 0.1 并只保留前 52 位:

img

尾数部分处理好后。现在我们用下面的方式处理指数:

img

这里,11 代表我们要使用的 64 位表示的指数位数,-4 代表科学计数中的指数。

所以最终数字 0.1 的表示形式是:

img

同理,0.2 表示为:

img

将两个数相加(这里的相加不是直接逢二进一,而是转换成相同的阶数之后再相加)分为5步,对阶,尾数运算,规格化,舍入,判溢出,这里不做介绍,感兴趣的可以去了解一下 ,之后得到:

img

转换为浮点数,它变成:

img

这就是 0.1 + 0.2 = 0.30000000000000004 的原因。

总结

  • 定点数表示有一定的局限性
  • 浮点数其实使用的是科学计数法表示,而且目前国际统一规范分成了32位的单精度和64位的双精度
  • 0.1+0.2!==0.3是因为0.1和0.2在转换的过程中发生了精度丢失,在相加的对阶运算过程中也发生了精度丢失