JavaScript是怎样编码数字的[How numbers are encoded in JavaScript]

7

在JavaScript中所有的数字都是浮点数,本篇文章将介绍这些浮点数在JavaScript内部是怎样被转为64位二进制的。
我们会特别考虑整数的处理,所以读完本篇之后,你会理解为什么会有以下结果发生:

> 9007199254740992 + 1
9007199254740992
> 9007199254740992 + 2
9007199254740994

1. JavaScript的数字

JavaScript数字全部是浮点数。 根据 IEEE 754标准中的64位二进制(binary64), 也称作双精度规范(double precision)来储存。从命名中可以看出,这些数字将以二进制形式,使用64个字节来存储。这些字节按照以下规则分配:

0 - 51 字节是 分数f(fraction )
52 - 62 字节是 指数(exponent )
63 字节 是 标志位 (sign)
标志位 (s, sign) 指数(e, exponent ) 分数(f, fraction )
(1 bit) (11 bit) (52 bit)
63 62 51
52 0

他们按照以下规则表示一个数字: 如果标志位是0, 表示这个数字为正数,否则为负数。粗略来说,分数f用来表示数字的‘数码’(0-9),指数表示这个数字的‘点’在哪里。接下来我们会使用二进制(虽然这并不是通常的浮点数表示方式)。并用一个%作为前缀来标识。虽然JavaScript数字是以二进制保存的,但输出(打印)时通常是以10进制显示. 接下来的例子,我们也会沿用这一规则。

2. 分数f

下表是一种表示非负浮点数的方法:
尾数 (小数点后面的数,significand 或 mantissa ) 以自然数字的形式保存‘数码’,指数决定需要往左(负指数)或者右(正指数)移多少位。再忽略位数,这个JavaScript数字就是 有理数1.f乘以2p。
译者注: 这里指数用p而不是e来表示是因为e是一个偏移量,第三点会详细说明

比如以下例子:

f = %101, p = 2 Number: %1.101 × 22 = %110.1
f = %101, p = −2 Number: %1.101 × 2−2 = %0.01101
f = 0, p = 0 Number: %1.0 × 20 = %1

2.1 表示一个整数

需要多少位来编码一个整数呢? 尾数共有53个数码,1个在‘点’的前面,52个在后面,如果p=52,我们就有一个53位的自然数,现在的问题是最高位总是为1,也就是说我们不能随便的使用所有的位。要去掉这个限制,我们需要2步,首先. 如果需要最高位是0,第二位是1的53位的数字,将p设置为51,这时分数f最低位变成了‘点’后面的第一个数码,也就是整数0。按照这个规律,直到指数p=0,分数f=0,这就是数字1的编码。

52 51 50 ... 1 0 (bits)
p=52 1 f51 f50 ... f1 f0
p=51 0 1 f51 ... f2 f1 f0=0
... ... ... ... ... ... ... ...
p=0 0 0 0 ... 0 1 f51=0, etc.

其次,对于完整的53位数字,我们还需要表示0,我们将在下一段详细介绍。
需要注意的是,我们可以表示完整的53位整数,因为标志位是另外储存的。

3. 指数e

指数占11位,它可以表示0-2047(211-1), 为了支持负指数,JavaScript使用偏移二进制来编码: 1023表示0,小于它的为负,大于它的为正。这就意味着,减去1023才能得到正常点数字。因此我们之前使用的变量p就等于e-1023,也就是尾数乘以2e-1023
例如:

    %00000000000     0  →  −1023  (最小的数字)
    %01111111111  1023  →      0
    %11111111111  2047  →   1024  (最大的数字)
                         
    %10000000000  1024  →      1
    %01111111110  1022  →     −1 

如果需要一个负数,只需要颠倒一下它的位数,再减一

3.1 特殊的指数

有2个指数是保留位。最小的0,和最大的2047. 指数2047表示无穷大(infinity)和 NaN(非数字)值。IEEE 754标准有很多非数字值, 但是JavaScript把他们都表示为NaN。指数为0时有两个意思。1. 如果分数f也是0,表示这个数字就是0.因为标志位是单独存储的。所以我们有+0和-0;

然后指数0也可以用来表示非常小的数字(接近0)。此时分数f必须为非0,而且,如果这个数字是由%0.f × 2−1022算出来的,这个表示方式叫做非规范化,而之前我们讨论的表示方式叫规范化。最小的非0正数可以被规范化为: %1.0 × 2−1022。 最大的非规范化数字为: %0.1 × 2−1022, 所以,从规范化到非规范化是过渡是平滑的。
译者注: 规范化就是把小数点放在第一个非零数字的后面

3.2 总结:

(−1)s × %1.f × 2e−1023 normalized, 0 < e < 2047
(−1)s × %0.f × 2e−1022 denormalized, e = 0, f > 0
(−1)s × 0 e = 0, f = 0
NaN e = 2047, f > 0
(−1)s × ∞ (infinity) e = 2047, f = 0

当p = e − 1023, 指数的范围是−1023 < p < 1024

4. 十进制分数

不是所有的十进制分数都能够非常精确的表示, 例如:

> 0.1 + 0.2
0.30000000000000004

0.1和0.2都不能够被精确的表示成二进制浮点数。但是这个偏差通常非常非常小,小到不能够被表示出来,加法可以使这个偏差变得可见:

> 0.1 + 1 - 1
0.10000000000000009

表示0.1相当于表示一个分数110,难的部分在于分母是10,10素数分解是2*5. 而指数只能分解2,所以没有办法得到5。相同的, 1/3也不能被精确表示成一个十进制分数,它大概能被表示成0.333333。
但相对的。要用十进制表示一个2进制分数却是永远可行的,值需要使用足够的2(每个10都有1个2)。

%0.001 = 1/8 = 1/2 × 2 × 2 = 5 × 5 × 5/(2×5) × (2×5) × (2×5) = 125/10 × 10 × 10 = 0.125

4.1 对比十进制分数

因此,当你要处理10进制分数,不要直接去比较他们,先想一想,它可能会有一个上限,比如有一个上限叫做机器最小数 machine epsilon. 标准的双精度数的最小数为 2−53.

var epsEqu = function () { // IIFE, keeps EPSILON private
    var EPSILON = Math.pow(2, -53);
    return function epsEqu(x, y) {
        return Math.abs(x - y) < EPSILON;
    };
}();

这个方法可以修正你的比较结果

> 0.1 + 0.2 === 0.3
false
> epsEqu(0.1+0.2, 0.3)
true

5. 最大的整数

“x 是最大的整数”这句话是什么意思呢?它的意思是说,任意整数n在 0 ≤ n ≤ x 范围内都是可以被表示的。也就是说如果大于x,将无法表示。比如253 。任何比它小的数字都可以被表示。

> Math.pow(2, 53)
9007199254740992
> Math.pow(2, 53) - 1
9007199254740991
> Math.pow(2, 53) - 2
9007199254740990
但比它大的就不行
> Math.pow(2, 53) + 1
9007199254740992

关于253 这个上限,有一些很令人惊奇的表现。我们将用一些问题来解释这些现象。你要记住的是,这个上限是分数f的上限,指数e部分其实还有空间。

为什么是53位呢?你有53位来表示数的大小,除去标志位。但是分数f却是由52位组成的,这是为什么呢。从前面的文章可以看出,指数e从第53位开始,它会移动分数f,所以这个53位的数字(除了0)可以被表示出来,并且有一个特别的数字去表示0(并且分数f也是0).

为什么最大的数不是253−1? 通常来说,x位就说明最小数是0,最大值是2x−1. 比如8位数字最大是255。而在JavaScript里,最大的分数f确实是253−1,但253 也可以被表示出来,因为有指数e的帮助。它只要让分数f等于0,指数e等于53即可。

%1.f × 2p = %1.0 × 253 = 253

为什么大于253就不能表示了呢?例如:

> Math.pow(2, 53)
9007199254740992
> Math.pow(2, 53) + 1  // not OK
9007199254740992
> Math.pow(2, 53) + 2  // OK
9007199254740994
> Math.pow(2, 53) * 2  // OK
18014398509481984

253×2 可以表示正确,因为指数e还可以用,乘以2仅仅需要指数e加一,而不影响分数f。所以乘以2的幂不是问题,只要分数f没有超过上限,那为什么2加253也可以表示正确,1却不可以呢,我们扩大一下之前的,加上53 和54位来看看。

54 53 52 51 50 ... 2 1 0 (bits)
p=54 1 f51 f50 f49 f48 ... f0 0 0
p=53 1 f51 f50 f49 ... f1 f0 0
p=52 1 f51 f50 ... f2 f1 f0

看p=53的那一行,它应该是一个JavaScript数字,53位设置成了1,但是因为它的分数f只有52位,而0位必须位0,而只有253 ≤ x < 254中的偶数数字x可以被表示。在p=54时,这个空间增加到乘以4,在 254 ≤ x < 255: 中。

> Math.pow(2, 54)
18014398509481984
> Math.pow(2, 54) + 1
18014398509481984
> Math.pow(2, 54) + 2
18014398509481984
> Math.pow(2, 54) + 3
18014398509481988
> Math.pow(2, 54) + 4
18014398509481988

6. IEEE 754 的例外

IEEE 754标准描述了5中例外 , 当出现这些例外,就无法算出准确的数字。

1. 无效 : 进行一个无效操作。例如,给一个负数开平方,返回NaN

> Math.sqrt(-1)
NaN

2. 除以0 : 返回正或者负的infinity(无穷大)

> 3 / 0
Infinity
> -5 / 0
-Infinity

3. 溢出(overflow) : 结果太大,无法表示。这时是指数已经太大, (p ≥ 1024).根据标志位,正或者负溢出,返回正或者负的infinity(无穷大)。

> Math.pow(2, 2048)
Infinity
> -Math.pow(2, 2048)
-Infinity

4. 潜流(underflow): 结果太接近于0,这时是指数已经太小(p ≤ −1023). 返回一个非规范化的数字,或者0.

> Math.pow(2, -2048)
0

5. 不精确(Inexact): 一个操作返回不精确的结果 - 有太多有意义的数字需要分数f去存,那就返回一个四舍五入的结果

> 0.1 + 0.2
0.30000000000000004
    
> 9007199254740992 + 1
9007199254740992

上面的第三点和第四点是关于指数的,第五点是关于分数f的,第三点和第五点的差别非常小,第五点的第二个例子,我们已经接近了分数f的最大值(这也可以算是一个溢出操作)。但根据 IEEE 754只有超过了指数的范围才算溢出。

7. 结论

本篇文章中,我们观察了JavaScript是怎样把浮点数存进64位中的。它之所以这么做是根据 IEEE 754 标准中的双精度。因为我们常常忘记,JavaScript对于分母质因分解不仅包含2的数字 是无法精确表示的。比如0.5(1/2),是可以精确表示的,但0.6(3/5)就不能。我们很容易忘记一个整数是由标志位,分数f,指数3部分组成,然后就会面对Math.pow(2, 53) + 2 可以计算正确,而Math.pow(2, 53) + 1会计算错误的问题。

8. 资源和引用

• “IEEE Standard 754 Floating-Point” - Steve Hollasch.
• “Data Types and Scaling (Fixed-Point Blockset)” in the MATLAB documentation.
• “IEEE 754-2008” on Wikipedia

本文也同时是JavaScript 数字系列 , 它包含:

  1. JavaScript中的数字显示
  2. JavaScript中的NaN 和 Infinity
  3. JavaScript的两种0

你可能感兴趣的

刘珂杰 · 1 小时前

有点难啊

回复

载入中...