浮点数的原理简析

1. 存储格式

  我平时在写浮点数相关的代码时,经常会用一些可笑的技巧来“规避”浮点数运算导致的随机误差,浮点数运算细节晦涩难懂,我确实一直对浮点数原理及其运算一知半解,写起代码来战战兢兢,本文参考刘纯根所著的《浮点计算——编程原理、实现与应用》,把浮点数的原理简单梳理一遍(主要以float为例),作为笔记,以备查验。

  IEEE754标准规定,浮点数由“符号”、“指数”和“尾数”3部分构成:

在这里插入图片描述
  下表列出C++中不同精度浮点数内存布局:

精度 C++类型 长度 符号位数 指数位数 尾数位数 有效位数 指数偏移 隐含位
单精度浮点数 float 32 1 8 23 24 127 1个隐含位
双精度浮点数 double 64 1 11 52 53 1023 1个隐含位
扩展双精度浮点数 long double 80 1 15 64 64 16383 无隐含位

  float的规格化表示为: ± 1. f × 2 E − 127 ±1.f \times 2 ^ {E - 127} ±1.f×2E127,其中, f f f是尾数, E E E是指数。以float为例:

在这里插入图片描述
  比如十进制数123.125,其二进制表示为:1111011.001,规格化表示为: 1.111011001 × 2 6 1.111011001\times2^6 1.111011001×26 也就是 1.111011001 × 2 133 − 127 1.111011001\times2^{133 - 127} 1.111011001×2133127 f f f= 111011001, E E E = 133,二进制为10000101,图示如下:

在这里插入图片描述
  规格化表示的浮点数,整数位固定为1,可以省略,所以用23位可以存储24位的尾数。这里可以得出一个结论:任意一个int值(二进制表示),只要存在这样的序列:从最低位开始找到第一个1,然后从这个1向高位数移动24位停下,如果更高的位上不再有1,那么该int值即可被float精确表示,否则就不行。简单说,就是第一个1开始到最后一个1为止的总位数超过24,那么该int值就不能被float类型精确表示,例:

浮点数的原理简析_第1张图片
图中能被丢弃的0,在尾数上体现出来,丢弃一个0,尾数的1就前移1位,并没有损失精度。

  很容易得出,从1开始的连续整数里面第一个不能被float精确表示的整数,其二进制形式为:1000000000000000000000001,即16777217: 1.000000000000000000000001 × 2 24 1.000000000000000000000001\times2^{24} 1.000000000000000000000001×224 f f f有24位,最后一个1只能舍弃,也就是 1.00000000000000000000000 × 2 24 1.00000000000000000000000\times2^{24} 1.00000000000000000000000×224,即 1.0 × 2 24 1.0\times2^{24} 1.0×224,这个数实际上是16777216。也就是说16777217和16777216的内存表示是一样的:

在这里插入图片描述
  那么16777217之后的下一个可以被float精确表示的int值是多少呢?很简单,向16777217上不断的加1,直到满足“第一个1开始到最后一个1为止的总位数为24位”:

浮点数的原理简析_第2张图片
1000000000000000000000010就是16777218,规格化表示为: 1.00000000000000000000001 × 2 24 = 1.00000000000000000000001 × 2 151 − 127 1.00000000000000000000001\times2^{24} =1.00000000000000000000001\times2^{151 - 127} 1.00000000000000000000001×224=1.00000000000000000000001×2151127

f f f 是23位(最后一位是1)。

2. 原理

  二进制的浮点数,其科学记数法的形式为:

± X n X n − 1 … X 0 . X − 1 X − 2 … X − m = ± X n . X n − 1 … X 0 X − 1 X − 2 … X − m × 2 n ±X_nX_{n-1}\dots X_0.X_{-1}X_{-2}\dots X_{-m}=±X_n . X_{n-1}\dots X_0X_{-1}X_{-2}\dots X_{-m}\times2^n ±XnXn1X0.X1X2Xm=±Xn.Xn1X0X1X2Xm×2n

其中 X i X_i Xi只能是0或1。规格化表示为:

± X n X n − 1 … X 0 . X − 1 X − 2 … X − m = ± 1. X n − 1 … X 0 X − 1 X − 2 … X − m × 2 n ±X_nX_{n-1}\dots X_0.X_{-1}X_{-2}\dots X_{-m}=±1 . X_{n-1}\dots X_0X_{-1}X_{-2}\dots X_{-m}\times2^n ±XnXn1X0.X1X2Xm=±1.Xn1X0X1X2Xm×2n

即约定小数点位于最高位的1之后,因此 X n X_n Xn不能是0。既然整数位只能是1,那么这一位可以不用存储,称之为隐含位,也就是说大家心里明白就行。这样做的好处是可以多存储一位小数部分,但是在作浮点运算时需要特殊处理,运算之前要补齐这一位,运算之后又得略去这一位,会有一点点性能损耗;如果有效位本来就足够多,省去整数位也赚不了多少便宜,这可能是扩展双精度浮点数不采用这种方案的原因

  指数n是一个普通的整数,可正可负(负指数表示纯小数),在计算机科学,整数编码一般采用补码(负数是其正数的反码+1),最高位是符号位(0表示正数,1表示负数),但IEEE754标准中,浮点数的指数使用了“真值 + 偏移值”的编码方式,将原码空间分成两部分,小的部分表示负数,大的部分表示正数。一般的,全0和全1都有特殊用途,以float为例,其指数部分有8位,原码空间就是0 ~ 255,不算0和255(全0和全1),剩下254个数,对半分即127,则N位指数的编码和原值有下列关系:

E = e + 2 N − 1 − 1 E=e+2^{N-1}-1 E=e+2N11

其中, E E E:指数编码; e e e:指数真值 。float类型的指数编码就是: E = e + 127 E=e+127 E=e+127,[ 1,127 ) 是负数, [ 127, 254 ] 是正数,0和255有特殊用途。

3. 分类

  IEEE标准定义了6类浮点数:

指数 分类 隐含位 尾数 说明
xx…xx 有限数 1 xx…xx 指数非全0,非全1,有隐含位
00…00 0 0 0 尾数为0
00…00 弱规范数 xx…xx 尾数不为0
11…11 ±∞ 1 0 尾数为0
11…11 QNaN 1 1xx…xx 尾数不为0,尾数高位为1
11…11 SNaN 1 0xx…xx 尾数不为0,尾数高位为0

3.1. 有限数

  有限数就是遵循规格化的常规数,其指数在最小数和最大数之间,且整数位恒为1,其形式为:

± 1. f × 2 E − o f f s e t ±1.f×2^{E−offset} ±1.f×2Eoffset

例如float,其指数 E E E有8位,取值范围为 ( 0, 255 ) 也就是 [ 1, 254 ] 。有限数在运算过程中最常见的问题就是溢出,即运算结果无法用有限数表示。

3.2. 零

  0,有一位隐含位(为0),除符号位可能不为0外,其它所有位都为0,也就是说存在正0和负0,其形式为:

± ( 0.0 ) × 2 0 − o f f s e t ±(0.0)×2^{0-offset} ±(0.0)×20offset

数学上,0没有正负之说,但按IEEE754标准,却有正负0之分(由符号位标识),注意:正0应该和负0相等,而不应正0大于负0。

3.3. 弱规范数

  弱规范数的指数和0一样,都是全0,但没有隐含位,尾数部分不为0,其形式为:

± ( f ) × 2 0 − o f f s e t ±(f)×2^{0-offset} ±(f)×20offset

  弱规范数的整数位是尾数的最高位。由于弱规范数没有隐含位,在向有限数转换时,要向高位移一位,以产生隐含位,但指数不变(不减1);从有限数形式转换成弱规范数形式时,正好相反,向低位移1位,指数仍不变。
  在计算过程中,如果中间结果小于最小的有限数却不是0,即出现下溢,如果当做0处理,可能会导致计算终止,引入“弱规范数”之后,在0和最小的有限数之间有相当一部分数可以表示为“弱规范数”,从而提高了计算能力。

3.4. 无穷大

   ± ∞ ±∞ ±,指数部分为全1(即最大值),整数位是1(是隐含位),尾数是0,其形式为:

± ( 1.0 ) × 2 M A X − o f f s e t ±(1.0)×2^{MAX-offset} ±(1.0)×2MAXoffset

float类型即: ± ( 1.0 ) × 2 255 − o f f s e t ±(1.0)×2^{255-offset} ±(1.0)×2255offset,产生 ∞ ∞ 的情形一般有:
  ● 自身运算,例如 ∞ + 1.0 = ∞ ∞+1.0=∞ +1.0=
  ● 被0除,例如 1 / 0 = ∞ 1/0=∞ 1/0= 1 / − 0 = − ∞ 1/{-0}=-∞ 1/0=
  ● 上溢,即计算结果超出了类型范围

3.5. NaN

  NaN,即Not a Number,和 ∞ ∞ 一样,指数部分为全1(最大值),整数位是1(是隐含位),但尾数部分不为0,其形式为:

± ( 1. f ) × 2 M A X − o f f s e t ±(1.f)×2^{MAX-offset} ±(1.f)×2MAXoffset

  其中, f f f 不为0。NaN有SNaN(Signal NaN)和QNaN(Quiet NaN)之分,IEEE标准要求:SNaN参与运算要触发异常,而QNaN则不触发异常。两者的区别在于,SNaN的尾数最高位是0,而QNaN的尾数最高位是1。
  IEEE标准只规定NaN的尾数不为0,并没有限定应该是什么,这就给予具体实现一定的空间。比如可以在计算出问题时,在尾数部分设置一些特殊的值,有利于调试。
  NaN有一些晦涩的运算规则:
  ●  0 × ∞ = N a N 0×∞=NaN 0×=NaN,因此“0乘任何数都是0”不是恒成立的
  ● NaN参与的所有逻辑运算,只有 N a N ! = N a N NaN!=NaN NaN!=NaN为真,其它的都不为真,即使 N a N = = N a N NaN==NaN NaN==NaN的结果也为
    false,因此在浮点数的逻辑运算中,编译器没有办法做积极的优化,因为如果有NaN参与逻辑运算,比如
     x = N a N x=NaN x=NaN,那么 ! ( x < y ) !(x < y) !(x<y) x > = y x>=y x>=y就不等价了。
令人无语的是,编译器不一定遵循IEEE标准的规定,可想而知,浮点运算有多难搞。

4. 特殊的数

4.1. 最小的正float有限数

  根据有限数的规格化形式,指数取最小值1,隐含位是1,尾数取最小值0(23位都是0):

1.0 × 2 1 − 127 = 1.0 × 2 − 126 = 1.1754943508222875079687365372222 e − 38 1.0×2^{1−127}=1.0×2^{-126}=1.1754943508222875079687365372222e^{-38} 1.0×21127=1.0×2126=1.1754943508222875079687365372222e38

如果浮点运算的结果小于这个数,就出现下溢,一般将其结果转换为最小的有限数,或者弱规范数,如果弱规范数也不能表示,那么将转换成0

4.2. 最大的float有限数

  根据有限数的规格化形式,指数取最大值254,隐含位是1,尾数取最大值(23位都是1):
1.11111111111111111111111 × 2 254 − 127 = ( 2 − 2 − 23 ) × 2 127 = 2 128 − 2 104 = 3.4028234663852885981170418348452 e 38 1.11111111111111111111111×2^{254−127}\\=(2-2^{-23})×2^{127}=2^{128}-2^{104}\\=3.4028234663852885981170418348452e^{38} 1.11111111111111111111111×2254127=(2223)×2127=21282104=3.4028234663852885981170418348452e38
如果浮点运算的结果超过这个数,就出现上溢,一般将其结果转换为最近的有限数或 ∞ ∞

4.3. 最小的正float弱规范数

  根据弱规范数的规格化形式,尾数取最小值:

0.0000000000000000000001 × 2 0 − 127 = 2 − 22 × 2 − 127 = 2 − 149 = 1.4012984643248170709237295832899 e − 45 0.0000000000000000000001×2^{0−127}\\=2^{-22}×2^{-127}=2^{-149}=1.4012984643248170709237295832899e^{-45} 0.0000000000000000000001×20127=222×2127=2149=1.4012984643248170709237295832899e45

f f f 是23位,且最高位是整数位,因此小数点之后只有22位,最后一位为1,即可得出该数。

4.4. FLT_EPSILON

  FLT_EPSILON是C++定义的一个float数,该数是满足 1.0 + F L T _ E P S I L O N ! = 1.0 1.0 + FLT\_EPSILON != 1.0 1.0+FLT_EPSILON!=1.0的最小的float有限数,比该数还小的float有限数会有: 1.0 + x = 1.0 1.0 + x = 1.0 1.0+x=1.0。根据这个定义,从比1.0大的最小float有限数开始推导:

1.00000000000000000000001 × 2 127 − 127 = 1.00000000000000000000001 = > F L T _ E P S I L O N = 0.00000000000000000000001 = 2 − 23 = 0.00000011920928955078125 ≈ 1.192092896 e − 7 1.00000000000000000000001×2^{127−127}\\=1.00000000000000000000001\\=>FLT\_EPSILON = 0.00000000000000000000001\\=2^{-23}=0.00000011920928955078125\approx 1.192092896e^{-7} 1.00000000000000000000001×2127127=1.00000000000000000000001=>FLT_EPSILON=0.00000000000000000000001=223=0.000000119209289550781251.192092896e7

  它的规格化表示: 1.0 × 2 104 − 127 1.0×2^{104−127} 1.0×2104127 E E E = 104, f f f = 0。注意,该数远不是最小的正float有限数,它比最小的正float有限数还要“大很多”,它的指数是-23,而最小的正float有限数的指数是-126。这个数并没有什么特别的意义,在代码中无条件的使用 f a b s ( x − y ) < F L T _ E P S I L O N fabs(x-y)fabs(xy)<FLT_EPSILON判断两个浮点数是否相等,可能会导致问题。如果你的系统中浮点数很小,极端来说,甚至小于 F L T _ E P S I L O N FLT\_EPSILON FLT_EPSILON,那你用 f a b s ( x − y ) < F L T _ E P S I L O N fabs(x-y)fabs(xy)<FLT_EPSILON来判断相等,显然是错误的,你应该自己定义一个可以接受的误差范围来辅助判断相等问题。

5. 结语

  浮点运算,深奥、晦涩、难懂!我们对浮点运算的所有想当然的假设可能都是不靠谱的。正如Herb Sutter所说,世界上的人可以分3种:
  ● 一种是知道自己不懂浮点运算(我就是);
  ● 一种是以为自己懂浮点运算;
  ● 最后一种是极少的专家级人物,他们想知道自己是否有可能最终完全理解浮点运算。

你可能感兴趣的:(C++)