js计算精度问题

1,308 阅读6分钟

问题描述

JavaScript 中只有一种数字类型 Number,无论整数还是小数都是以双精度浮点数(64位)形式储存。所以我们在打印 1.00 这样的浮点数的结果是 1 而非 1.00 。
因为有些小数以二进制表示位数是无穷的,所以会有一些精度问题,当然精度问题也不是 JS 特有的。下面举几个因此产生精度问题的例子:

/**
* 加法
*/
0.1 + 0.2  // 0.30000000000000004
0.7 + 0.1  // 0.7999999999999999
0.2 + 0.4  // 0.6000000000000001
2.22 + 0.1 // 2.3200000000000003
   
/**
* 减法
*/
1.5 - 1.2  // 0.30000000000000004
0.3 - 0.2  // 0.09999999999999998

/**
* 乘法
*/
19.9 * 100  // 1989.9999999999998
1306377.64 * 100  // 130637763.99999999
1306377.64 * 10 * 10  // 130637763.99999999
0.7 * 180  // 125.99999999999999
9.7 * 100  // 969.9999999999999
39.7 * 100  // 3970.0000000000005
 
/**
* 除法
*/
0.3 / 0.1  // 2.9999999999999996
0.69 / 10  // 0.06899999999999999

/**
* toFixed
*/
2.55.toFixed(1)  // 2.55

双精度浮点数存储

JavaScript 里的数字是采用 IEEE 754 标准的 64 位双精度浮点数。在内存中的表示如下:

image.png

  • 第0位:符号位, s 表示 ,0表示正数,1表示负数;
  • 第1位到第11位:储存指数部分,即 2^e-1023;
  • 第12位到第63位:储存小数部分(即有效数字),f 表示;
    符号位决定了一个数的正负,指数部分决定了数值的大小,小数部分决定了数值的精度。 IEEE 754规定,有效数字第一位默认总是1,不保存在64位浮点数之中。也就是说,有效数字总是1.xx…xx的形式,其中xx..xx的部分保存在64位浮点数之中,最长可能为52位。因此,JavaScript提供的有效数字最长为53个二进制位。

0.1 + 0.2 !== 0.3

首先,十进制的0.10.2都会被转换成二进制,但由于浮点数用二进制表达时是无穷的,

0.1 -> 0.0001100110011001...(无限)
0.2 -> 0.0011001100110011...(无限)

IEEE 754 标准的 64 位双精度浮点数的小数部分最多支持 53 位二进制位,所以两者相加之后得到二进制为:

0.0100110011001100110011001100110011001100110011001100 

因浮点数小数位的限制而截断的二进制数字,再转换为十进制,就成了 0.30000000000000004。所以在进行算术计算时会产生误差。

整数精度也有可能有问题

console.log(19571992547450991); //=> 19571992547450990
console.log(19571992547450991===19571992547450992); //=> true

因为在 JavaScript 中 Number类型统一按浮点数处理,数值范围为-1.7E-308~1.7E+308,安全整数最大(253 - 1Number.MAX_SAFE_INTEGER,9007199254740991) 和最小(-(253 - 1)Number.MIN_SAFE_INTEGER,-9007199254740991)。只要超过这个范围,就会存在被舍去的精度问题。(E: 7.8x10^7,简写为“7.8E+07)

如何解决

计算类库

  • mathJs 是专门为 JavaScript 和 Node.js 提供的一个广泛的数学库。它具有灵活的表达式解析器,支持符号计算,配有大量内置函数和常量,并提供集成解决方案来处理不同的数据类型
    像数字,大数字(超出安全数的数字),复数,分数,单位和矩阵。 功能强大,易于使用。
  • bigNumber 用于任意精度十进制和非十进制算法的JavaScript库。

格式化类库

  • Numeral.js: 一个用于格式化和操作数字的JavaScript库。数字可以被格式化为货币,百分比,时间,几个小数位数,千分位等等。 您也可以随时创建自定义格式。
  • accounting.js :一个轻量级的JavaScript库,用于格式化数字,金额和货币等。

自定义加减乘除

如果不方便在项目中引入精确计算的类库就只能自定义方法实现运算:

我最开始想到使用toFixed()格式化得到精确的计算结果,写着写着却发现一些问题(允悲。。)

image.png

实现思路避坑:toFixed()的计算规则并不是数学中的四舍五入,可以看下 MDN-toFixed

既然我们发现了浮点数的这个问题,又不能直接让两个浮点数运算,那怎么处理呢?
我们可以把需要计算的数字升级(乘以10的n次幂)成计算机能够精确识别的整数,等计算完成后再进行降级(除以10的n次幂),这是大部分变成语言处理精度问题常用的方法。例如:

0.1 + 0.2 == 0.3 //false
(0.1*10 + 0.2*10)/10 == 0.3 //true

但是这样就能完美解决么?细心的读者可能在上面的例子里已经发现了问题:

35.41 * 100 = 3540.9999999999995
(10120.4695 * 10000 + 589.60 * 10000)/ 10000 = 10710.069499999998

看来进行数字升级也不是完全的可靠啊(允悲)。那只能不让 小数 参与运算。将浮点数toString后 indexOf(' . '),记录一下小数位的长度,然后利用 string.replace( ' . ' , ' ' ) 将小数点抹掉,最后再除以抹掉的位数,这样就变成了 “整数” 之间的运算! 完整代码如下:

/**
 * 加法 用来得到精确的加法结果
 * @param number
 * @returns number
 */
function accuracyAdd(num1 = 0, num2 = 0) {
  let r1; // num1的小数部分长度
  let r2; // num2的小数部分长度
  try {
    r1 = num1.toString().split('.')[1].length;
  } catch (e) {
    r1 = 0;
  }
  try {
    r2 = num2.toString().split('.')[1].length;
  } catch (e) {
    r2 = 0;
  }
  // 要放大的倍数
  const commonMultiple = Math.pow(10, Math.max(r1, r2));
  // 把num1, num2 放大commonMultiple倍
  const maxMultiple = Math.max(r1, r2);
  const tempNum1 = Number(num1.toString().replace('.', '') + '0'.repeat(maxMultiple - r1));
  const tempNum2 = Number(num2.toString().replace('.', '') + '0'.repeat(maxMultiple - r2));

  // 整数之间的除法没有问题
  return (tempNum1 + tempNum2) / commonMultiple;
}


/**
 * 减法 用来得到精确的减法结果
 * 返回值:arg1减去arg2的精确结果
*/
function accSubtr(arg1, arg2) {
  var r1, r2, m, n;
  try {
    r1 = arg1.toString().split(".")[1].length;
  } catch (e) {
    r1 = 0;
  }
  try {
    r2 = arg2.toString().split(".")[1].length;
  } catch (e) {
    r2 = 0;
  }
  m = Math.pow(10, Math.max(r1, r2));
  //动态控制精度长度
  n = r1 >= r2 ? r1 : r2;
  return ((arg1 * m - arg2 * m) / m).toFixed(n);
}
//给Number类型增加一个subtr 方法,调用起来更加方便。
Number.prototype.subtr = function (arg) {
  return accSubtr(arg, this);
};


/**
 * 乘法 用来得到精确的乘法结果
 * @param number
 * @returns number
 */
function accuracySub(arg1 = 0, arg2 = 0) {
  // arg1、arg2小数部分长度和
  let m = 0;
  const s1 = arg1.toString();
  const s2 = arg2.toString();

  try {
    m += s1.split('.')[1].length;
  } catch (e) {
    console.log(e);
  }
  try {
    m += s2.split('.')[1].length;
  } catch (e) {
    console.log(e);
  }
  return (Number(s1.replace('.', '')) * Number(s2.replace('.', ''))) / Math.pow(10, m);
}

/**
 * 除法 用来得到精确的除法结果
 * 返回值:arg1除以arg2的精确结果
 */
function accDiv(arg1, arg2) {
  var t1 = 0,
    t2 = 0,
    r1,
    r2;
  try {
    t1 = arg1.toString().split(".")[1].length;
  } catch (e) {}
  try {
    t2 = arg2.toString().split(".")[1].length;
  } catch (e) {}
  with (Math) {
    r1 = Number(arg1.toString().replace(".", ""));
    r2 = Number(arg2.toString().replace(".", ""));
    return (r1 / r2) * pow(10, t2 - t1);
  }
}
//给Number类型增加一个div方法,调用起来更加方便。
Number.prototype.div = function (arg) {
  return accDiv(this, arg);
};

自定义toFixed,ceil,保留n位小数

/**
 * 保留len位小数
 * len<数字的小数位数?截取前len位小数:补0
 * @param num 原数字
 * @param len 保留几位小数
 * @returns string
 */
function padNum(num: string | number = 0, len = 2) {
  const numToString = num?.toString();
  const dotPos = numToString.indexOf('.');
  //整数的情况
  if (dotPos === -1) {
    // 如果保留0位小数
    if (len === 0) {
      return numToString;
    }
    return `${numToString}.${'0'.repeat(len)}`;
  } else {
    //小数的情况
    const need = len - (numToString.length - dotPos - 1);
    if (need <= 0) {
      return len === 0
        ? numToString.substring(0, len + dotPos)
        : numToString.substring(0, len + dotPos + 1);
    }
    return `${numToString}${'0'.repeat(need)}`;
  }
}

// 重写toFixed 方法
const toFixed = function (num: string | number = 0, len = 0) {
  if (len > 20 || len < 0) {
    throw new RangeError('toFixed() digits argument must be between 0 and 20');
  }

  const number = Number(num);
  if (isNaN(number) || number >= Math.pow(10, 21)) {
    return '-';
  }
  if (typeof len === 'undefined' || len === 0) {
    return Math.round(number).toString();
  }
  let result = number.toString();
  const numberArr = result.split('.');

  if (numberArr.length < 2) {
    //整数的情况
    return padNum(result, len);
  }
  const intNum = numberArr[0]; //整数部分
  const deciNum = numberArr[1]; //小数部分
  const lastNum = deciNum.substr(len, 1); //最后一个数字

  // 需要截取的长度等于当前长度
  if (deciNum.length === len) {
    return result;
  }
  // 需要截取的长度大于当前长度 eg:1.3.toFixed(2)
  if (deciNum.length < len) {
    return padNum(result, len);
  }
  // 需要截取的长度小于当前长度,需要判断最后一位数字
  result = intNum + '.' + deciNum.substr(0, len);
  //最后一位数字大于5,要进位
  if (parseInt(lastNum, 10) >= 5) {
    const times = Math.pow(10, len);
    let changedInt = Number(result.replace('.', ''));
    changedInt++; //整数进位
    changedInt /= times; //整数转为小数,注:有可能还是整数
    result = padNum(changedInt, len);
  }
  return result;
};

// 有时还有小数部分向上取整的情况,也可以和toFixed方法合并
/**
 * 对数据进行四舍五入 | 向上取整
 * @param num 原数据
 * @param len 保留 len 位小数
 * @param rule 格式化规则
 * @returns string
 */
function fixed(num: number | string = 0, len = 2, rule: typeof FIX | typeof CEIL = FIX) {
  if (len > 20 || len < 0) {
    throw new RangeError('toFixed() digits argument must be between 0 and 20');
  }

  const tempNum = Number(num);
  // 如果tempNum 不是数字或者是大数
  if (isNaN(tempNum) || tempNum >= Math.pow(10, 21)) {
    return '-';
  }
  // 如果是四舍五入且不保留小数
  if (rule === FIX && len === 0) {
    return Math.round(tempNum).toString();
  }
  let result = tempNum.toString();
  const numberArr = result.split('.');

  //整数的情况
  if (numberArr.length < 2) {
    return padNum(result, len);
  }
  const intNum = numberArr[0]; //整数部分
  const deciNum = numberArr[1]; //小数部分
  const lastNum = deciNum.substring(len, len + 1); //最后一个数字

  // 需要截取的长度等于当前长度
  if (deciNum.length === len) {
    return result;
  }
  // 需要截取的长度大于当前长度
  if (deciNum.length < len) {
    return padNum(result, len);
  }
  // 需要截取的长度小于当前长度,需要判断最后一位数字
  result = intNum + '.' + deciNum.substring(0, len);

  let baseNum = 1;
  if (rule === FIX) {
    baseNum = 5;
  }
  //最后一位数字大于基准数,要进位
  if (parseInt(lastNum, 10) >= baseNum) {
    console.log(parseInt(lastNum, 10));
    const times = Math.pow(10, len);
    let changedInt = Number(result.replace('.', ''));
    changedInt++; //整数进位
    changedInt /= times; //整数转为小数,注:有可能还是整数
    result = padNum(changedInt, len);
  }
  return result;
}

[参考文章] :
为什么 0.1 + 0.2 不等于 0.3
双精度浮点数
Math.round