浮点数的世界(一):深入解析Float、Double与Decimal

160 阅读6分钟

前言

之前有一位学长在上班的时候出现了这样一个Bug:死活算不出正确数据

于是拿测试单元试了一下,发现关键问题在于...

小数!

先来看看这段代码

下面的代码是我用Python复现的测试逻辑:

a = 0.6
b = 0.7
print (a+b == 1.3)

# False

是的,你没看错,0.6+0.7 不等于 1.3

也就是说,问题肯定出现在小数上面。

那么我们要把代码的Bug找出来,就必须要了解小数在计算机当中,是怎么表示的。

小数是怎么表示的

各位试想一下,如果你想要设计一个小数的存储格式,你会怎么想?

我们可以先回想一下在十进制下,我们是如何表示一个小数的:

(1)c×n×10y(-1)^c \times n \times 10^{y}

例如0.1,我们就可以表示成:

(1)0×1×101(-1)^0 \times 1 \times 10^{-1}

那么现在我们就可以了解到,我们只需要存储三个数值就可以表示一个小数了。也就是ccnnyy

但是我们的电脑使用的是二进制,为了方便电脑处理数据,我们得将最后指数10y10^y中的10换成2。也就是下面的公式:

(1)c×n×2y(-1)^c \times n \times 2^{y}

例如0.5,我们就可以写成(1)0×1×21(-1)^0 \times 1 \times 2^{-1}

因此我们现在就可以设计一个自己的小数储存方式。我们拿一块大小为32位的地方,其中cc占一位,nn占10位,yy占21位。

那么我们可以算出c为0或1;n这里我们规定为一个无符号数,那么他的范围就是0到2^10;y这里我们定义为一个有符号数,范围即为-2^20 ~ 2^20-1。

但是我们也不难发现,我们可以表示出一个很小的数,但是我们不能表示出一个高精度的值。为了方便理解,我们先用十进制来代替:我们现在需要表示0.11...11(这里总共有19个1),我们想要表达这个式子,用刚才的公式就是:

(1)0×1..1×1020(-1)^0 \times 1..1 \times 10^{-20}

这里我们不难发现,如果我们去表示这个式子,我们是表示不出来的。因为我们的n只能到10位,多了就不行了。但是我们又想去表示这个式子,怎么办?

对了,科学计数法!

我们完全可以把小数用小数表示:

(1)c×n.m×10y(-1)^c \times n.m \times 10^{y}

例如1.5就可以表示成(1)0×1.5×100(-1)^0 \times 1.5 \times 10^{0}。但是这时中间的n.nn.n,我们就可以采用我们刚才用来表示小数的式子:n×10yn \times 10^{y} 来进行了。

那么,我们现在就可以这么设计了。我们将刚才的式子转为二进制:(1)c×n.m×2y(-1)^c \times n.m \times 2^{y}

我们还能优化吗?可以的。

如果我们的y为0时,此时值是直接表示成n.m的,但是这里的n.m我们也可以表示为0.nm*2^1这个形式,这时我们完全可以把前面的n直接融入到m当中,这样就可以将精度再往后一位。

那么n.m这个小数我们不妨令他在1到2之间。既然他在1到2之间,那么我们不妨直接转为1+0.m,这样我们就可以少存一个n了。

现在,我们来看看那些科学家是如何规定的:

Float 是如何表示的

IEEE 754 标准下,Float的公式是这样的:

(1)sign×(1+fraction)×2(exponent127)(−1) ^\texttt {sign}×(1+\texttt {fraction})×2^{(\texttt {exponent}−127)}

这里的sign\texttt {sign}为之前我们提到的表示正负号的东西,fraction{\texttt {fraction}} 这里是之前提到的 mm,剩下的 (exponent127){(\texttt {exponent}−127)} 就相当于是 yy

而在具体的储存上,我们将32位分为三个区域来分别储存这三个值:

借用一下Wiki的图

其中:

  • sign\texttt {sign} (正负号) 占 1 位
  • exponent\texttt {exponent} (指数) 占 8 位
  • fraction{\texttt {fraction}} (尾数)占 23 位

但是这里就有一个疑问了,为什么要减去127?

对于一个有符号数的运算而言,我们还得用额外的逻辑来处理符号位。例如当我们进行两个小数的比较时,我们可以先对正负号进行比较,然后再比较他们的指数,此时如果是有符号数的话,还得去处理符号位的问题,而如果是无符号数,我们就可以直接去比较,不需要进行其他的处理即可了解他们的大小。

我们现在可以通过Float的定义来得到这几个特殊值:

形式指数小数部分
00
非规范形式0大于0小于1
规范形式1到2^e−2大于等于1小于2
无穷2^e−10
NaN2^e−1非0

[!NOTE] 这里的NaN即为Not a Number(不是一个数)

当然,如果你还想要更高的精度,还可以把空间放大到64位。此时这个类型就叫Double(双精度)。Double具体的分配如下:

  • sign\texttt {sign} (正负号) 占 1 bit
  • exponent\texttt {exponent} (指数) 占 11 bit
  • fraction{\texttt {fraction}} (尾数)占 52 bit

但是,正如十进制无法精准的表达1/3这个值,只能无限的趋近于一样。对于2进制而言,也永远无法表达0.1这种值。所以这也就是为什么前面的代码会出现问题——因为人类用的和计算机的不是一个进制。

那么如果要设计一个可以规避这个问题的结构,你会怎么设计呢?

让计算机迁就于我们

之前我们想着是让计算机算的快一些,所以才使用二进制来表示。但是我们现在的应用场景是——要求能在人们日常使用的时候不会遇到这个问题,而对于算法的性能要求并没那么高。既然前面的问题根源在于进制,那我直接换成十进制,这样不就行了?

所以我们也可以直接使用十进制来表达这个小数,也就是我们最开始列出来的这个式子:

(1)c×n×10y(-1)^c \times n \times 10^{y}

我们在这里还是跟前面的一样,存入这三个值即可。

[!TIP] 但是,正如前面一样,十进制的存储方式依然不能解决这个趋近的问题。 那么我们再想想,还有没有一种方法可以准确的表达有理小数?

有的,分数!

我们现在只需要使用这样的公式,对于任意的有理小数,我们可以有这个公式:ab\dfrac{a}{b}。同样的,我们只需要存入两个数即可。这就是目前julia的解决办法。

最后,让我们来对比一下

维度Float(32bit)Double(64bit)Decimal
精度范围7位有效数字15-17位有效数字28-29位精确小数
内存占用4字节8字节可变(通常16+字节)
运算速度纳秒级(硬件加速)微秒级毫秒级(软件模拟)
适用场景GPU计算/嵌入式通用科学计算财务/货币计算

End

对于小数的储存我们先讲到这里。下一期我们将讲讲大佬是如何利用float的特性来简化算法的。

文后注解

[1]:Float和Double的相关标准,请看zh.wikipedia.org/wiki/IEEE_7…

[2]:如果看不懂,也可以去看一下这篇文章:kaito-kidd.com/2018/08/08/…

[3]: 对于最后面的分数这个概念,可以去看一下这篇文章:draveness.me/whys-the-de…

[4]:或者直接去看Julia的文档:docs.julialang.org/en/v1/manua…

文本 | 李嘉俊

图片 | 部分来源于网络