例子:
你有没有发现一个场景,在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 标准中是如何存储的?
如下图所示(此网站):
image.png可以看出: 指数位决定了大小范围,小数位决定了计算精度。
有两个点需要注意:
- 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尾数
可以通过这个网站验证,如下所示:
image.png0.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尾数
image.png
对阶运算
接下来,计算 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的一个广泛的数学库。支持数字,大数,复数,分数,单位和矩阵等数据类型的运算。
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://www.jianshu.com/p/e22d1268cb96
https://juejin.cn/post/6844903680362151950
https://xiaolincoding.com/os/1_hardware/float.html#_0-1-0-2-0-3
网友评论