数字精度丢失缘由和number-precision源码解析

85 阅读7分钟

原因和背景

js中的number和精度丢失原因

在JavaScript中,所有的数字(包括整数和浮点数)都以64位双精度浮点数的形式存储,这是遵循IEEE 754标准的一部分。这个过程并不是在“编译时”发生的,因为JavaScript通常被认为是一种解释型语言,但它的行为可以类比为编译型语言中的编译过程。 以下是JavaScript处理数字的大致步骤:

  1. 解析(Parsing):当JavaScript代码被解析时,源代码中的十进制数字被识别并转换成抽象语法树(AST)中的数字字面量。
  2. 字面量评估(Literal Evaluation):在代码执行之前,这些数字字面量会被评估成它们的数值等价物。
  3. 存储(Storage):在JavaScript的执行环境中,所有的数字都以64位双精度浮点数的形式存储在内存中。这意味着,无论原始代码中的数字是整数还是浮点数,它们都会被转换成这种格式。
  4. 计算(Computation):在执行JavaScript代码时,所有的算术运算都是基于这种64位双精度浮点数格式进行的。
  5. 输出(Output):当数字被输出或显示时,它们通常被转换回十进制形式以供人类阅读。

这个过程是由JavaScript引擎(如V8引擎、SpiderMonkey等)自动管理的,开发者通常不需要关心这些底层细节。JavaScript引擎会处理从源代码中的十进制表示到内存中的二进制表示的转换,以及反向转换。 需要注意的是,由于这种转换,某些十进制小数无法精确地表示为二进制小数,这可能导致精度损失和一些奇怪的算术行为,比如0.1 + 0.2 !== 0.3。这是因为0.10.2的二进制表示是无限循环的,而计算机只能存储有限的位数,因此它们被近似表示。 在这个过程中,JavaScript中的数字(包括整数和小数)都是以64位双精度浮点数的形式处理和存储的。这意味着,当你在JavaScript代码中写一个数字时,它在内存中的表示形式是64位双精度浮点数,而不是直接作为十进制数存储.

64位双精度浮点数

64位双精度浮点数是一种浮点数的表示方式,它遵循IEEE 754标准。这种表示方式使用64位(即8字节)来存储浮点数,包括以下几个部分:

  1. 符号位(Sign bit) :1位,表示数值的正负,0表示正数,1表示负数。
  2. 指数位(Exponent bits) :11位,用来存储指数部分,采用偏移量(bias)的方式来表示实际的指数值。对于双精度浮点数,偏移量是1023(即2^10 - 1)。
  3. 尾数位(Mantissa or significand bits) :52位,用来存储尾数部分,即有效数字。在双精度浮点数中,尾数前面隐含了一个前导的1(对于规格化的数),因此实际能够表示的有效数字位数是53位。

JavaScript 使用的是 64 位双精度浮点数,它能够精确表示的数字是有限的。由于尾数位是52位,加上隐含的前导1,我们可以认为它有53位的有效数字。大约是15到17位十进制有效数字,超过这个范围的数字可能会失去精确性。

看源码前,预先了解:

科学计数法: 一种表示非常大或非常小的数字的方法,它将数字表示为一个1到10之间的数乘以10的幂。在JavaScript中,科学计数法通常用于表示超出Number类型正常范围的数字。

例如,数字123456789可以表示为科学计数法:

1.23456789e8

这里,1.23456789是1到10之间的数,e8表示10的8次方。因此,1.23456789e8等于1.23456789 * 10^8

同样,非常小的数字也可以用科学计数法表示。例如,0.000000123可以表示为:

1.23e-7

这里,1.23是1到10之间的数,e-7表示10的-7次方。因此,1.23e-7等于1.23 * 10^-7

在JavaScript中,当数字太大或太小时,它们会自动转换为科学计数法。例如:

let largeNumber = 12345678901234567890;
console.log(largeNumber); // 输出:1.2345678901234568e+19

let smallNumber = 0.000000000123;
console.log(smallNumber); // 输出:1.23e-10

np的实现思路

(1)辅助函数:用于取出待计算数字的小数部分长度(包含科学计数法的处理,用小数长度减去指数,就是真实的小数长度)

function digitLength(num: NumberType): number {
  // Get digit length of e
  const eSplit = num.toString().split(/[eE]/);
  const len = (eSplit[0].split('.')[1] || '').length - +(eSplit[1] || 0);
  return len > 0 ? len : 0;
}

(2)辅助函数:将number转化为15精度的number,通过toPrecision转字符串,再解析为浮点数

function strip(num: NumberType, precision = 15): number {
  return +parseFloat(Number(num).toPrecision(precision));
}

(3)辅助函数:用于将科学计数法的number,转为一个没有小数部分的固定整数

function float2Fixed(num: NumberType): number {
  if (num.toString().indexOf('e') === -1) {
    return Number(num.toString().replace('.', ''));
  }
  const dLen = digitLength(num);
  return dLen > 0 ? strip(Number(num) * Math.pow(10, dLen)) : Number(num);
}

(4)辅助函数 扩展能力 支持多个参数进行计算

function createOperation(operation: (n1: NumberType, n2: NumberType) => number): (...nums: NumberType[]) => number {
  return (...nums: NumberType[]) => {
    const [first, ...others] = nums;
    return others.reduce((prev, next) => operation(prev, next), first) as number;
  };
}
转为js:
function createOperation(operation) {
  return function(...nums) {
    const [first, ...others] = nums;
    return others.reduce((prev, next) => operation(prev, next), first);
  };
}

这段代码定义了一个名为 createOperation 的函数,它接受一个参数 operation,这个参数是一个函数,接受两个数字类型的参数(NumberType)并返回一个数字类型的结果(number)。createOperation 返回一个新的函数,这个新函数接受任意数量的数字类型参数(...nums),并返回一个数字类型的结果。 工作原理:

  1. 首先,它将传入的参数数组 nums 分解为第一个元素 first 和其余元素 others
  2. 然后,它使用 reduce 方法对 others 数组中的元素进行累加操作,累加的基础值是 first。对于 others 中的每个元素 next,它都会调用传入的 operation 函数,将当前的累加结果 prev 和 next 作为参数传递给 operation,并更新累加结果。
  3. 最终,reduce 方法返回的累加结果被返回,这个结果是一个数字。 这个函数的目的是创建一个新的函数,这个新函数可以将任意数量的数字通过传入的 operation 函数进行组合计算。

(5)加减计算,得到小数长度n,number* n*10(幂计算Math.pow),便可以转化为整数,计算后,再换算回来;Math.max用于指定小数长度较大的number,确保换算为整数

加法

const plus = createOperation((num1, num2) => {
  // 取最大的小数位
  const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2)));
  // 把小数都转为整数然后再计算
  return (times(num1, baseNum) + times(num2, baseNum)) / baseNum;
});

减法

const minus = createOperation((num1, num2) => {
  const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2)));
  return (times(num1, baseNum) - times(num2, baseNum)) / baseNum;
`});`

(6)乘除计算,依然是依靠小数长度,转为整数计算后,换算回来

乘法

const times = createOperation((num1, num2) => {
  const num1Changed = float2Fixed(num1);
  const num2Changed = float2Fixed(num2);
  const baseNum = digitLength(num1) + digitLength(num2);
  const leftValue = num1Changed * num2Changed;

  checkBoundary(leftValue);

  return leftValue / Math.pow(10, baseNum);
});

除法

const divide = createOperation((num1, num2) => {
  const num1Changed = float2Fixed(num1);
  const num2Changed = float2Fixed(num2);

  checkBoundary(num1Changed);
  checkBoundary(num2Changed);

  // fix: 类似 10 ** -4 为 0.00009999999999999999,strip 修正
  return times(num1Changed / num2Changed, strip(Math.pow(10, digitLength(num2) - digitLength(num1))));
});