# 精读ECMAScript规范:截断

之所以要写这个,是因为之前遇到了一个老生常谈的问题:如何在JS中实现截断,也就是向0取整,也就是保留数字的整数部分?

JS使用的是基于IEEE 754的浮点数,这个大家都知道。IEEE 754所带来的浮点数精度问题(比如著名的0.1 + 0.2 != 0.3==还是===都是一样的,因为都是number),以及大整数的误差问题(比如著名的2**53 + 1 == 2**53),我们也都很清楚。关于这些问题,ES6以后提供了类似于Number.EPSILON、BigInt等一系列特性去解决这些问题,就不在这里赘述了。

首先需要说明的是,截断并不代表向下取整。比如对于-0.2,如果是截断,结果应该是0,而向下取整则是-1。事实上,截断的实现相当于这样:

function trunc(x) {
  return x < 0 ? Math.ceil(x) : Math.floor(x);
}

因为这个场景还是比较常用的,所以ES6提供了原生的Math.trunc来实现截断;当然了,这个有兼容性的问题,并不是所有浏览器都支持ES6(没错我说的就是IE)。

原生的实现有一个非常有趣的地方,如果参数本身是一个整数,那返回的结果也是整数。啥意思呢?意思是:

Math.trunc(20)   // 20
Math.trunc(20.0) // 20
Math.trunc(20n)  // Uncaught TypeError: Cannot convert a BigInt value to a number

只要这个数字事实上是一个number类型的整数(20.0事实上也是一个number类型的整数),返回值就是一个整数,这也是Math里很多函数的共同特点。不过,如果传入的参数是一个BigInt类型的整数,那么就会报错。虽然我们都知道,20n他就是一个整数,而且也在number的范围内,但类型不同就是不同,没办法执行。

事实上,规范里明确定义了“整数”的概念:

When the term integer is used in this specification, it refers to a Number value whose mathematical value) is in the set of integers, unless otherwise stated: when the term mathematical integer is used in this specification, it refers to a mathematical value which is in the set of integers.

也就是说,“整数”指的是number类型中的整数,而“数学整数”才是数学意义上的整数集;BigInt就属于“数学整数”,和普通的数字不是一个类型的,无法互操作。

我们看看规范里怎么定义Math.trunc的行为的:

Returns the integral part of the number x, removing any fractional digits. If x is already an integer, the result is x.

  • If x is NaN, the result is NaN.
  • If x is -0, the result is -0.
  • If x is +0, the result is +0.
  • If x is +∞, the result is +∞.
  • If x is -∞, the result is -∞.
  • If x is greater than 0 but less than 1, the result is +0.
  • If x is less than 0 but greater than -1, the result is -0.

规范里也强调了,该方法只适用于number类型。

事实上,截断的实现方式有很多种,其中流传最广的几种之一,就是位运算。比如:

const n = -1.23;
~~n;    // -1
n | 0;  // -1

但是我们刚才看到了规范里对trunc的定义,位运算只能实现其中的一部分。比如这里以~~为例:

~~NaN       // 0
~~+0        // 0
~~-0        // 0
~~+Infinity // 0
~~-Infinity // 0
~~-0.5      // 0
~~(2**31)   // -2147483648(-2**31)

我们可以看到,因为位运算的特性,特殊值的输出全是0,无法分辨。此外,因为位运算只支持32位,所以一旦超过32位有符号整数的范围,就会出现错误的结果(超过32位有符号整数的范围后,截取低32位)。

但是,有一个非常蹊跷的地方:为什么~~NaN会是0?如果大家对IEEE 754比较了解,应该会记得:

img

因为JS的位运算是截取低32位,所以~~Infinity为0是可以理解的,因为Infinity的低32位都为0。但是NaN的低32位并不一定全为0,并且NaN不是一个数,而是一个范围;为什么还是0呢?

规范上规定了这个行为的过程。还是以取反操作为例:

The abstract operation Number::bitwiseNOT takes argument x (a Number). It performs the following steps when called:

Let oldValue be ! ToInt32(x).Return the result of applying bitwise complement to oldValue. The result is a signed 32-bit integer.

相当于是先转换成32位的整数,然后再取反。而这个转换过程是这样的:

The abstract operation ToInt32 takes argument argument. It converts argument to one of 232 integer values in the range -231 through 231 - 1, inclusive. It performs the following steps when called:

  1. Let number be ? ToNumber(argument).
  2. If number is NaN, +0, -0, +∞, or -∞, return +0.
  3. Let int be the Number value that is the same sign as number and whose magnitude is floor(abs(number)).
  4. Let int32bit be int modulo 2^32^.
  5. If int32bit ≥ 2^31^, return int32bit - 2^32^; otherwise return int32bit.

所以,规范里就已经规定了NaN会被转换成0,0取反两次自然还是0。

不过,还是那个问题,为什么NaN会对应到0?我觉得简单一句“规定”并没有说服力。我觉得可能是这样:因为NaN不是一个整数概念,而是一个浮点数概念(来源于IEEE 754的浮点数定义)。它在−2^31^到2^31^ − 1的范围内找不到任何一个整数能表达这个概念,就只能委屈一下,跟0放在一起,因为它代表的是“不存在”。Infinity同理,因为在这个范围内找不到无穷大,就只能跟0放在一起,表达一个“不存在”的概念。

最后更新于: 6/25/2020, 2:10:06 PM