至今 JavaScript 已经有 6 种基本类型 了,其中数字类型(Number)是表示数字的唯一方法。 目前其标准维护在 ECMA262,在 JavaScript 语言层面不区分整数与浮点、符号与无符号。 所有数字都是 Number 类型,统一应用浮点数算术。 由于 JavaScript 中无法访问低层的二进制表示,而且 64 位可表示范围非常大,不容易遇到和了解到边界情况。 这篇文章对 JavaScript Number 的二进制表示进行简要的介绍,主要明确使用者观察到的边界, 解释 MAX_VALUE, MIN_VALUE, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, EPSILON 这些常量取值的原因;回答 POSITIVE_INFINITY, NEGATIVE_INFINITY, NaN 这些常量的表示方法。

二进制表示

JavaScript 数字占 64 位,与 C++ 中的 double 类型一样,采用 IEEE 754 规范的 双精度浮点数。 字节分配如下图,从高位到低位依次是一个符号位(sign)、11个指数位(exponent)、52个分数位(fraction)。

double float representation

这样一个数字它的值等于:

\[(-1)^{sign}(1+\sum_1^{52}{b_{52-i}2^{-i}})\times 2^{e-1023}\]

为方便讨论,下文使用简写:

\[(-1)^{sign}\times 1.fraction\times 2^{e-1023}\]

为了表示负的指数,指数部分存在一个偏移量 -1023。 对于非零值第一位有效位始终为 1,因此二进制表示中省略了这个 1,分数位分别表示 1/2, 1/4,1/8,…。 上式表示的浮点数称为 normal number, 特殊值(0, NaN, Infinity)和 subnormal number 不同于上述公式, 见下文

normal number

语法:指数部分 $1 \leq e \leq 2046$ 的值会被解析为 normal number。 0 和 2047 分别被用于 subnormal number 和特殊值,见下文

概念normal number 只表示非零值, 并规定省略第一个非零的 1,significant(有效位数)部分可以多一位精度。 指数部分的最大值为 2046,因此 normal number 的最大值 (也是 Number 的最大值Number.MAX_VALUE 的值)为:

\[1.1111111...\times 2^{2046-1023} = (2-2^{-52}) \times 2^{1023} \approx 1.7976931348623157 \times 10^{308}\]

这个最大值略小于 $2^{1024}$,相差 $2^{971}$。 Math.EPSILON 表示大于 1 的最小浮点数与 1 的差, 它的值等于:

\[1....0001 \times 2^{1023-1023} - 1....0000 \times 2^{1023-1023} = 2^{-52} \times 2^0 = 2^{-52} \approx 2.220446049250313 \times 10^{-16}\]

normal number 的最小值(也是 Number 的最小值Number.MIN_VALUE 的值) 为上述最大值的负值,只变化符号位:

\[-1.1111111...\times 2^{2046-1023} = -(2-2^{-52}) \times 2^{1023} \approx -1.7976931348623157 \times 10^{308}\]

normal number 的指数部分最小值为 1, fraction 部分最小为 0,significant(有效位数)部分最小为 1, 因此 normal number 能够表达的最小正数为:

\[1 \times 2^{1−1023} = 2^{-1022} \approx 2.2250738585072014 \times 10^{−308}\]

注意后四位小数是 2014 哈哈,normal number 能够表达的最大负数也是上面的大小,符号位变负。

subnormal number

语法:指数部分 $e = 0$,且分数部分 $fraction \neq 0$ 的值会被解析为 subnormal number

概念:normal number 省略前导的 1 虽然能够多一位有效位数, 但首位有效数字必须为 1 也限制了最小正数的大小。 subnormal number 就是来弥补 0 与 1 之间的取值的。 它规定指数部分全零且没有前导 1,但计算时采用 -1022 作为指数(等效 e=1), 一个 subnormal number 的值由以下公式给出:

\[(-1)^{sign}\times 0.fraction\times 2^{-1022}\]

因此最大的 subnormal number 为:

\[0.1111111... \times 2^{−1022} = (1-2^{-52}) \times 2^{-1022} \approx 2.2250738585072009\times10^{−308}\]

它与最小的正 normal number($2^{-1022}$)相差 $2^{-1074}$。 最小的 subnormal number 与它大小相同,符号为负。

最小的正 subnormal number(也是 Number 能够表示的最接近 0 的数值),它的值为:

\[2^{-52} \times 2^{-1022} = 2^{-1074} \approx 4.9 \times 10^{−324}\]

spetial values

0

语法:指数部分 $e = 0$,且分数部分 $fraction = 0$ 的值会被解析为零。

概念:根据 $sign$ 的取值,有 $+0$, $-0$ 两种 0 的表示。

Infinity

语法:指数部分 $e = 11111111111_2$,且分数部分 $fraction = 0$ 的值会被解析为 $\infty$。

概念:根据 $sign$ 的取值,有 Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY 两个值。

NaN

语法:指数部分 $e = 11111111111_2$,且分数部分 $fraction \neq 0$ 的值会被解析为 NaN

概念:由于不限定分数部分取值,NaN 值有很多种表示。

符号位可以取任意值($2$ 种),分数只是不可取零($2^{52} - 1$), 因此共有 $(2^{52}-1)\times 2 = 2^{53} - 2$ 种。

整数的表示

所有的非零整数都属于 normal number(0 属于特殊值,见下文), 它们的指数部分刚好能够把所有分数移出到小数点左侧,数学地表示为:

\[2^{e-1023} \times 2^{-l} \geq 1 \Rightarrow e \geq 1023 + l\]

其中 $l$ 为最后一个非零的分数下标,起始 $l = 1$。例如, 1 的 $l = 0, e = 1023, fraction = 0$, 3 的 $l = 1, e = 1024, fraction = 1000…_2$, 5 的 $l = 2, e = 1025, fraction = 0100…_2$。 这些整数中可以连续、准确地表示的那些整数称为 安全的整数, 例如 $2^{100}$ 是不安全的,因为它和 $2^{100} + 1$ 具有完全相同的表示:$e = 1123, fraction = 0$, 这导致在 JavaScript 中,

Math.pow(2, 100) === (Math.pow(2, 100) + 1)

最大的安全整数(即 Number.MAX_SAFE_INTEGER 的值) 是 52 位分数都刚好用到的情况,此时 $l = 52 \Rightarrow e = 1023 + l = 1075$, 加省略的前导 1 共有 53 个 1,它的值为:

\[111...(共 53 个)...111_2 = 2^{53} - 1 = 9007199254740991\]

同样地,符号位取 1 即可得到 最小的安全整数,也就是 Number.MIN_SAFE_INTEGER 的值:

\[-2^{53} + 1 = -9007199254740991\]

对于不安全的整数或其他未能精确表示的浮点数,会选择最接近的一个可以精确表示的值, 如果存在两个同样接近的值,IEEE 754 binary64 提供了 ties to even 和 ties to odd 两种 Rounding 方式。 JavaScript Number 实现的浮点数 Rounding 方式是 Round to nearest, ties to even。 我们来观察一下最大安全整数附近的 Rounding 方式:

console.log(Number.MAX_SAFE_INTEGER);
// sign=0, fraction=9007199254740991(52个1), e=1023+52
// 输出 9007199254740991,这个值就是 MAX_SAFE_INTEGER = 2^53 - 1

console.log(Number.MAX_SAFE_INTEGER+1);
// sign=0, fraction=0(全0), e=1023+53
// 输出 9007199254740992,这个值是精确的 2^53

console.log(Number.MAX_SAFE_INTEGER+2);
// sign=0, fraction=0(全0),e=1023+53
// 输出 9007199254740992,这个值仍然等于 2^53,不等于 2^53 + 1

我们来考虑 Number.MAX_SAFE_INTEGER + 2 的表示方式。它是一个奇数,它的二进制值共 54 位:$1000…(共52个0)…0001$, 分数加省略的前导 1 共 53 位,因此最后一个 1 无法表示出来。 这时可以选 $1000…000…0010$(最后一个零不存)和 $1000…000…0000$(最后一个零不存)两个同样接近的值, 根据 ties to even 策略,选择后者让 fraction 部分变成偶数。 就得到了与 Number.MAX_SAFE_INTEGER + 1 同样的值:$2^{53}$。

所以为什么不用四舍五入呢?因为四舍五入中每次遇到中间值时总是“入”的,在累加时会放大误差; 选择绑定到最近的奇数/偶数则会两两抵消,避免误差放大。

一些讨论

Number 一共多少种值?

Number 使用64位双精度浮点数实现,根据指数部分的值来区分不同的表示法。

  • 指数为 0
    • 分数为 0 表示 0,正负共 $2$ 个
    • 分数不为 0 表示 subnormal numbers,共 $(2^{52}-1)\times 2 = 2^{53} - 2$ 个
  • 指数为 2047(全1)
    • 分数为 0 表示 $\infty$,正负共 $2$ 个。
    • 分数不为 0 表示 NaN,共 $(2^{52}-1)\times 2 = 2^{53} - 2$ 个
  • 指数为其他值,表示 normal numbers,共 $2 \times 2^{52} \times (2^{11}-2) = 2^{64} - 2^{54}$ 个

加起来共有 $2^{64}$ 种值(当然 64 位嘛),减去重复的 NaN ($\pm 0$ 是不重复的,它们作除数时会分别得到 $\pm \infty$), Number 能够表示的不重复的值 有:

\[2^{64} - (2^{53} - 2 - 1) = 2^{64} - 2^{53} + 3\]

Number 精度到底如何?

所以 浮点数的精度如何 呢?精度取决于连续两个双精度浮点数之间的差,这个差取决于指数的大小。

  • 对于 normal number(绝对值大于等于 $2^{-1022}$)来讲,指数越大(通常数字越大)精度越小,1 附近的精度由 Number.EPSILON 给出(见上文);
  • 对于 subnormal number(绝对值小于 $2^{-1022}$)来讲,指数是固定的,精度是确定的 $2^{-1074}$;

Number 转换为 32 位整数

虽然 Number 都适用浮点数运算(Floating point arithmetic),但有些运算符和方法只支持 32 位整数。 这时会进行 JavaScript 类型转换, 这于 <<, >>, >>>, |, &, parseInt(), Atomics.wait() 等操作, 会先调用 ToInt32 转换类型:把 64 位 Number 先转换为整数(abs 后再 floor), 再取其低 32 位作有符号 32 位整数解释(即第一位被当做符号位,以 two's complement 解释)。 例如:

console.log(Math.pow(2, 100) << 2)

输出为 0,因为 $2^{100}$ 的低 32 位全零,解释后的结果为 0,左移一位仍然为 0。

console.log((Math.pow(2,50) - 1) << 1)

输出为 -2,因为 $2^{50} - 1$ 的低 32 位全1,解释后的结果为 -1,左移一位右侧补 0 得到 -2。

本文采用 知识共享署名 4.0 国际许可协议(CC-BY 4.0)进行许可,转载注明来源即可: https://harttle.land/2018/06/29/javascript-numbers.html。如有疏漏、谬误、侵权请通过评论或 邮件 指出。