2

1340.640.jpg

0.前言

最近在看计算机组成原理的浮点数部分,突然想起之前看过的一道快手面试题

为什么JS中0.1+0.2不等于0.3,应该如何解决?

这里我们可以借这道题来说一下JS的精度问题

1.JS数的储存

二进制和浮点数和定点数

首先计算机里面的数据肯定以二进制形式存储
对于同一段二进制码,不同的解读方式肯定有不同的意义
对于小数,我们有定点数和浮点数两种表示方法
目前计算机大多用浮点数,精度高,表示范围大

一个数以浮点数二进制码形式储存,我们从二进制浮点数码中能算出表达的二进制,然后二进制又可以得到相应的十进制,这就是他们的转化关系

补充下浮点数相对于定点数的优缺点:
优点 : 表示范围大
缺点 : 计算效率低,有误差

复习浮点数

我们复习一下计组中对浮点数的介绍 这里以32位为例

如上图,32位二进制码中有三个部分,符号位,指数位,尾数位
浮点数计算公式:
WX20200203-105333@2x.png

从左往右看有三个部分,符号位指数位,尾数位
(1)(-1)^s表示符号位,当s=0,V为正数;当s=1,V为负数。
(2)2^E表示指数位。
(3)M表示有效数字,大于等于1,小于2。
这里有两个注意点,
1.M由于恒定为1.xxx,所以默认省略1
2.E不全为0或不全为1。这时E=E-127或者E=E-1023(64位)

bg2010060601.png
我们按上面的规则算一下为什么图中0011111000100...00表示的0.15625
首先指数位置01111100表示的是124 则E=124-127=-3
然后我们看尾数,尾数位为1.01(加上了隐藏的1)
所以v=1.01*2的-3次方=0.00101
注意这个是二进制结果,转化为十进制的就是2(-3次方)+2(-5次方)=0.125+0.125*0.25=0.15625

JS中数值

JavaScript 内部,所有数字都是以64位浮点数形式储存,即使整数也是如此。所以,11.0是相同的,是同一个数。

我们看一下64位的JS数字是怎么储存的
1位符号位 11位指数位 52位尾数位

64w.jpg

JS中1和0.1的表示方法

http://www.binaryconvert.com/... 这个网站上我们可以找到一个数的二进制浮点数表示
我们手动按上面的方法算一下,并且验证看对不对

1的表示方法

1对应的二进制1.00000
那么1对应的浮点数应该长成这样
1.0* 2的0次方
指数为0 尾数为1.0 所以指数实际是1023(1023-1023=0) 尾数是0000000000000...00
屏幕快照 2020-02-04 17.41.09.png
看来我们算的是对的

0.1的表示方法

0.1对应的二进制 0.000110011001100...(循环)
那么1对应的浮点数应该长成这样
1.10011001100....*2的-4次方
所以尾数位应该是10011001100....
指数应该是1019(1019-1023=-4)也就是01111111011
屏幕快照 2020-02-04 17.41.37.png
看来我们是对的

0.5,0.25的表示

0.5
0.5.png
0.25
0.25.png

观察

我们从上面的浮点数二进制码可以看出一个规律,
如果是整数,尾数都是0
如果是小数,如果是0.5 0.25 0.125这种的,尾数都是0
如果是0.1,0.2,0.4这种的小数,尾数都是无限循环的

2.精度产生的原因

为什么精度会产生呢

首先0.1这种转化为二进制码是有误差的,尾数是一个不断循环的数,但是我们浮点数的尾数尾数是有限的,所以需要省略一部分,这就产生了一部分误差

其次,在浮点数加法运算里面,有对阶操作。対阶会损失一部分尾数,如果尾数后面都是0(整数或者0.5之类的),没影响。但是如果是0.1转换成的二进制浮点数码的尾数,対阶的时候舍弃部分尾数明显也会造成误差。

所以可以说误差产生在对尾数的处理中

如果一个大数和一个小数相加时,会产生很大的误差,因为対阶的时候尾数得截掉好多位

(1+0.1).toPrecision(20) //"1.1000000000000000888"
(100000000000+0.1).toPrecision(20)
"100000000000.10000610"

很明显误差大了

3.关于精度的额外知识点

0.1并不是0.1

上面的内容你如果理解了,你再看到0.1,你就会清楚,0.1并不是0.1,0.1的浮点数二进制码是有误差的,不可能算出0.1
我们看到的是浏览器帮我们做了处理的

不信,你可以试试

0.1.toPrecision(17)
// "0.10000000000000001"

JS安全数

面试喜欢考这个问题,啥叫安全数,就是在这个范围数值都有一正一反,一一对应
尾数一共52位,加上一个隐藏位,共53位
也就是JS能表示的最大整数是2的53次方,这个数是16位
但是

Math.pow(2, 53) === Math.pow(2, 53) + 1 // true

实际上2的53次方都不安全了,所以是2的53次方-1
在es6中 是Number.MAX_SAFE_INTEGER

toPrecision vs toFixed

数据处理时,这两个函数很容易混淆。它们的共同点是把数字转成字符串供展示使用。注意在计算的中间过程不要使用,只用于最终结果。

不同点就需要注意一下:

  • toPrecision是处理精度,精度是从左至右第一个不为0的数开始数起。
  • toFixed是小数点后指定位数取整,从小数点开始数起。

两者都能对多余数字做凑整处理,也有些人用toFixed来做四舍五入,但一定要知道它是有 Bug 的。

如:1.005.toFixed(2)返回的是1.00而不是1.01

原因:1.005实际对应的数字是1.00499999999999989,在四舍五入时全部被舍去!

不过(1.005).toPrecision(4) ===> 1.005

怎么解决这个问题呢,自己写一套字符串逻辑去处理也可以

if (!Number.prototype._toFixed) {
    Number.prototype._toFixed = Number.prototype.toFixed;
}
Number.prototype.toFixed = function(n) {
    return (this + 3e-16)._toFixed(n);
};

//加上一个非常非常小的数,此时就正常了
1.005.toFixed(2)
"1.01"

4.解决误差方案

误差主要产生在进制转化和浮点数运算的対阶操作中
整数由于尾数后面全是0,同时转化为二进制数没有误差,所以

我们第一种方案就是全部转化为整数,计算完再转化为小数类似这种

/**
 * 精确加法
 */
function add(num1, num2) {
  const num1Digits = (num1.toString().split('.')[1] || '').length;
  const num2Digits = (num2.toString().split('.')[1] || '').length;
  const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
  return (num1 * baseNum + num2 * baseNum) / baseNum;
} 

我们第二种方案就是用现成的库 mathjs之类的,原理就是不走浮点数那一套,转化成字符串,自己实现运算逻辑,从性能上说肯定比原生慢一点

有小数运算就是有风险的 比如0.56*100 => 56.00000000000001
不过就算不精准也比这种展示好,所以小数计算的结果注意tofixed一下

5.参考资料

理解有限,如果有不对的地方,欢迎大家在评论区指出

  1. JavaScript中的数字存储
  2. 0.1 + 0.2不等于0.3?为什么JavaScript有这种“骚”操作?
  3. 抓住数据的小尾巴 - JS浮点数陷阱及解法 camsong
  4. javascript标准参考教程
  5. mathjs 官网

Runningfyy
1.3k 声望661 粉丝