JS中0.1 + 0.2 不等于0.3 ?

例子:

你有没有发现一个场景,在JS中对十进制数进行了一些算术计算,但它返回了一个奇怪的结果?

比如以下例子:

  • 0.1 + 0.2 期望是等于 0.3 但显示结果是 0.30000000000000004
  • 6 * 0.1期望是 0.6 但显示结果是 0.6000000000000001
  • 0.11 + 0.12 期望是 0.23 但显示结果是 0.22999999999999998
  • 0.1 + 0.7 显示结果是 0.7999999999999999
  • 0.3 + 0.6 显示结果是 0.8999999999999999。…… 还有其他一些类似的情况。

从上面可以看到,0.1+0.2!==0.3并且0.1+0.2的结果是0.30000000000000004。为什么会出现这样的结果呢?

下文就让我们一块来探索一下这背后的原因。

十进制和二进制表示的小数特点:

  • 在base10 (十进制)的系统(由人类使用)中,如果使用以10为底的质因数,则可以精确表示分数。
    • 2和5是10的质因数。
    • 1/2、1/4、1/5 (0.2)、1/8 和 1/10 (0.1) 可以精确表示,因为分母使用 10 的质因数。
    • 而 1/3、1/6 和 1/7 是重复小数,因为分母使用 3 或 7 的质因数。
  • 另一方面,在base 2 (二进制)的系统中(由计算机使用),如果使用以 2为底的素因子,则可以精确表示分数。
    • 2 是 2 的唯一质因数。
    • 所以 1/2、1/4、1/8 都可以精确表示,因为分母使用 2 的质因数。
    • 而 1/5 (0.2) 或 1/10 (0.1) 是重复小数。

我们在计算数学问题的时候,使用的是十进制,计算0.1 + 0.2的结果等于0.3,没有任何问题。但在计算机中,存储数据使用的是二进制,数据由0和1组成。所以在对数据进行计算时,需要将数据全部转换成二进制,再进行数据计算。

进制转换:

十进制转二进制的主要原则如下所示:

  • 十进制整数转换为二进制整数:除2取余,逆序排列
  • 十进制小数转换为二进制小数:乘2取整,顺序排列

这里主要介绍小数转换。十进制小数转二进制,小数部分,乘 2 取整数,若乘之后的小数部分不为 0,继续乘以 2 直到小数部分为 0 ,将取出的整数正向排序。

例如: 0.1 转二进制

0.1 * 2 = 0.2 --------------- 取整数 0,小数 0.2
0.2 * 2 = 0.4 --------------- 取整数 0,小数 0.4
0.4 * 2 = 0.8 --------------- 取整数 0,小数 0.8
0.8 * 2 = 1.6 --------------- 取整数 1,小数 0.6
0.6 * 2 = 1.2 --------------- 取整数 1,小数 0.2
0.2 * 2 = 0.4 --------------- 取整数 0,小数 0.4
0.4 * 2 = 0.8 --------------- 取整数 0,小数 0.8
0.8 * 2 = 1.6 --------------- 取整数 1,小数 0.6
0.6 * 2 = 1.2 --------------- 取整数 1,小数 0.2
...

最终 0.1 的二进制表示为 0.000110011...... 后面将会 0011 无限循环,因此二进制无法精确的保存类似 0.1 这样的小数。那这样无限循环也不是办法,又该保存多少位呢?也就有了我们接下来要重点讲解的 IEEE 754 标准。

IEEE 754

维基百科的链接,感兴趣的自行了解一下。

这里用一句话概述,IEEE754是一种二进制浮点数算术标准

IEEE 754 常用的两种浮点数值的表示方式为:单精确度(32位)、双精确度(64位)。例如, java语言中的 float 通常是指 IEEE 单精确度,而 double 是指双精确度。

在 JavaScript 中不论小数还是整数只有一种数据类型表示,这就是 Number 类型,其遵循 IEEE 754 标准,使用双精度浮点数(double)64 位(8 字节)来存储一个浮点数。

双精度(64bits)浮点数的三个域:

  • sign bit(S,符号):用来表示正负号,0 为 正 1 为 负(1 bit)
  • exponent(E,指数):用来表示次方数(11 bits)
  • Significand(M,尾数):用来表示精确度 1 <= M < 2(52 bits)

下面看一下0.1在IEEE 754 标准中是如何存储的?

如下图所示(此网站):

可以看出: 指数位决定了大小范围,小数位决定了计算精度

有两个点需要注意:

  • IEEE 754标准规定,在保存小数Significand时,第一位默认是1,因此可以被舍去,只存储后边的部分。例如,1.01001保存的时候,只保存01001,等到用的时候再把1加上去。这样,就可以节省一个位的有效数字。
  • 指数E在存储的时候也有些特殊。为64位浮点数时,指数占11位,范围为0-2047 。但是,指数是有正有负的,因此实际值需要在此基础上减去一个中间数。对于64位,中间数为1023 。

故0.1最后保存在计算机里,成为了以下形式:

符号位: 0
指数位: -4+1023 = 1019,二进制表示为:01111111011
小数位:1.1001100110011001100110011001100110011001100110011001 ,舍弃第一位的1,根据最右边未显示的一位0舍1入,表示为:1001100110011001100110011001100110011001100110011010

所以最终是:

    0  01111111011  1001100110011001100110011001100110011001100110011010
S符号      E指数                            M尾数

可以通过这个网站验证,如下所示:

0.1 + 0.2 等于多少?

上面得到了0.1的二进制表示形式,下面推算一下0.2的二进制表示形式

十进制0.2转为二进制为0.001100110011(0011循环),即 1.100110011(0011)*2^-3 ,存储时:

符号位: 0

指数位:-3,实际存储为 -3 + 1023 = 1020 的二进制 01111111100

小数位: 1.100110011(0011循环),舍弃首位,截掉多余位后(精度损失的原因之一)为1001100110011001100110011001100110011001100110011010

0  01111111100  1001100110011001100110011001100110011001100110011010
S     E指数          M尾数

对阶运算

接下来,计算 0.1 + 0.2 。

浮点数进行计算时,需要对阶。即把两个数的指数阶码设置为一样的值,然后再计算小数部分。其实对阶很好理解,就和我们十进制科学记数法加法一个道理,先把指数部分化成一样,再计算小数。

另外,需要注意一下,对阶时需要小阶对大阶。因为,这样相当于,小阶指数乘以倍数,小数部分相对应的除以倍数,在二进制中即右移倍数位。这样,不会影响到小数的高位,只会移出低位,损失相对较少的精度。

因此,0.1的指数阶码为 -4 , 需要对阶为 0.2的指数阶码 -3 。尾数部分整体右移一位。

1.100110011(0011)*2^-4 变成 0.1100110011(0011)*2^-3

符号位: 0

指数位:-3,实际存储为 -3 + 1023 = 1020 的二进制 1111111100

小数位: 0.1100110011(0011循环),截掉多余位(0舍1入)后为1100110011001100110011001100110011001100110011001101

原来的0.1
0  01111111011  1001100110011001100110011001100110011001100110011010
对阶后的0.1
0  01111111100  1100110011001100110011001100110011001100110011001101

然后进行尾数部分相加 ,做加法时我们带上整数位进行计算:

  0 01111111100   0.1100110011001100110011001100110011001100110011001101
+ 0 01111111100   1.1001100110011001100110011001100110011001100110011010
= 0 01111111100  10.0110011001100110011001100110011001100110011001100111

可以看到,产生了进位。因此,阶码需要 +1,即为 -2,尾数部分进行低位0舍1入处理(精度损失的原因之二)。因尾数最低位为1,需要进位。所以存储为:

0  1111111101  0011001100110011001100110011001100110011001100110100

最后把二进制转换为十进制,计算结果的二进制表示为:

1.0011001100110011001100110011001100110011001100110100 * 2^-2

转为十进制,最终结果为:

0.30000000000000004

所以 0.1 + 0.2 !== 0.3 这个问题就这样产生了。

所以:

精度损失可能出现在<u>进制转化</u>和<u>对阶运算</u>过程中

只要在这两步中产生了精度损失,计算结果就会出现偏差。

只有 JavaScript 中存在吗?

这显然不是的,这在大多数语言中基本上都会存在此问题(大都是基于 IEEE754 标准),让我们看下 0.1 + 0.2 在一些常用语言中的运算结果。

Python

Python2 的 print 语句会将 0.30000000000000004 转换为字符串并将其缩短为 “0.3”,可以使用 print(repr(.1 + .2)) 获取所需要的浮点数运算结果。这一问题在 Python3 中已修复。

# Python2
print(.1 + .2) # 0.3
print(repr(.1 + .2)) # 0.30000000000000004

# Python3
print(.1 + .2) # 0.30000000000000004

Java

Java 中使用了 BigDecimal 类内置了对任意精度数字的支持。

System.out.println(.1 + .2); // 0.30000000000000004 (java中小数默认是double类型)

System.out.println(.1F + .2F); // 0.3 (进制转换和对阶运算后正好是0.3)

这个网站中可以看到有很多语言存在这种问题。

解决方法:

1、转换为整数计算

function add(num1, num2) {
    //num1 小数位的长度
 const num1Digits = (num1.toString().split('.')[1] || '').length;
 //num2 小数位的长度
 const num2Digits = (num2.toString().split('.')[1] || '').length;
 // 取最大的小数位作为10的指数
 const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
 // 把小数都转为整数然后再计算
 return (num1 * baseNum + num2 * baseNum) / baseNum;
}

把计算数字 提升 10 的N次方 倍 再 除以 10的N次方。N>1。

2、使用ES6提供的极小数Number.EPSILON:

function numbersequal(a,b){ 
        return Math.abs(a-b) < Number.EPSILON;
} 
var a=0.1+0.2, b=0.3;
console.log(numbersequal(a,b)); //true

3、类库:

(1)math.js

math.js是JavaScript和Node.js的一个广泛的数学库。支持数字,大数,复数,分数,单位和矩阵等数据类型的运算。

官网:http://mathjs.org/

GitHub:https://github.com/josdejong/mathjs

0.1+0.2 ===0.3实现代码:
var math = require('mathjs')
console.log(math.add(0.1,0.2))//0.30000000000000004
console.log(math.format((math.add(math.bignumber(0.1),math.bignumber(0.2)))))//'0.3'

(2)decimal.js

为 JavaScript 提供十进制类型的任意精度数值。

官网:http://mikemcl.github.io/decimal.js/

GitHub:https://github.com/MikeMcl/decimal.js

var Decimal = require('decimal.js')
x = new  Decimal(0.1)
y = 0.2
console.log(x.plus(y).toString())//'0.3'

(3)bignumber.js

用于任意精度算术的JavaScript库。

官网:http://mikemcl.github.io/bignumber.js/

Github:https://github.com/MikeMcl/bignumber.js

var BigNumber = require("bignumber.js")
x = new BigNumber(0.1)
y = 0.2
console.log(x.plus(y).toString())//'0.3'

(4)big.js

用于任意精度十进制算术的小型快速JavaScript库。

官网:http://mikemcl.github.io/big.js/

Github:https://github.com/MikeMcl/big.js/

var Big = require("big.js")
x = new Big(0.1)
y = 0.2
console.log(x.plus(y).toString())//'0.3'

总结:

发现在js中存在0.1+0.2!= 0.3这个现象后,通过上面的分析发现此现象的原因为:在<u>进制转化</u>和<u>对阶运算</u>过程中会出现精度损失。在其他基于 IEEE754 标准的语言中也存在这种问题。并列出了几个解决的方法。

参考链接:

https://github.com/qufei1993/Nodejs-Roadmap/blob/master/docs/javascript/floating-point-number-0.1-0.2.md

https://www.jianshu.com/p/e22d1268cb96

https://juejin.cn/post/6844903680362151950

https://xiaolincoding.com/os/1_hardware/float.html#_0-1-0-2-0-3

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章