Blog
Blog copied to clipboard
js小数的数学运算和四舍五入精度问题
前言
在开发中,要进行计算,你可能会遇到小数运算,运气好的话,你的测试测不到精度问题,但其实这是很严重的,以下两个典型例子先感受以下
0.1 + 0.2 = 0.30000000000000004
35.41 * 100 = 3540.9999999999995
是不是出乎你的意料?
写这篇文章的原因是网上找了些资料,要不就是介绍不全的,要不就是存在错误的(可能大家没发现),要不就是方案还有待加强的。于是我决定自己整理出一份较为全面而不误导别人的文章出来(文章方案对网上大部分资料存在的缺陷进行弥补增强),如果您发现不足,请告诉我,虚心请教。
以下我们了解原因以及寻找解决方案。
原因
js的数字存储情况
在计算机中存储的信息都是二进制来表示,我们都知道,js中数字类型只有Number
,不像其他语言如java
有int
、double
类型等。它的实现遵循 IEEE 754 标准,使用64位固定长度来表示,也就是标准的 double 双精度浮点数。
其中这64位数又分为三部分(从左往右看):
- 符号位:第一位为符号位,0表示正数,1表示负数;
- 指数位:中间的11位存储指数,用来表示次方数
- 尾数位:最后的52位就是尾数,超出部分会0舍1入(类似四舍五入)
计算过程
以例子0.1 + 0.3
展开说明:
- 先把0.1和0.3转化位二进制,会发现转化后的二进制会陷入循环,超出了上面说的尾数52位,因为小数位只能到52位,所以进行0舍1入的处理,
0.1
->0.0001100110011001100110011001100110011001100110011010
;0.2
->0.0011001100110011001100110011001100110011001100110011
- 转化后的二进制进行加法运算,得
0.0100110011001100110011001100110011001100110011001101
,转化为十进制就是0.30000000000000004
于是就这样,得到了一个出乎你预料的结果了。并不是所有小数计算都会这样,是一些小数转化位二进制时出现超出52位数时,就可能会出现这种意外结果。
解决方案
上面我们知道了出现意外结果是因为小数转化为二进制时发生问题,那整数呢?很自然,整数也是有最大值安全值的,就是2的53次方,为9007199254740992。超出这个数值的计算同样也是带来精度问题。但是我们一般使用是不会超出这个值的(如果你的需求也要考虑超出这个数值,抱歉,我无能为力)
如果你的需求可以无视上面整数最大安全值的弊端,那么接下来的解决方案才是适合你的。
解放方案:把小数运算中的小数,升级转化为整数(乘以10的n次幂),在进行运算,将最后结果再降级(除以10的n次幂)
上面的描述仅仅是一个思路,一个转化思路。怎么将小数转化为整数,这就讲究了,不能真的进行乘法计算,乘以10的n次幂,还记得前言里的第二个例子吗,这种转化整数的方式本身就是一个小数运算,所以还是会出现问题。
我们要用字符串替换的方式来实现这个“升级”:
原值:1.23
转化过程:
1. 化为字符串 '1.23'
2. '1.23'.replace('.', ''),得'123',相当于乘以10的2次幂
结合实际例子来了解大概的一个情况,例子1.1 + 1.22
1. 分别对1.1和1.22进行字符串替换,变成'110'和'122',相当于乘以10的2次幂
2. 对替换结果转化回数字类型然后再进行加法运算:110 + 122 = 232
3. 对232除以10的2次幂,得2.32
以上就是一个转化和运算过程。
基于上述基本思想,我对加减乘除进行一个方法封装,方便大家进行小数运算。
/**
* 带有小数的加法/减法运算
* 减法实际上可看成加法,所以如果要做减法,只需第二个参数即被减数传负值即可
* @param {Number} arg1 - 加数/减数
* @param {Number} arg2 - 加数/被减数
*/
function addFloat(arg1, arg2) {
let m = 0; // 记录两个加数中最长的小数位长度
let arg1Str = arg1 + '';
let arg2Str = arg2 + '';
const arg1StrFloat = arg1Str.split('.')[1];
const arg2StrFloat = arg2Str.split('.')[1];
arg1StrFloat && (m = arg1StrFloat.length);
arg2StrFloat && (m = m > arg2StrFloat.length ? m : arg2StrFloat.length);
arg1Str = arg1.toFixed(m); // 主要是为了补零
arg2Str = arg2.toFixed(m);
const transferResult = +(arg1Str.replace('.', '')) + +(arg2Str.replace('.', ''));
return transferResult / Math.pow(10, m);
};
/**
* 带有小数的乘法运算
* @param {Number} arg1 - 因数
* @param {Number} arg2 - 因数
*/
function multiplyFloat(arg1, arg2) {
let m = 0;
const arg1Str = arg1 + '';
const arg2Str = arg2 + '';
const arg1StrFloat = arg1Str.split('.')[1];
const arg2StrFloat = arg2Str.split('.')[1];
arg1StrFloat && (m += arg1StrFloat.length);
arg2StrFloat && (m += arg2StrFloat.length);
const transferResult = +(arg1Str.replace('.', '')) * +(arg2Str.replace('.', ''));
return transferResult / Math.pow(10, m);;
};
/**
* 有小数的除法运算
* @param {Number} arg1 - 除数
* @param {Number} arg2 - 被除数
*/
function divideFloat(arg1, arg2) {
const arg1Str = arg1 + '';
const arg2Str = arg2 + '';
const arg1StrFloat = arg1Str.split('.')[1] || '';
const arg2StrFloat = arg2Str.split('.')[1] || '';
const m = arg2StrFloat.length - arg1StrFloat.length;
const transferResult = +(arg1Str.replace('.', '')) / +(arg2Str.replace('.', ''));
return transferResult * Math.pow(10, m);;
};
小缺陷
任何一个方案都不能十全十美的,多多少少会有一些限制,毕竟需求是多种多样的。我写文章的习惯就是得告知别人缺陷,而不能忽悠别人。知道自己写的东西的利,也得知道自己的弊。
该方案会有几个小缺陷:
- 进行运算的值不能超过js的数字最大安全值
9007199254740992
- 加法中运用到了
toFixed
方法,该方法的参数num有个限制:当 num 太小或太大时抛出异常 RangeError。0 ~ 20 之间的值不会引发该异常。
以上着两个小缺陷其实在我们正常开发中,一般不会触及到,因为这样的数字和小数位实在太长了,我们一般需求不会要求进行这么大的运算以及小数点保留位。
四舍五入(保留小数位)
这里顺着这个主题,可以顺带讲一下在js中进行四舍五入或进行小数位保留的情况。
很多人会想到,用tofixed进行四舍五入,实际上,tofixed函数对于四舍五入的规则与数学中的规则不同,使用的是银行家舍入规则:其实是一种四舍六入五取偶(又称四舍六入五留双)法。表现为:
四舍六入五考虑,五后非零就进一,五后为零看奇偶,五前为偶应舍去,五前为奇要进一。
很显然,这并不是我们想要的结果。但是Math.round
方法,就是我们所熟知的四舍五入规则,我们可以利用该方法扩展到小数位的四舍五入。
网上很多资料都有介绍这种方式:
对小数乘以10的n次幂,再用Math.round取整,再除以10的n次幂,就能得到进过四舍五入后的指定小数位了。
经过上文我的介绍,只要对小数进行数学运算,都有可能出现精度不准确的问题。所以最终的一步正如网上这么多资料说的那样做法,但是其中的乘法和除法,请用文中封装好的方法来进行,而不是直接进行小数的数学运算。
这里我封装一个四舍五入的方法
function roundFloat (value, decimal = 2) {
const n = Math.pow(10, decimal);
return divideFloat(Math.round(multiplyFloat(value, n)), n).toFixed(decimal);
}
可能有人会疑问,我这里为什么还要用toFixed
,不是说这个不准的吗?
其实我这里并没有用toFixed
做实际性上的四舍五入,真正做了四舍五入的工作在调用toFixed
前就已经完成了,最后还用toFixed
只是做润色作用,例如1.1
你要保留小数点后两位的话,理应显示成1.10
,但是js中数字类型显示出来的话,是不会有后面的不起作用的0,因此如果你想显示出指定位数,不足补零的话,就得用toFixed
转化位字符串了。
因此上面的方法的返回结果是一个字符串类型,这大家要注意了,如果你没有这方面的需求,可自行拿掉后面的toFixed
调用。
未经允许,请勿私自转载