确保前端 JavaScript 浮点数精度的四则运算方法

目录
[隐藏]

0.30000000000000004

1 浮点数运算与 IEEE 754 标准

在 JavaScript 中,执行 0.1+0.2,得到的结果却是 0.30000000000000004。这就不得不提到 IEEE 754 标准。

IEEE二进制浮点数算术标准(IEEE 754)定义了表示浮点数的格式(包括负零-0)与反常值(denormal number)、一些特殊数值(无穷(Inf)与非数值(NaN))、以及这些数值的“浮点数运算符”,它也指明了四种数值舍入规则和五种例外状况(包括例外发生的时机与处理方式)。该标准为许多CPU与浮点运算器所采用。

ECMAScript® 语言规范中的 Number 类型遵循 IEEE 754 标准,使用64位固定长度来表示。

IEEE754 规定:

  • 单精度浮点数字长 32 位,尾数长度 23(即小数部分),指数长度 8,指数偏移量 127;
  • 双精度浮点数字长 64 位,尾数长度 52(即小数部分),指数长度 11,指数偏移量 1023;
  • 约定小数点左边隐含有一位 1(在二进制,第一个有效数字必定是1,所以不用存储)。所以上述单精度尾数长度实际为24,双精度尾数长度实际为53。

对于无限循环小数和无理数来说,超过 23(单精度)/52(双精度) 位的小数部分必然会出现溢出而产生精度丢失。
十进制的浮点数运算,都会先转换为二进制,再执行二进制的四则运算。但有一些浮点数在转化为二进制时,会出现无限循环 。比如十进制的 0.1 与 0.2 转换为二进制,会得到如下结果:

0.1 => 0.0001 1001 1001 1001…(无限循环)
0.2 => 0.0011 0011 0011 0011…(无限循环)

任何遵循 IEEE 754 标准的编程语言都会如此。如果你有兴趣了解具体都哪些编程语言,可参考这个网站:

2 确保 JavaScript 浮点数运算精度的方法

2.1 转换为整数计算

先将需要计算的数字乘以 10 的 n 次幂,换成计算机能够精确识别的整数,等计算完毕再除以 10 的 n 次幂以还原精度。这是较为常见的处理精度差异的方法。

以下为实现代码示例:

function toNonExponential(num) {
  num = Number(num);
  const strNum = String(num);
  if (strNum.indexOf('e') === -1) return strNum;
  const m = num.toExponential().match(/\d(?:\.(\d*))?e([+-]\d+)/);
  return num.toFixed(Math.max(0, (m[1] || '').length - Number(m[2])));
}
function getDecimalLen(num) {
  try {
    return toNonExponential(num).split('.')[1].length;
  } catch (f) {
    return 0;
  }
}

/** 加法 */
function add(a, b) {
  const n1 = getDecimalLen(a);
  const n2 = getDecimalLen(b);
  const n = Math.pow(10, Math.max(n1, n2));

  return (mul(a, n) + mul(b, n)) / n;
}
/** 减法 */
function sub(a, b) {
  return add(a, -b);
}
/** 乘法 */
function mul(a, b) {
  const decimalLen = getDecimalLen(a) + getDecimalLen(b);
  const e = Math.pow(10, decimalLen);
  const aa = Number(toNonExponential(a).replace('.', ''));
  const bb = Number(toNonExponential(b).replace('.', ''));

  return (aa * bb) / e;
}
/** 除法 */
function div(a, b) {
  const decimalLen = getDecimalLen(b) - getDecimalLen(a);
  const e = Math.pow(10, decimalLen);
  const aa = Number(toNonExponential(a).replace('.', ''));
  const bb = Number(toNonExponential(b).replace('.', ''));

  return e === 1 ? aa / bb : mul(aa / bb, e);
}

验证:

console.log('0.1 + 0.2 =', 0.1 + 0.2); // 0.30000000000000004
console.log('add(0.1, 0.2) =', add(0.1, 0.2)); // 0.3

console.log('0.3 - 0.1) =', 0.3 - 0.1); // 0.19999999999999998
console.log('sub(0.3, 0.1) =', sub(0.3, 0.1)); // 0.2

console.log('0.1 * 0.2) =', 0.1 * 0.2); // 0.020000000000000004
console.log('mul(0.1, 0.2) =', mul(0.1, 0.2)); // 0.02

console.log('19.9 * 100) =', 19.9 * 100); // 1989.9999999999998
console.log('mul(19.9, 100) =', mul(19.9, 100)); // 1990

console.log('0.02 / 0.2) =', 0.02 / 0.2); // 0.09999999999999999
console.log('div(0.02, 0.2) =', div(0.02, 0.2)); // 0.1

相关实现参考:

2.2 对计算结果执行 toFixed

由于精度误差都在最后一位小数,只要精确计算结果的小数位未溢出,那么执行 toFixed(decimal)即可还原精确的精度。其中 decimal 为精确计算结果的小数位。

例如加、减、乘法实现示例(除法可用第一种方式实现,见前文示例代码):

/** 加法 */
function add(a, b) {
  const n1 = getDecimalLen(a);
  const n2 = getDecimalLen(b);
  const n = Math.max(n1, n2);

  return parseFloat((a + b).toFixed(n));
}
/** 减法 */
function sub(a, b) {
  return add(a, -b);
}
/** 乘法 */
function mul(a, b) {
  const decimalLen = getDecimalLen(a) + getDecimalLen(b);
  return parseFloat((a * b).toFixed(decimalLen));
}

同样可采用前文代码执行验证。

2.3 按长度分段计算

以上方法可以解决十进制小数和二进制小数转换计算带来的误差问题,但不能提高运算的精度,也不能突破 JavaScript 数字表示的范围。对于常见的前端计算来说这已经足够了。对于涉及大量数据汇总类的大数计算,应当由后端接口计算并以字符串方式返回以供展示。

不过采用按长度分段计算、以字符串方式展示的思路,也可以突破这些限制。此种方式需要考虑的细节场景相对较为复杂,常见的流行开源库多采用该方式实现。例如开源库 decimal.js 即此类方案的典范:

decimal.js 是一个非常成熟的 JavaScript 小数计算处理库,支持非常多的数学计算方法。如对其细节有兴趣,可参阅一下其源码实现。另外还有 big.js、mathjs、bignumber.js 等流行开源库可选用。

2.4 总结

一般来说,简单的常见四则运算处理,前两种方式就足够了,实现简单,代码量也少。

如果有复杂数学计算需求,可采用 decimal.js 等开源库。不过由于这些库包含功能比较多,一般压缩后仍有数十KB大小。如果只是简单的四则运算,不妨采用前两种思路自行实现。

最后安利一下这个 asmd-calc 四则运算 js 库,其核心实现不足百行代码,为第一种方法(转为整数计算)的实现。该库为个人在工作过程中实践经验的总结实现,历经多个涉及大量简单四则计算的金融类项目。如有简单计算需求可参考使用,也欢迎提出改进意见。

3 相关参考

点赞 (3)

发表评论

电子邮件地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据