确保前端 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 等流行开源库可选用。

示例:使用 math.js 实现简单的加减乘除运算:

/* eslint-disable no-extend-native */
import { chain, bignumber } from 'mathjs';

type FuncType = 'add' | 'divide' | 'multiply' | 'subtract';
type ArgsMuti = (number | string)[];

function calc(funcType: FuncType, args: ArgsMuti) {
    let valChain = chain(bignumber(args[0]));

    args.forEach(arg => {
        valChain = valChain[funcType](bignumber(arg));
    });

    return parseFloat(valChain.done());
}

/** 加法 */
export function add(...args: ArgsMuti) {
    return calc('add', args);
}
/** 减法 */
export function sub(...args: ArgsMuti) {
    return calc('subtract', args);
}
/** 乘法 */
export function mul(...args: ArgsMuti) {
    return calc('multiply', args);
}
/** 除法 */
export function div(...args: ArgsMuti) {
    return calc('divide', args);
}

// 若无后续兼容性顾虑,还可以考虑一下扩展 Number 对象属性:

// Number.prototype.div = function (...args: ArgsMuti) {
//     return div(this.valueOf(), ...args);
// };

// Number.prototype.mul = function (...args: ArgsMuti) {
//     return mul(this.valueOf(), ...args);
// };

// Number.prototype.add = function (...args: ArgsMuti) {
//     return add(this.valueOf(), ...args);
// };

// Number.prototype.sub = function (...args: ArgsMuti) {
//     return sub(this.valueOf(), ...args);
// };

2.4 总结

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

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

在实际的金融交易系统开发实践过程中,我们使用了第一种(转为整数计算)方案,并将其剥离为了独立的工具库,外部 npm 发布包为 @lzwme/asmd-calcnpm: @lzwme/asmd-calc) 。由于只包含加减乘除运算,其核心实现不足百行代码,在涉及复杂科学运算时则使用 mathjs、decimal.js 等库。

3 相关参考

点赞 (7)
  1. 不曾潇洒说道:
    Google Chrome 96.0.4664.45 Google Chrome 96.0.4664.45 Windows 10 x64 Edition Windows 10 x64 Edition

    学习了

发表回复

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

Captcha Code