程序中计算小数,0.1+0.2为何不等于0.3

参考的文章:

问题

起初问题是来自项目里,一段数字莫名奇妙得显示成了 “乱码” :

1
6.93889390390723e-18

这种问题是不是似曾相识呢?

相信你肯定知道javascript里面一个”bug”:

1
2
3
0.1 + 0.2 = 0.30000000000000004 ; // 0.1+ 0.2 > 0.3 

0.6 - 0.5 - 0.1 = -2.7755575615628914e-17;// 0.6 - 0.5 - 0.1 < 0

得出的这个数是不是就像 “乱码” ?

实际这段 “乱码” 是程序语言中的科学计数的表示方法
e作为分隔符,所以这段数字折合成十进制就是 -2.7755575615628914 * 10^-17

计算出来的结果是一个非常小的数字。
而且只要使用双精度浮点型类型计算都会出现该问题。

原因

想要回答这个问题,就得从计算机存储数字的原理说起。

计算机如何存储数字

我们都知道,计算机只能认得出0和1,所以数据在计算机中都是二进制的方式存储,最小的单位就是位(bit)。
程序中所使用的数字存储到计算机中,也都是以二进制的方式存储。

但是计算机怎么就凭0和1知道我们要存储的数字了呢?
答案是 指定存储数据的数据类型

以C#的数据类型举例:常规的 Byte,float,double,Int16,Int,Int64,UInt16…都是用来指定 “0和1” 的数据类型。

计算机如何读取数字

计算机在存储的时候指定了数据类型,在读取的时候就可以根据存储的数据类型计算出原来的数字(计算机如何识别数据类型这里不展开)。

在C#中:

1
2
3
4
5
byte num = 15;
// 在计算机存储为 (byte长度为8位)
00001111
// 这段二进制表示的数据解析出值为(按位计数法)
0*2^7 + 0*2^6 + 0*2^5 + 0*2^4 + 1*2^3 + 1*2^2 + 1*2^1 + 1*2^0 = 15

在 javascript 中 number 都是以双精度浮点型来存储的,在C#中对应的类型就是double

浮点数

浮点数。顾名思义,用来保存有小数点的数。也使用二进制存储,但是保存规则和解析规则不同(参考IEEE 754)

  • 双精度浮点数
    float1

  • 单精度浮点数
    float2

  • 半精度浮点数
    float3

它们都分成3部分,符号位,指数(阶码)和尾数。不同精度只不过是指数位和尾数位的长度不一样。

解析一个浮点数就5条规则 (下例为单精度)

  • 如果指数位全零,尾数位是全零,那就表示0
  • 如果指数位全零,尾数位是非零,就表示一个很小的数(subnormal),计算方式 (−1)^sign_bit × 2^−126 × 0.fraction_bits
  • 如果指数位全是1,尾数位是全零,表示正负无穷
  • 如果指数位全是1,尾数位是非零,表示不是一个数NAN
  • 剩下的计算方式为 (−1)^sign_bit × 2^(exponent_bits−127) × 1.fraction_bits
  1. 浮点数指数部分为了表示正数和负数,有偏移量,规则为: 指数的值 = 指数部分按位计数的值 - 偏移量。
  2. 这里的 0.fraction_bits,1.fraction_bits 都表示二进制小数(关于如何计算二进制小数不展开)。
    ⚠️ 这些都不是我制定的,都来自国际标准 IEEE 754

我们就拿上图单精度浮点数来说,保存一个单精度浮点数

1
2
3
4
5
6
// 总共 32bit
SIGN EXPONENT FRACTION
0 01111100 0100 0000 0000 0000 0000 000

// 表示的值为 :
(−1)^0 * 2^124-127 * 1.01 = 2^-3 * (1*2^0 + 0 + 1*2^-2) = 2^-3 * 1.25 = 0.15625

如何用二进制表示0.1?

上面介绍了小数在计算机中是如何存储的,这里我们直接看0.1是如何表示的:

关于十进制的转换这里就直接说结论:
十进制整数转二进制方法:除2取余;十进制小数转二进制方法:乘2除整

十进制0.1表示成二进制小数,乘2取整过程:

1
2
3
4
5
6
7
8
9
10
11
12
0.1 * 2 = 0.2 # 0
0.2 * 2 = 0.4 # 0
0.4 * 2 = 0.8 # 0
0.8 * 2 = 1.6 # 1
0.6 * 2 = 1.2 # 1
0.2 * 2 = 0.4 # 0
.....
// 转换成二进制大约是0.000110001100011...这样的数

// 转换成双精度浮点类型的计算方式为:
SIGN EXPONENT FRACTION
(-1)^0 * 2^-3 * 1.10001100011...

从上面可以看出,转换出来的二进制小数是一个无限循环的,保存到浮点数尾数也是无限的。

但是数据存储的位数有限,我们不能保存所有的尾数,这时候该怎么保存进去呢?
答案是: 在某个精度点直接舍入
当然造成的后果就是0.1并不是精确表示的,是有舍入误差的0.1。

相等运算就是比较位数,舍入误差会引起如下场景

1
2
3
0.100000000000000002 == 0.1 //true
0.100000000000000002 == 0.100000000000000010 //true
0.100000000000000002 == 0.100000000000000020 //false

可以使用工具查看转换出的二进制来分析原因

精度是在哪里丢失的?

保存数值时

由于我们的十进制转换为二进制,而二进制不能精确得表示出来,这个时候就有了舍入误差。

IEEE 754规定了几种舍入规则,但是默认的是舍入到最接近的值,如果“舍”和“入”一样接近,那么取结果为偶数的选择。

进行运算时

在浮点数参与计算时,有一个步骤叫对阶,以加法为例,要把小的指数域转化为大的指数域,也就是左移小指数浮点数的小数点。一但指数左移,必然要把尾数最右边的 “挤出去”,这个时候挤出去的部分也会发生舍入,再次发生舍入误差。

当这个误差大到编译不能忽略的时候,自然就被表示出来了
理解计算机这么做的”苦衷”,也就理解0.1+0.2不等于0.3了 😊

解决

  • 计算时转换成整数,把小数部分抵消掉,计算结果再还原位数

    1
    2
    3
    0.1 + 0.2 
    ✓ (0.1*10 + 0.2*10)/10
    // 可以封装,使用截取小数位等方式实现
  • 在javascript中可使用专门的库,如 math.js, bignumber.js

  • 使用专门为解决这类问题封装的类型,在c#中有decimal,java中BigDecimal