平时在写js代码时会用到一些简单的计算,比方说系统中我们数据库储存的金额是分,前端展示的是元,所以在用户输入元之后要转成分传给后台,这个公式小学一年级就学过了

1
1.11*100 = 111

一般来说这个计算结果是没问题的,但是在js里面却有这样的尴尬

1
1.11*100 = 111.00000000000001

结果不是我们想要的111,类似的情况还有

1
2
3
4
0.1+0.2 = 0.30000000000000004   //加法
0.27-0.11 = 0.16000000000000003 //减法
19.9*100 = 1989.9999999999998 //乘法
0.3/0.1 = 2.9999999999999996 //除法

一般遇到这种问题,我们都有成熟的解决方案解决

用着用着就习惯了,一直没有搞清楚为什么会有这样的误差。这两天正好有空,看了一些博客终于搞清楚了。

双精度浮点数

JS 的数据类型比较特别,和C、Java 等语言的的数据类型不一样,不管是 int、double、float 在 JS 里面都是Number类型。
要搞清楚为什么有这个误差,就要先介绍一下双精度浮点(double)

双精度浮点数(double)使用 64 位(8字节) 来储存一个浮点数。 它可以表示十进位制的15或16位有效数字,其可以表示的数字的绝对值范围大约是 [2.23*10^(-308),1.79*10^(308)]

这其中的64位bit又可以分为下面的格式

  • sign bit(符号位):0代表正数,1代表负数
  • exponent(指数):中间的11位用来表示次方数
  • mantissa(尾数):最后的52位用来表示精确度

上面的格式可以转换成这个这个公式

在十进制中,整数部分可以是09,在二进制中整数部分只能是01,所以可以看到上面公式对应的整数部分只能是1,这样就可以不用管整数部分直接保留后面的小数部分就可以了。指数 exponent(E) 是一个无符号整型 (unsigned int) ,那么问题就来了,我们怎么保留小数呢?按照科学计数法,如果E小于0才可以表示成小数,因为E是11位的,最大可以表示为2047,所以取一个中间值1023(十六进制为ox3FF),01022表示为负,10232047表示为正,这样就解决了小数的表示问题。

我们来看看数字 1 是怎么储存的

用上面的公式表示就是:(-1)^0 * 2^(1024-1023) * 1.0 = 1,再看一下 0.5 的储存形式

(-1)^0 * 2^(1022-1023) * 1.0 = 0.5,搞清楚这个,我们再看看上面提到的 1.11*100 = 111.00000000000001 这个问题。

将 1.11 转换成二进制是这样的1.0001110000101000111101011100001010001111010111000011...(11100001010001111010循环)(十进制小数转二进制方法),换成64位浮点来表示,S为0,E为1023,mantissa(M)为0001110000101000111101011100001010001111010111000011,因为位数只有52位,后面循环的部分就被舍弃了,转成64位浮点数是这样的

然后转成10进制的就变成了

1
1.11000000000000009769962616701

所以这里出现了问题,误差就有了,究其根本还是精度的问题。

还有一个问题?

为什么我直接输入 1.11 得到的结果是 1.11,而不是1.11000000000000009769962616701 呢?

这个还是精度问题,64位浮点的尾数是52位,因为整数部分只能是1所以可以省略一位,比方说

1
2
11.101 * 2^1001 可以格式化为 1.1101 * 2^1010,尾数部分M直接储存1101即可;
0.0011101 * 2^-1001 可以格式化为 1.1101 * 2^-1100,尾数部分M储存1101即可。

所以他可以表示的最大长度是53,即2^53 = 9007199254740992,所以双精度浮点能表示的最大精度是 16 位的,JS 会调用 toPrecision(16) 来做运算

1
1.11.toPrecision(16) = 1.110000000000000 //自动取整之后就是1.11

如果精度调整一下,结果就不一样了:

1
2
1.11.toPrecision(17) = 1.1100000000000001
1.11.toPrecision(20) = 1.1100000000000000977

到这里终于真相大白了!