An old dog learns code

IEEE-754标准与JS中的number类型

October 01, 2018


引子: 0.1+0.2===0.3 ?

用一道有点无聊的面试题作为开篇:

0.1+0.2==0.3的结果是什么?
当然是false

为什么?简言之,因为JS的number类型是双精度浮点数,浮点数计算有误差。

console.log(0.1+0.2==0.3) // false
//控制台输入,结果如下
0.1+0.2 //0.30000000000000004

至于具体原因就是本文的主题了,下文会依据浮点数的原理解释上述现象。


Number类型的二进制表示

跟其他强类型语言不同(比如C++,C#,Java等),作为动态语言的JS没有Int这样的整数类型。所有的数字,不管小数还是整数,只有一种类型:number。JS中的number类型是双精度浮点数,即用64位二进制数按照一定规则来存储及表示数字。浮点数的通行标准是IEEE-754,基本思想是使用科学记数法,利用±f×2n\pm f \times 2^n这样的形式来表示数字。

二进制小数与二进制科学记数法

十进制的小数和科学记数法很好理解,但是二进制没那么直观。我们先看个简单的例子。

例1 用二进制小数和科学记数法表示32.125

对于十进制来说 32.125=30+2+0.1+0.02+0.005\ 32.125=30+2+0.1+0.02+0.005, 可以拆分为以下几个部分

101 100 . 10-1 10-2 10-3
3 2 . 1 2 5

所以 32.125=3×101+2×100+1×101+2×102+5×103=3.2125×101\ 32.125=3 \times 10^1+2 \times 10^0+1 \times 10^{-1}+2 \times 10^{-2}+5 \times 10^{-3}=3.2125 \times 10^{1}

类似的,对于二进制来说,32.125=32+0.12532.125=32+0.125,可以拆分如下

25 24 23 22 21 20 . 2-1 2-2 2-3
1 0 0 0 0 0 . 0 0 1

因此 32.125=1×25+0×24+0×23+0×22+0×21+0×20+0×21+0×22+1×23=100000.0012=1.000000012×25\ 32.125=1 \times 2^{5}+0 \times 2^{4}+0 \times 2^{3}+0 \times 2^{2}+0 \times 2^{1}+0 \times 2^{0}+0 \times 2^{-1}+0 \times 2^{-2}+1 \times 2^{-3}=100000.001_{2}=1.00000001_{2} \times 2^{5}

先记住这样的形式,接下来浮点数内容中会用到。


IEEE-754双精度浮点数

IEEE-754标准用64位二进制数,以二进制科学记数法的形式表示双精度浮点数,主要分为符号、指数、有效数字三部分。根据标准,64位浮点数的存储结构如下:

存储结构

符号位sign 指数exponent 有效数fraction
63bit
(1 bit)
62bit...52bit
(11 bits)
51bit...0bit
(52 bits)
0=>正数
1=>负数
按照11bits无符号数处理
实际值=exponent-1023
XXXXX视为二进制小数0.XXXXX2
实际值=fraction+1

基本计算公式是  N=(1)sign×2exponent1023×(1+fraction)\ N = (-1)^{sign} \times 2^{exponent-1023} \times (1+fraction)


计算方法

  1. 符号位

    • sign=0的时候为正数,sign=1的时候为负数,因此定义最终的符号计算方式为 (1)sign\ (-1)^{sign}
  2. 指数部分

    • 11位二进制数以无符号数处理,可以表示总共211,即2048个数,可表示区间为[0, 2047]
    • 通过减去1023的偏移量,转换到区间[-1022,1023],原区间0和2047作为特殊情况保留
  3. 有效数部分

    • 按照标准规范之后,所有的数都默认第一位为1,因此存储时予以省略,仅存储小数点后的二进制部分

例2 写出32.125对应的IEEE浮点数
例1中得到了 32.125=1.000000012×2532.125=1.00000001_2 \times 2^{5}

下面根据浮点数的计算公式,计算对应的二进制表示

  • 符号位:32.125显然为正数,因此符号位是0
  • 指数部分:指数为5,根据公式,exponent=5+1023=1028=1024+4exponent = 5+1023=1028=1024+4,因此指数部分11位二进制数为100 0000 0100,如下:

    10bit 9bit 8bit 7bit 6bit 5bit 4bit 3bit 2bit 1bit 0bit
    210=1024 29=512 28=256 27=128 26=64 25=32 24=16 23=8 22=4 21=2 20=1
    1 0 0 0 0 0 0 0 1 0 0
  • 小数部分:因为标准只存储小数部分,因此 fraction=1.000000011=0.000000012\ fraction=1.00000001-1=0.00000001_2,如下:
51bit 50bit 49bit 48bit 47bit 46bit 45bit 44bit 43bit 42bit ... 0bit
0 0 0 0 0 0 0 1 0 0 ... 0

最终的二进制表示为

符号位sign 指数exponent 有效数fraction
0 10000000100 (1).0000000100000000000000000000000000000000000000000000

特殊情况

特殊情况有3种,分别是0,正负无穷大和NaN:

  • 0

    • 符号位为0
    • 指数部分全为0,即exponent为0
    • 小数部分为0
  • 无穷大

    • 符号位任意
    • 指数部分全为1,即exponent为2047
    • 小数部分为0
  • NaN

    • 符号位任意
    • 指数部分全为1,即exponent为2047
    • 小数部分非0



浮点数的误差

双精度浮点数只有64位,如果一个十进制数无法被64位二进制数完整表示,那么就会产生误差。首先十进制数需要转换为二进制数,再将二进制数转换为符合IEEE-754标准的形式。这个过程有可能带来误差。

十进制转浮点数

十进制转换时,按照整数部分+小数部分分别计算的原则。整数部分使用除2取余法,小数部分使用乘2取整法。

例3 32.125转换为二进制数
先处理整数部分32

被除数 余数
32 16 0
16 8 0
8 4 0
4 2 0
2 1 0
1 0 1

从下往上取余数,于是得到10 0000

再处理小数部分

被乘数 整数
0.125 0.25 0
0.25 0.5 0
0.5 1 1

从上往下取整数,得到001

所以最后得到 32.125=100000.0012\ 32.125=100000.001_2

例4 将0.1转换为IEEE浮点数
按照例3中的小数转换为二进制的方法,计算如下:

被乘数 整数
0.1 0.2 0
0.2 0.4 0
0.4 0.8 0
0.8 1.6 1
0.6 1.2 1
0.2 0.4 0
0.4 0.8 0
0.8 1.6 1
0.6 1.2 1
0.2 0.4 0

可以看到出现了循环, 0.110=0.000112=1.100112×24\ 0.1_{10} = 0.0\overline{0011}_2=1.1 \overline{0011}_2 \times 2^{-4}

无限循环的0.1无法被有限64位完整表示,必须截断后才能保存,这个过程必然会带来误差。先按照例2的方法,可将其转换为浮点数,如下:

符号位sign 指数exponent 有效数fraction 无法表示部分
0 01111111011‬ (1).1001100110011001100110011001100110011001100110011001 10011...0011...

舍入

IEEE-754规范默认采用的舍入规则是“向最接近的值舍入”,即数字向上或向下舍入,使得舍入后的值最接近原值,如果二者精确度相同,选择偶数的结果。在二进制中,最后一位为0是偶数,1是奇数。

例4-续

0.1的二进制小数是无限循环的,转换为浮点数必然存在舍入操作

符号位sign 指数exponent 有效数fraction 无法表示部分
0 01111111011‬ (1).1001100110011001100110011001100110011001100110011001 10011...0011...

为了便于描述,取fraction后2位视为整数,取无法表示部分前4位作为小数,合并记为01.1001,如上表中黑体部分。根据舍入规则,fraction最后2位有效数字为01,如果向上舍入变为10,向下舍入变为00,如何选择呢?

01.1001的整数部分01,正好是向上舍入后值10的一半,再加上小数部分大于10的一半,因此向上舍入为10的误差小于向下舍入为00的误差,所以这里选择向上舍入。

因此,0.110以二进制形式保存的值实际上比自身要大一点点,具体表示如下:

符号位sign 指数exponent 有效数fraction
0 01111111011‬ (1).1001100110011001100110011001100110011001100110011010

例5 将0.2转换为IEEE浮点数
与例4类似,转换过程如下

被乘数 整数
0.2 0.4 0
0.4 0.8 0
0.8 1.6 1
0.6 1.2 1
0.2 0.4 0
0.4 0.8 0
0.8 1.6 1
0.6 1.2 1
0.2 0.4 0
0.4 0.8 0

可以看到也出现了循环,因此 0.210=0.00112=1.100112×23\ 0.2_{10} = 0.\overline{0011}_2=1.1\overline{0011}_2 \times 2^{-3}

转换为浮点数如下:

符号位sign 指数exponent 有效数fraction 无法表示部分
0 01111111100 (1).1001100110011001100110011001100110011001100110011001 10011...0011...

按照舍入规则,最终二进制表示为

符号位sign 指数exponent 有效数fraction
0 01111111100‬ (1).1001100110011001100110011001100110011001100110011010

可以看到,它比真实的0.2也大了一点点。



0.1+0.2

按照规范,两个浮点数计算时有一个步骤叫做对阶,就是让2个操作数的小数点对齐,把较小的指数转化为较大的指数,然后再按照符号进行小数部分的计算。以0.1+0.2为例:

0.110 =1.100112×2-4
0.210 = 1.100112×2-3

计算时,要将0.1变为0.2的同阶

0.110 =0.1100112×2-3
0.210 = 1.100112×2-3

反映到二进制上就是对0.1的二进制码进行右移。因为两数的指数仅相差1,因此只需要右移1位:

0.1 符号位sign 指数exponent 有效数fraction
移位前 0 01111111011 (1).1001100110011001100110011001100110011001100110011010
移位后 0 01111111100 (0).1100110011001100110011001100110011001100110011001101(0)
0.2 符号位sign 指数exponent 有效数fraction
不移位 0 01111111100‬ (1).1001100110011001100110011001100110011001100110011010

相加时,指数不变,有效数部分(包括隐含的1)进行相加,变成了

0.1100110011001100110011001100110011001100110011001101+1.1001100110011001100110011001100110011001100110011010=10.01100110011001100110011001100110011001100110011001110.1100110011001100110011001100110011001100110011001101\\+1.1001100110011001100110011001100110011001100110011010\\ =10.0110011001100110011001100110011001100110011001100111

因为规范要求有效数的第一位为1,所以需要将和的小数点左移1位,同时指数部分加1。移位后有效数小数部分发生舍入。
舍入时,最后四位是011(1),向上舍入变成100,向下舍入变为011。原值恰好位于二者中间,无论舍入方案如何,误差都一样。在“向最接近的值舍入”失效的情况下,采用舍入后是偶数的表示形式,即向上舍入变为100。

0.1+0.2 符号位sign 指数exponent 有效数fraction
移位前 0 01111111100 (10).0110011001100110011001100110011001100110011001100111
移位后 0 01111111101 (1).0011001100110011001100110011001100110011001100110011(1)
舍入后 0 01111111101 (1).0011001100110011001100110011001100110011001100110100

再将二进制码转换为十进制数,于是得到
0.1+0.2 = (-1)0×2(1021-1023)×(1+1×2-3+1×2-4+1×2-7+1×2-8+1×2-11+1×2-12+1×2-15+1×2-16+1×2-19+1×2-20+1×2-23+1×2-24+1×2-27+1×2-28+1×2-31+1×2-32+1×2-35+1×2-36+1×2-39+1×2-40+1×2-43+1×2-44+1×2-47+1×2-48+1×2-50)=0.3000000000000000444089209850062616169452667236328125 ≈ 0.30000000000000004

题外话

  • 为什么按照符号位->指数->有效数这样的顺序进行排列二进制位?

    • 这样可以提高比较的速度,尽量实现一次比较得出结果:
    • 首先比较符号位,如果正负不同,谁大谁小一目了然;
    • 如果符号相同,那么比较指数,指数大的更大;
    • 如果指数也相同,那么最后再比较有效数的部分;
    • 所以才将符号位放在的最高权重位MSB上,而指数和有效数依次放在后面。
  • 很显然指数可正可负,这样可以分别表示大于1或小于1的数;
  • 如果用补码表示,比较的时候又需要先比较符号位再比较数据位,规则太复杂。因此直接把11bits的指数部分当作了无符号数进行计算比较,再通过一个偏移把它映射到一个包含正负的对称区间内;
  • 11位的二进制数可以表示211,即2048个数,所以11位无符号数可以表示区间[0, 2047],通过减去偏移1024或1023,可以分别映射为2个对称区间:[-1024, 1023]或[-1023,1024]。
  • 标准中最终选择了1023作为偏移,原区间内0和2047作为特殊情况保留,得到转换后的有效指数区间[-1022,1023]。至于标准为什么要映射到对称区间,同时偏移为什么选择1023

    The reason for having |emin| < emax is so that the reciprocal of the smallest number will not overflow. Although it is true that the reciprocal of the largest number will underflow, underflow is usually less serious than overflow.

  • 因为浮点数可以有多种表示方式,比如1.12×25,也可以写作112×24,又或者0.112×26(因为小数点的位置可变,所以这种类型被称为浮点数)。为了规范表示,避免出现同一个数出现多种表示方法的情况,标准规定采用前者的形式,即第一位始终为1
  • 这样隐性增加了1位,提高了精度

参考文献

  1. https://floating-point-gui.de/formats/fp/
  2. https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html
  3. https://www.cnblogs.com/bossin/archive/2007/04/08/704567.html
  4. https://www.eecs.wsu.edu/~jdelgado/EE334/chp3b.pdf
  5. https://en.wikipedia.org/wiki/Exponent_bias
  6. http://blog.reverberate.org/2014/09/what-every-computer-programmer-should.html
  7. http://sandbox.mc.edu/~bennet/cs110/flt/dtof.html
  8. https://randomascii.wordpress.com/2012/02/25/comparing-floating-point-numbers-2012-edition/
  9. http://www.oxfordmathcenter.com/drupal7/node/43
  10. https://blog.angularindepth.com/the-mechanics-behind-exponent-bias-in-floating-point-9b3185083528