本文为作者自己的总结的,由于作者的水平限制,难免会有错误,欢迎大家指正,感激不尽。
说起浮点数,大家都是又恨又爱的。爱呢,是因为,只有它可以方便地使用小数;恨呢,是因为它并不能精确地表示小数。
以 PHP 为例:floor((0.1 + 0.7) * 10)
这样一个函数调用,根据数学老师死得晚原理,大家都能得出 8 这个结果。可是事实上呢?它会返回 7。数学老师的棺材板。。。(╯‵□′)╯︵┻━┻
可是为什么会出现这种情况呢?这就要从浮点数的特性说起了。
万物皆二进制
我们都知道,在计算机中,一切的一切都是二进制表示的。假设一个 4 字节整型的十进制数 8,在大端表示的机器中,表示成 00000000 00000000 00000000 00001000
(0x0000008
)。将十进制整数转换成二进制数,是非常容易的。可是,小数呢?比如,我们要表示 1.75,该怎么存储在计算机中呢?显然,不能像整数一样存储了。
小数的二进制
让我们回忆一下,在十进制中,小数是怎么计算的。上面的 1.75 我们是这么算的:1 × 10^0 + 7 × 10^-1 + 5 × 10^-2 。那么我们按照相同的规则,来用二进制计算一下小数部分:0.75 = 1/2 + 1/4,也就是 1 × 2^-1 + 1 × 2^-2 ,再加上前面的整数部分,那么整个式子就变成了 1 × 2^0 + 1 × 2^-1 + 1 × 2^-2 ,写成二进制形式就是 1.11。所以,1.75 的二进制表示是 1.11。
对于将小数转换为二进制,和整数部分除二取余相反的,是乘二取整。
0.75 * 2 = 1.5 -> 1
0.5 * 2 = 1 -> 1
所以我们同样可以得出 1.11。
科学计数法
好了,我们已经知道如何表示一个小数的二进制了。辣么,问题来了。学过 C 语言的同学都知道,一个 float
只有 4 字节,一个 double
也只有 8 字节。那么,这么表示一个小数,好像范围很有限。
在数学老师哭晕在厕所之前,我们应该还记得十进制数中有这么一个东西——科学计数法,我们可以很方便地用它来表示很大的十进制数。那么,同理,我们也可以用在浮点数的表示上。
让我们先来回忆一下,科学计数法的表示。假设我们有一个数 17500,我们可以用科学计数法表示成 1.75 × 10^4 。我们照葫芦画瓢,在二进制数中,假设有一个数是 11010。我们来和十进制对应一下。十进制是乘 10,那么二进制就是乘 2,我们对应的就可以写成 1.101 × 2^100 。对,其实就是这么简单。那也许有的人会问了,为什么不写成 0.1101 × 2^101 呢?我们再来回忆一下,在十进制科学计数法中,是不是有一个规定,整数部分的范围是 [1,10)。那对应到我们的二进制数上,这个规定就可以变成 [1,2) 了,没错,对应关系就是这么简单。
浮点数
好了,我们现在也知道怎么使用二进制来表示小数,以及使用科学计数法来表示二进制小数了。那么,我们距离把数字存入计算机内存仅剩一步之遥了,我们要把所有的东西存到内存里去,那么我们就需要合理地分配内存空间。浮点数有两种,一种是单精度浮点数(float
),占用 4 字节的内存。其中,1 位是符号位,8 位是阶码(幂),23 位是尾数(小数部分)。
细心的各位可能会发现,好像没有整数部分?别急,这就是上面那个规定的有用之处。当整数部分在 [1,2) 之间时,也就只可能取到一个值 1,那么,对于这个值,我们是不是就可以当做默认值而不记录在浮点数的表示中了?而这样,我们的浮点数的精度又多了一位(小数部分的位数决定了精度)。这种表示叫做隐含 1 开头的表示。
规格化与非规格化
偏置值
到了这里,我们发现,第一位是浮点数的正负符号,那么,对于一个科学计数法来说,阶码同样需要有正负。而在单精度中,阶码只有 8 位;双精度中,阶码只有 11 位。如果我们给阶码表示成补码,那么,我们能够表示的数的范围就会缩小,这样显然是不划算的。于是,偏置值就由此诞生了。
规格化的值(阶码不全为 0 或 1)
在内存中的规格化的浮点数表示中,阶码并非是 2 的幂,而是经过计算的结果,这个计算公式就是 e - Bias,这里的 Bias 就是偏置值
,而 e 就是阶码在浮点数中的二进制表示。Bias 的值是 2^k-1 - 1(单精度是 127,双精度是 1023),所以,e - Bias 的取值范围就是 [-126, 127](单精度)和 [-1022, 1023](双精度)。其实如果对补码了解的比较好的同学,应该就能看出来,这其实就是省略了符号位的补码表示)。
通过上面的隐含 1 开头的表示的尾数,我们可以计算出基数 M = 1 + f。那么我们整个的浮点数可以写成这样一个表达式:M × (e - Bias)。
非规格化的值(阶码全为 0)
对于规格化和非规格化的值来说,我们都可以用同一个式子来表示。不过,为了某些更加方便的原因(这里就不展开讲了),对它们做了区分。如果按照规格化的计算来看,阶码的值是 0 - Bias,不过在这里,我们让阶码的值等于 1 - Bias。同样的,由于我们给阶码加了 1,那么整个浮点数就会向左移动一位,那么,我们需要让浮点数的值不变,M 就不在需要上面整数部分的 1 了,所以 M = f。
同时,我们会发现一个问题,那就是 +0.0 和 -0.0 在浮点数的二进制表示上是不同的。
特殊值(阶码全为 1)
最后,还剩下这样一种数字,那就是阶码全为 1 的情况。当小数为 0 的时候,浮点数的值为 ∞。当小数不为 0 时,浮点数的值为 NaN,即不是一个数(Not a Number)。
计算浮点数
好了,扯了这么多,我们现在回到最开始的问题上,floor((0.1 + 0.7) * 10) = 7
。我们先看 0.1 的二进制表示。
首先,我们将十进制小数转换成二进制小数,可以得到 0.000[1100]···。让我们转换成浮点数的二进制表示。按照上面的规则,它可以被表示成科学计数法 1.10011001100110011001100 × 2^-4 ,这样,阶码就是 -4 + 127 = 123,二进制表示为 01111011。所以,整个浮点数的二进制表示就是 00111101110011001100110011001100
(0x3dcccccc
)。同样的,0.7 会表示为00111101001100110011001100110011
(0x3d333333
)。
首先我们要对阶码小的数进行对阶,然后再进行尾数的加法,这样,我们得到的值就是 00111101111001100110011001100101
。我们将其转换成十进制,发现,它是小于 0.8 的。因此,当我们再进行乘法运算向下取整时,会等于 7。
最后
其实,浮点数有很多坑。因此,我们在使用浮点数的时候,一定要小心。还有,涉及到金额计算的时候,一定不能使用浮点数。
本文为作者自己读书总结的文章,由于作者的水平限制,难免会有错误,欢迎大家指正,感激不尽。
参考文献
《深入理解计算机系统(第 3 版)》第 2.4.2 节
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。