如果我问你,0.1+0.2等于多少,是不是有种想让我吃药的冲动?
哈哈,最近在项目中做浮点数计算的时候,还真遇到了一些看起来看起来不合常理的事情,例如:
0.1+0.2 控制台打印:
0.30000000000000004
1.11 x 100 控制台打印:
111.00000000000001
哈哈,博学深邃的你又说了,这是js自身浮点数计算存在的bug,但是为什么会存在这个bug呢,客官先别给我答案,给个机会让我去一探究竟吧。
1 揭开神秘的面纱
在《Javascript高级程序设计》第三版,第28页中有这么一段话:
关于浮点数值计算会产生舍入误差的问题,有一点需要明确:这是使用基于IEEE754 数值的浮点计算的通病,ECMAScript 并非独此一家;其他使用相同数值格式的语言也存在这个问题
看到这里,我们知道了,浮点数计算产生的误差和IEEE754肯定有着不可磨灭的渊源,那么IEEE754是个什么神秘代号,别急,咱们慢慢来说。
1.1 何为IEEE754
20 世纪 80 年代之前,计算机制造商们根据自己的需要来设计浮点数的表示规则,由于浮点数没有统一的表示标准,造成代码的移植性很低。后来,IEEE(Institute of Electrical and Electronics Engineers,电子电气工程师协会),制订了二进制浮点运算标准 IEEE 754(IEEE Standard for Binary Floating-Point Arithmetic,ANSI/IEEE Std 754-1985),并被广泛使用,也改善了代码的可移植性。
IEEE 754规定了四种表示浮点数值的方式:单精度(32位)、双精度(64位)、延伸单精度与延伸双精度。其浮点数表示遵循科学记数法规范,采用{S,E,M}来表示一个数V ,并限定指数的底为2,即:
上式(记为式1)中:
1.2 用IEEE754表示JavaScript中的数字
JavaScript 中的数字类型只有 Number 一种,这种类型使用IEEE754格式中的 “双精度浮点数” 来表示一个数字,不区分整数和浮点数。而双精度的浮点数使用 64 位固定长度来表示,也即S+E+M=64bit,各部分所占bit之和为64bit,如图1所示:
从图1中,不难发现:
那么,针对双精度浮点数的情况,IEEE754双精度浮点数的描述,可改写为式2,如下:
说到这,咱们基本上清楚了,JavaScript中Number类型的数据,均采用IEEE754标准中的双精度格式来描述(以上),如果结果存在误差,很有可能是尾数位超过了52,做了近似处理。下面,咱们结合公式举例说明。在举例之前,咱们先在脑海中大致构造一个数据格式转换的流程图:
栗子1:十进制2.5精度无损
(1)原始数据转二进制
十进制浮点数2.5,转换成二进制得:10.1
(2)移动小数点
根据科学计数法规范,将小数点左浮动1位,得:1.01,则e=1
(3)对号入座
由于2.5为正数,则S=0;由式2可知,E=e+1023=1024(10000000000);将1.01中的整数部分1舍掉(原因前面有说明),得M=01,扩充至52bit得:
0100000000000000000000000000000000000000000000000000
因此, 2.5=(-1)^0 X(1.01)X 2^1。为了更形象的展示,咱们使用工具,得到下图:
(4)反推与原始数据比较
1.01小数点右浮动一位得->10.1->转十进制->2.5,没有精度损失。
由于2.5的M部分实际上有效部分只有2位,2<52,没有做近似处理,因此2.5在内存中是被精确存储的,没有精度损失。
栗子2:十进制0.1精度损失过程
(1)原始数据转二进制
十进制浮点数0.1,转换成二进制后,小数部分是0011的一个循环:
整数 | 小数点 | 小数部分 |
0 | . | 000110011001100110011001100110011001100110011001100110011……… |
(2)移动小数点
根据科学计数法规范,将小数点右浮动4位,得如下,则e=-4
整数 | 小数点 | 小数部分 |
0 | . | 10011001100110011001100110011001100110011001100110011……… |
(3)对号入座
同样0.1为正数,则S=0;由式2可知:
E=e+1023=-4+1023=1019(01111111011);
第0-52bit | 第53bit |
1001100110011001100110011001100110011001100110011001 | 1……… |
将第(2)步得到的数据舍掉1,从小数点右侧截取52位数据,由于第53bit数据位1,因此产生进位,得:
M=1001100110011001100110011001100110011001100110011010
因此IEEE754表示0.1,可得到下图:
(4)现在对IEEE754标准表示得数据,转回十进制:
取尾数M部分
1001100110011001100110011001100110011001100110011010
添加整数1,恢复到指数格式
1.1001100110011001100110011001100110011001100110011010
E=1019,则e=-4
1.1001100110011001100110011001100110011001100110011010 * 2 ^ -4
移位
0.00011001100110011001100110011001100110011001100110011010
转成10进制
0.10000000000000000555
可以发现0.1经过周转后变成了0.10000000000000000555,产生了误差。
同样的,0.2经过转换后也会存在误差,转换过程和以上两个示例相同,在此就不多做描述啦,根据浮点数加减运算规则,0.1+0.2得IEEE754的浮点数:
0011111111010011001100110011001100110011001100110011001100110100,
将它转换为10进制数得到:
0.30000000000000004440892098500626,
这也就解释了为什么0.1+0.2 控制台打印:
0.30000000000000004的现象。
关于浮点数的运算规则,感兴趣的小伙伴可以点这:
https://wenku.baidu.com/view/d24fd70902020740be1e9b73.html
2 应对措施
2.1 对于数值
可以使用内置方法,对浮点数做处理
toFixed 是小数点后指定位数取整,从小数点开始数起。
x=1.005
parseFloat(x.toFixed(2))//1.00
toPrecision 是处理精度,精度是从左至右第一个不为0的数开始数起。它四舍五入,返回字符串
x=1.005
parseFloat(x.toPrecision(3))//1.01
Math.round(x) 四舍五入处理 ,不产生精度问题,需要注意的是,需要注意负数的舍、入
Math.round(-0.5)//0
Math.round(-0.2)//0
Math.round(-0.7)//-
2.2 运算类
可以对结果进行指定精度的四舍五入(同单个数值的处理),使用parseFloat(x.toPrecision(num))num可根据实际情况取,一般来说num=12可处理大多数情况:
x=1.11*100//111.00000000000001
parseFloat(x.toPrecision(12))//111
以上给出的解决方法可应对简单的浮点数运算情况,若需要处理复杂的浮点数运算,可以使用一些解决浮点运算精度的库,例如:
3 参考资料
《Javascript高级程序设计》
抓住数据的小尾巴 - JS浮点数陷阱及解法
https://juejin.im/post/59f9e26f6fb9a0452724ea32
IEEE-754标准与浮点数运算
https://blog.csdn.net/m0_37972557/article/details/84594879
计算机组成原理第4章 浮点数运算方法
https://wenku.baidu.com/view/d24fd70902020740be1e9b73.html