# JavaScript 中的数值

在 JavaScript 中的 Number 对象是经过封装能让你处理数字值的对象,其中数字的类型就是为双精度 IEEE 754 64 位浮点类型。

因此,JavaScript 能够准确表示的整数范围在 (-2^53, 2^53) 之间,超过这个范围,无法精确表示这个整数。

在解析序列化的 JSON 时,如果 JSON 解析器将它们强制转换为 Number 类型,那么超出此范围的整数值可能会被破坏。在工作中使用 String 类型代替,是一个可行的解决方案。

值得注意的是,最近推出的 BigInt 任意精度数字类型提供了一种方法来表示大于 $2^53 - 1$ 的整数,它可以表示任意大的整数。

# 位运算

除了一些常规的算术运算,在 JavaScript 中也支持一些按位操作:

操作名称 运算符 说明
按位与 & 两个操作数对应的位都是 1 时返回 1
按位或 | 其中一个操作数对应的位是 1 时就返回 1
按位异或 ^ 仅当其中一个操作数对应的位是 1 时就返回 1
按位非 ~ 按位取反,1 成为 0,0 变为 1
按位左移运算符 << 将第一个操作数向左移动指定的位数。向左被移出的位被丢弃,右侧用 0 补充
按位右移运算符 >> 将第一个操作数向右移动指定的位数。向右被移出的位被丢弃,拷贝最左侧的位以填充左侧
按位无符号右移运算符 >>> 将第一个操作数向右移动指定的位数。向右被移出的位被丢弃,左侧用 0 填充

在无符号右移中结果总是非负的,即使右移 0 个比特,结果也是非负的。

另外,由于按位非只在 -1 时返回 0,所以可以结合 indexOf() 方法来做逻辑判断,而按位与经常可以用来做去零操作。

# 0.1 + 0.2 != 0.3

为什么 0.1 + 0.2 在 JavaScript 中不等于 0.3?

事实上,所有采用双精度 IEEE 754 64 位浮点数类型的语言都存在这个问题。因为这并不是语言本身的问题,而是因为浮点数计算标准的要求。

在生活中,我们习惯使用的有十进制,另外还有八进制、十二进制、十六进制、六十进制等等,而计算机使用的是二进制。

所以,计算机在存储和计算像 0.1 和 0.2 这样的十进制的数字前要进行二进制转换。按照 IEEE 754 标准,十进制的 0.1 和 0.2 转换成二进制数值都会是无限循环的值:

0.1 -> 0.0001100110011001...(循环)
0.2 -> 0.0011001100110011...(循环)

根据 IEEE 754 双精度标准,尾数最多能存储 53 位有效数字,那么就必须在特定的位置进行四舍五入处理,最后的结果是:

0.1 -> 1.1001100110011001100110011001100110011001100110011010 * 2 ** -4
    -> 0000011110111001100110011001100110011001100110011001100110011010
0.2 -> 1.1001100110011001100110011001100110011001100110011010 * 2 ** -3
    -> 0000011111001001100110011001100110011001100110011001100110011010

可见两位数字在转换后都存在一定的精度损失,接下来就是运算之前的对阶。

# 对阶

所谓对阶就是将两个进行运算的浮点数的阶码对齐的操作,目的是为使两个浮点数的尾数能够进行加减运算。

当进行 Mx·2ExMy·2Ey 加减运算时,首先求出两浮点数阶码的差,即 ⊿E=Ex-Ey,将小阶码加上 ⊿E,使之与大阶码相等,同时将小阶码对应的浮点数的尾数右移相应位数,以保证该浮点数的值不变。

  • 对阶的原则是小阶对大阶,之所以这样做是因为若大阶对小阶,则尾数的数值部分的高位需移出,而小阶对大阶移出的是尾数的数值部分的低位,这样损失的精度更小。
  • ⊿E=0,说明两浮点数的阶码已经相同,无需再做对阶操作了。
  • 采用补码表示的尾数右移时,符号位保持不变。
  • 由于尾数右移时是将最低位移出,会损失一定的精度,为减少误差,可先保留若干移出的位,供以后舍入处理用。

对 0.1 和 0.2 来说,0b00001111100 比 0b00001111011 大 1,所以需要将 0.1 对应的尾数向右移一位:

0.1 -> 0000011110111001100110011001100110011001100110011001100110011010
    -> 0000011111001100110011001100110011001100110011001100110011001101

# 尾数求和

经过对阶之后,现在将其尾数与 0.2 的尾数进行直接相加。

需要注意的是经过右移之后 0.1 尾数中隐藏的已经出现了,而 0.2 尾数中隐藏的 1 还在,所以也要进行相加。

    0.1100110011001100110011001100110011001100110011001101
+   1.1001100110011001100110011001100110011001100110011010
    ______________________________________________________
   10.0110011001100110011001100110011001100110011001100111

# 规格化

规格化又叫做规格化数,是一种表示浮点数的规格化的表示方法,还可以通过修改阶码并同时移动尾数的方法使其满足这种规范。

规范中规定尾数部分用纯小数给出,而且尾数的绝对值应大于或等于 1/R,并小于或等于 1,即小数点后的第一位不为零。

针对上面计算的结果,我们需要进行右规操作(尾数右移一位,阶码 +1):

->
0000011111010011001100110011001100110011001100110011001100110100

在规格化过程中,由于尾数的位数溢出所以移除了最低位,并进行了四舍五入的操作,同样操作了精度损失。

# 判断

根据阶码判断浮点运算是否溢出。而我们的阶码 0b00001111101 即不上溢,也不下溢。

接着我们可以经过转换得到它的二进制形式:

$1.0011001100110011001100110011001100110011001100110100 * 2 ** -2$

然后再转化得到十进制的结果为:0.30000000000000004440892098500626,显然不等于 0.3。

# 处理方式

最常见的解决方案就是设置一个误差范围值,通常称为“机器精度”,对 JavaScript 的数字来说,这个值通常是 $2^-52$。从 ES6 开始,该值定义在 Number.EPSILON 中。

另外,对于一些精度要求不高的计算,你也可以调用 round() 方法四舍五入或者 toFixed() 方法保留指定的位数。

或者,将小数转为整数再做计算,最后再将结果缩小之前放大的倍数。

function getDecimalLen(val) {
  return (val.toString().split('.')[1] || '').length
}

function sum(num1, num2) {
  const multiple = Math.max(getDecimalLen(num1), getDecimalLen(num2))
  const expandPrecision = Math.pow(10, multiple)
  const result = (num1 * expandPrecision + num2 * expandPrecision) / expandPrecision
  return result
}

最后,你还可以借助一些第三方库,比如 bignumber.js (opens new window)mathjs (opens new window) 来进行处理。

# 两个大数字相加

在 BigInt 出现之前,在 JavaScript 中无法处理超大整数相加的情形,但是我们可以通过一些方法来实现:

function bigSum(num1 = 0, num2 = 0) {
  const valueArr1 = num1
    .toString()
    .split('')
    .map(Number)
    .reverse()
  const valueArr2 = num2
    .toString()
    .split('')
    .map(Number)
    .reverse()
  const resultLen = 1 + Math.max(valueArr1.length, valueArr2.length)
  const resultArr = new Array(resultLen).fill(0)
  let i = 0,
    up = 0,
    tmpItem = 0

  valueArr1.forEach((item, index) => (resultArr[index] = item))

  while (up || i < valueArr2.length) {
    tmpItem = i < valueArr2.length ? valueArr2[i] + resultArr[i] + up : resultArr[i] + up
    if (tmpItem > 9) {
      resultArr[i] = tmpItem % 10
      up = 1
    } else {
      resultArr[i] = tmpItem
      up = 0
    }
    i++
  }

  return Number(resultArr.reverse().join(''))
}

当然,如今通过 BigInt 也能进行处理。

# 参考