JavaScript是怎样编码数字的[How numbers are encoded in JavaScript]
在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 数字系列 , 它包含: