js小数的数学运算和四舍五入精度问题

4,016 阅读7分钟

前言

在开发中,要进行计算,你可能会遇到小数运算,运气好的话,你的测试测不到精度问题,但其实这是很严重的,以下两个典型例子先感受以下

0.1 + 0.2 = 0.30000000000000004

35.41 * 100 = 3540.9999999999995

是不是出乎你的意料?

写这篇文章的原因是网上找了些资料,要不就是介绍不全的,要不就是存在错误的(可能大家没发现),要不就是方案还有待加强的。于是我决定自己整理出一份较为全面而不误导别人的文章出来(文章方案对网上大部分资料存在的缺陷进行弥补增强),如果您发现不足,请告诉我,虚心请教。

以下我们了解原因以及寻找解决方案。

原因

js的数字存储情况

在计算机中存储的信息都是二进制来表示,我们都知道,js中数字类型只有Number,不像其他语言如javaintdouble类型等。它的实现遵循 IEEE 754 标准,使用64位固定长度来表示,也就是标准的 double 双精度浮点数。

其中这64位数又分为三部分(从左往右看):

  • 符号位:第一位为符号位,0表示正数,1表示负数;
  • 指数位:中间的11位存储指数,用来表示次方数
  • 尾数位:最后的52位就是尾数,超出部分会0舍1入(类似四舍五入)

计算过程

以例子0.1 + 0.3展开说明:

  1. 先把0.1和0.3转化位二进制,会发现转化后的二进制会陷入循环,超出了上面说的尾数52位,因为小数位只能到52位,所以进行0舍1入的处理,0.1 -> 0.0001100110011001100110011001100110011001100110011010; 0.2 -> 0.0011001100110011001100110011001100110011001100110011
  2. 转化后的二进制进行加法运算,得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调用。


未经允许,请勿私自转载