参考的文章:
问题
起初问题是来自项目里,一段数字莫名奇妙得显示成了 “乱码” :1
6.93889390390723e-18
这种问题是不是似曾相识呢?
相信你肯定知道javascript里面一个”bug”:1
2
30.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
5byte 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)
双精度浮点数
单精度浮点数
半精度浮点数
它们都分成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
- 浮点数指数部分为了表示正数和负数,有偏移量,规则为: 指数的值 = 指数部分按位计数的值 - 偏移量。
- 这里的 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
120.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
30.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
等