回想起刚学C语言时,我对浮点数的印象大概是“能够表示小数”的数据类型。还死记硬背过例如什么“小数用double存,用%f输出”这类的话。实际上呢,浮点数可以用这么一个公式来概括:
除了我们所熟知的,表示小数之外,它对执行涉及非常大的数字、非常接近于0的数字,以及更普遍地作为对实数运算的结果的近似值,是非常有用的。
以下摘取一些书本中的内容:
20世界80年代,每个计算机制造商都指定了自己的表示浮点数的规则,以及对浮点数执行运算的细节。因此,这对不同的计算机之间的协同工作带来了很大的不便。这一切都随着IEEE 754标准的推出而改变了。这是一项由Intel公司赞助的计划,详细地制定了浮点数的表示方法和运算标准。因为其具有足够的合理性和先进性,被IEEE采纳为浮点数的标准,在1985年发布。目前,实际上所有的计算机都支持这个后来被称为IEEE浮点的标准。大大提高了各种程序在不同机器上的可移植性。
旁注:IEEE(读作“Eye-Triple-Eee”),指电器和电子工程师协会,是一个包括所有电子和计算机技术的专业团体。它出版刊物、举办会议、建立委员会来定义各种标准,涉及范围从电力传输到软件工程。
这一节中,我们将看到IEEE 754 浮点格式是如何表示数字的,以及有关的其他细节。许多程序员认为浮点数没甚意思(往坏了说,深奥难懂)。但我们将看到,IEEE标准是相当优雅和容易理解的。
二进制小数
理解浮点数的第一步是理解含有小数部分的二进制数字。对于我们熟悉的十进制来说,一个小数可以表示为
每个数位上面的数字的范围是0~9。上面的一长串表示法所表示的数值d为:
十进制,意味着数字权为10。小数点左边的数字的权是10的正幂或0次幂,乘起来之后累加会得到整数部分的值。小数点右边的数字的权是10的负幂,乘起来之后累加会得到小数部分的值。下面举一个 (12.34_{(10)}) 的运算过程作为例子。
其实二进制小数不会很难理解,跟10进制不同的是,把10的幂次改成2的幂次,仅此而已。还有就是,二进制只有0和1组成。下面举一个(101.11_{(2)})的运算过程作为例子。
对于十进制小数的一些性质,完全能够在二进制小数中发现类似的。例如,对于十进制小数来说,把小数点右移一位相当于乘以10。那么,对于二进制小数来说,把小数点往右移一位相当于乘以2。
由于(0.111111....) 非常接近于1,因此我们用 (1.0−ϵ)来表示。
很显然,任何编码长度都是有限的。十进制表示法是无法准确地表达像三分之一这样的无限循环小数。其究极原因是在于,如果把十进制小数想象成一种编码的话,那么这种编码方案的精度不够,所以无法准确地把三分之一表示出来(这个例子有点牵强,因为三分之一是无限循环的)。由此,我们可以想象,二进制数表示法只能表示那些能够被写成(x×2^y)的小数。例如,数字(frac{1}{5})可以被精确地表示成十进制小数0.2,但没办法用一个二进制小数准确地表示它,只能通过提高二进制表示位的长度来提高精度,从而近似地去表示。这是一个很重要的理念。
IEEE 754 浮点标准
在正式进入对754标准的讲解之前,我想先回到我之前在博客中提到过的一句话:
0和1这种离散值在用机器来表示时是非常方便的……虽然0和1是离散的,但大量的0和1,足够让我们认为是“连续”的。
什么叫做“大量的离散,足够让我们认为是连续的”呢?对于整数编码了解后,我们知道其实计算机能够表示的整数的范围很有限。但如果用比较长的字长,例如32位,那么对于普通人来说,他们日常需要用到的加减法是绝对够用了。但是,整数编码是表示不了特别大的数的,也不能表示特别小的负数、以及小数。例如,如果你用一个32位的int整型去存储计算阶乘!n的结果,那么计算到13的阶乘时就已经溢出了。即使你用long long去存,算到20的阶乘也就溢出了。
下面你将能够看到,浮点数不仅仅能表示小数,还具有表示特别大/特别小的数的能力。但通过二进制小数我们也了解到,不是每个数都能够被“精确”地表示,有的时候只能用另一个近似数通过“舍入”去表示,因此也增加了对于编码设计、数值分析方面的挑战性。综上所诉,我们可以这样认为:整型只能表示范围较小的数,但它能表示的每个数都是准确的。浮点数虽然能编码一个很大的范围,但这种表示只是近似的。
好了,废话不多说,下面介绍IEEE 754标准的内容。
IEEE浮点标准使用 $ V=(-1)^s×M×2^E$ 的形式来表示一个数。
-
符号位(sign),与补码类似,s为1代表这个数为负,s为0代表这个数为正
-
阶码(exponent)E的作用是对浮点数加权,权重是2的E次幂。
-
尾数(significand)M是一个二进制小数,范围是(1到2−ϵ)(规格化)或者(0到1−ϵ)(非规格化)
标准浮点格式把位划分成三个字段,分别对上面这三种值进行编码
-
开头第一位代表符号位
-
接下来是k位的字段exp,编码的是阶码,但是结果要减去偏置值。(需要重点理解)
-
在接下来n位的小数字段frac,编码尾数M,但是编码出来的值依赖于这个数是规格化数or非规格化数(后面会解释,需要重点理解)
比较常见的是32位和64位的浮点数编码。在C语言中,32位对于的就是数据类型float,称为单精度浮点数(single precision)。64位的是double,称为双精度浮点数(double precision)。下面是他们的编码格式,具体的就是k和n位数的不同,看下图。
规格化和非规格化的值
规格化的值(Normalized Values)
在 exp≠000…0 和 exp≠111…1 时(阶码部分不全为0或全为1),表示的值就叫规格化值。到底规格化是什么意思呢?对于规格化数来说,用二进制数来表示时,原本连续的值会被规范到有限的定值上。如果把规格化的数放到数轴上表示,那他们之间的距离是不同的。(后面会举例子解释,现在不懂不用太担心)
对于规格化值,阶码字段(exp这部分)的编码区域的无符号值为e,但阶码E的值是(E = e - Bias),注意这里不要把大小写e和E搞混。
Bias是指偏移量,值为$ 2^{k-1}-1 $,k是阶码字段的位数。
也就是说
-
单精度规格化数的Bias是127,e的范围是1 ~ 254,E的范围是-126 ~ 127
-
双精度规格化数的Bias是1023,e的范围是1 ~ 2046,E的范围是-1022 ~ 1023
之所以需要采用一个偏移量,是为了保证 exp 编码只需要以无符号数来处理。
还记得吗?阶码值E,是用来做浮点数的权。可以看到,这个权在float最大可以达到127,最小可以达到-126,double的范围就更大了,不知道比float高到哪里去了。想想看,用float可以表示的最大的数是表示为某个小数乘以2的127次方,这就是为什么浮点数可以表示很大的数与很小的数的原因,相比之下,int能存下的最大的数也就10的9次方啊。
对于小数字段frac,它就是描述小数值f,0≤f≤1。而尾数M定于为 M = 1 + f。你也可以这样理解:M一定是一个以1开头的小数,形如 M=1.xxxxx.xx,那些 xxx 就是 frac 的编码部分。当 frac 全为0时 M 最小(M=1.0),当 frac 全为1时 M 最大(M=2-ϵ)。之所以采取这种方式,是因为如果阶码没有溢出,我们总是可以调整阶码(想象一下,也就是移动小数点的位置)来把尾数控制在 1≤M<2 之间。这种方式就是轻松获得一个额外精度位的技巧。也可以理解为:开头的1是白送的,不需要额外的编码位。
举个例子:
int x = 12345;
printf("%f",(float)x);
在强制类型转换时,到底发生了什么呢?
首先,用二进制来表示十进制的12345:
将其小数点左移13位,创造一个规格化的形式:
那么,构造float型的32位浮点数编码:
-
正数,所以符号位为0
-
阶码的值E为13,E = e - Bias, e = E + Bias = E + 127 = 140。所以阶码字段就是140的八位二进制表示,即10001100,这就是exp字段。
-
将上述规格化形式的小数部分,后续添0添到23位,也就是添10个0,构造frac字段,即10000001110010000000000
将其组合在一起,就形成了32位单精度浮点数12345的编码:
0 10001100 10000001110010000000000
s exp frac
非规格化的值(Denormalized Values)
当阶码域exp全为0时,所表示的数就是非规格化形式。这里的意思是,原本用二进制表示的连续值,它们之间的间距是一样的。
在这种情况下有两点不同。对于阶码字段,阶码值E改成了E = 1 - Bias。也就是说
-
单精度非规格化数的E是-126
-
双精度非规格化数的E是-1022
对于尾数字段,尾数的值是 M = f。也就是说,M不再是一个以1开头的小数,而是以0开头的小数。这所以这样设置是有原因的。首先,非规格化数定义了一种表示零的方法,如果使用规格化数,由于总有 M ≥ 1,那么我们就不能表示0。实际上,零的表示就是exp和frac字段全为0,因此,对于浮点数来说,还有正零和负零的区别。非规格化数的另一个用途就是表示非常接近零的数。这种机制实现了由最大非规格化数到最小规格化数的平滑转换。
特殊值
第一类是当exp全为1,frac全为0时,表示的值为无穷。当符号位 s = 1 代表负无穷,s = 0 代表正无穷。当小数域不是0,那就代表NaN(Not a Number),不是一个数,表示出错。例如计算-1开根号的值就是NaN。
实例分析
我们采取一位符号位,4位exp位,3位frac位,bias = 2^3-1 = 7的编码方式。
s exp frac E 十进制值
------------------------------------------------------------------
0 0000 000 -6 0 # 这部分是非规范化数值,下一部分是规范化值
0 0000 001 -6 1/8 * 1/64 = 1/512 # 能表示的最接近零的值
0 0000 010 -6 2/8 * 1/64 = 2/512
...
0 0000 110 -6 6/8 * 1/64 = 6/512
0 0000 111 -6 7/8 * 1/64 = 7/512 # 能表示的最大非规范化值
------------------------------------------------------------------
0 0001 000 -6 8/8 * 1/64 = 8/512 # 能表示的最小规范化值
0 0001 001 -6 9/8 * 1/64 = 9/512
...
0 0110 110 -1 14/8 * 1/2 = 14/16
0 0110 111 -1 15/8 * 1/2 = 15/16 # 最接近且小于 1 的值
0 0111 000 0 8/8 * 1 = 1
0 0111 001 0 9/8 * 1 = 9/8 # 最接近且大于 1 的值
0 0111 010 0 10/8 * 1 = 10/8
...
0 1110 110 7 14/8 * 128 = 224
0 1110 111 7 15/8 * 128 = 240 # 能表示的最大规范化值
------------------------------------------------------------------
0 1111 000 n/a 无穷 # 特殊值
观察表格,我们更好地可以理解之前的几个细节:
-
连续非规格化值之间的间距是一样的,上面这个例子中,都是差了1/512
-
连续的规格化值之间的间距不同。这是由于exp的不同而导致的。比方说最接近1的数字是15/16和9/8,分别相差1/16和1/8。
-
“浮点数能编码一个很大的范围,但这种表示只是近似的。”我们能想象,如果frac位的长度越长,那么对于小数表示的精度就能越高。如果exp位的长度越长,那么能够表示的范围就越大。
上面是一个数轴,表示的是这种编码方式下的能表示的数在数轴上排列的情况。可以看到,浮点数表示的范围是很大的,也能够表示特别大和特别小的数。但是数的分布不是均匀的——在数轴两端,数字比较稀疏;在越靠近原点的地方,数字越稠密。在最靠近原点的地方,是非规格化数。
舍入(rounding)
因为表示方法限定了浮点数不是精确的,因此浮点运算只能近似地表示实数运算。因此,对于某个运算结果x,我们需要想出一个系统的方法,找到一个能够用浮点编码表示的“最接近”的匹配值x1,用x1来表示运算结果。这就是舍入的任务。
浮点数采取的规则是舍入到偶数,即:将数字向上或向下舍入,使得结果的最低有效数字是偶数。舍入到偶数我认为可以这么理解:四舍六入,五到偶。
例如,我们要把一个一位小数舍入到整数位,那么1.4会舍入成1,1.6会舍入成2。如果是按照四舍五入的话,2.5和1.5会舍入成3和2。如果是按照舍入到偶数,那么2.5和1.5都是舍入成2的。
为什么倾向于采取这种方法?我们很容易想到这样的情景:用某种方法舍入一组数值,必然会在用这些数值做某种计算的时候产生误差,误差的类型依照舍入方法而不同。例如,如果都采取向上舍入,那么一组数据经舍入后,求这组数据的平均值,那么平均值肯定是偏大的。之所以采取舍入到偶数,可以认为舍入到偶数是一种比四舍五入更加“公平”的舍入方法,我们把中间值5,一半向上舍入,一半向下舍入,那么就在大多数的现实情况中避免了计算的统计偏差。
浮点运算
浮点加法
结果:(这里假设 E1 ≥ E2)
其中
-
如果两个浮点数的阶码不一样,那么首先要对阶。因为假设了E1 > E2,所以可以把第二个数的小数点往左移动,然后增加E2的值。
-
尾数相加得结果M。
-
规格化,把M通过左移or右移小数点来表示为1.xxx的形式,并更新E的值。
-
如果不能完成规格化的操作,那么就是非规格化数。
-
如果超过了可以表示的范围,那么就是溢出。
-
最后把M舍入到frac的精度。
浮点乘法
结果:
其中
仍然需要进行规格化操作、也需要舍入、也有可能溢出。
浮点运算性质
对于浮点值x和y,如果对它们进行某种运算,那么计算的结果实际上是将精确的计算结果舍入后的结果。因此,浮点运算有以下几条重要的性质:
- 满足交换率,但不满足结合律
例如表达式(3.14 + 1e10) - 1e10的结果会是0.0——因为舍入的缘故,3.14就丢失了。然而,表达式3.14 + (1e10 - 1e10)会得到正确结果3.14
- 浮点加法满足单调性原则。
如果 a ≥ b ,那么对于任何的x值,除了产生NaN,都有x + a ≥ x + b。
- 浮点加法不具有可结合性。
(1e20 × 1e20)× 1e-20的值为正无穷,然而 1e20 × (1e20 × 1e-20)的值为1e20。
- 不具备分配性。
1e20 ×(1e20-1e20) 的值为0.0,然而1e20 × 1e20 - 1e20 × 1e20会得到NaN。
int、float、double间的强制类型转换
在没有了解编码方面的细节前,我们对这个问题只能说知其然而不知其所以然。
-
int转换成float,数字不会溢出,但是可能被舍入。
-
int或float转换成double,因为double有比这两者都来得更大的范围,和更高的精度,所以能够保留精确的数值。
-
double转成float,范围会变小,有可能得到值正负无穷。由于精度也变小了,所以可能被舍入。
-
float或double转成int,值将会像零舍入。例如:1.999会变成1,-1.999会变成-1。值有可能溢出。C语言标准没有对这种情况制定固定的结果。
对于浮点数的一些总结,以及特殊的浮点数
第一章小结
好了,我们也看完了浮点数的全部内容,也就了解了信息在计算机内的存储方式这一章的全部内容。
我们从最基本的元素——bit开始,了解了整型和浮点数两个非常重要的数据类型的编码方式。只有了解了位的知识、以及编码方式,我们才可能对这些数据类型涉及的其他方方面面的概念,例如:掩码运算、强制类型转换、扩展、截断、舍入、溢出等等。如果不了解位,那么对于上述这些概念只能是知其然而不知其所以然(好吧,原谅我的词穷,这个词我好像在本篇博客中用了好几遍。。但我觉得知道深层的原理是很有帮助的)。
这些内容很重要,但我觉得比较好的学习方法是举例子来理解。这本书每个小节后面配有很好的题目,可以帮助大家理解这些知识点。