数字知多少?

某天早上阳光明媚,挺风和日丽的,刚嚼一口早餐就被同学问到一个奇怪的问题,大概情况是这样的:

1
2
3
4
5
var str = '7172328d6ddf0296e7e7d4a8';
var n = parseInt(str, 16);

// false ...
console.log(n.toString(16) === str);

一个巨大的16进值字符串转化成数字后再转化成对应的字符串就不相等了,实际输出的值还差得蛮远的… 这个问题的第一反应就是精度丢失啰,不过同学们可不是这么好打发的,都不说一个为什么怎能了事呢?那就让我们稍微挖一挖,看看究竟是为什么吧~

根据规范 JavaScript 中的数字都是以64位双精度浮点类型存储,遵循IEEE 754标准。

双精度浮点是个什么货?简单的说就是用科学记数法的形式来表示数字,比如数 N 可以表示为 x * 10 ^ y ,相应的,13000 就可以表示为 1.3 * 10 ^ 4,通过这种方式可以使用有限的存储空间表示很广的值域,比如限定 x 与 y 是两位数,那么这四位数字可以表示的数值范围是 0 ~ 9.9 * 10 ^ 99(99后面98个零 够大吧…)。但同时有限的存储空间必定会导致精度缺失,比如 123 这个数虽然在能被表示的数值范围内,但是却不能被精确的表示,只能近似的表示为 1.2 * 10 ^ 2。这其实就是之前那个问题的答案:在将字符串转化成数字时,由于64位存储空间的限制,只能近似表示为另外一个相近的数,造成了精度的丢失。

进一步看,采用科学记数法的64位双精度浮点类型的内存布局情况如下:

从高位到低位依次是 符号位(1位)指数位(11位)尾数位(52位)

符号位顾名思义是表示正负号的,指数位控制小数点的位置,而尾数就是具体的数值,不过它只表示小数部分,实际并不存储整数部分,而是默认整数部分为1。原因嘛,二进制下,整数部分不是1就是0,而对于0的情况可以使用指数部分为负值来控制,所以默认整数部分为1即能保证精度又能减少需要的存储空间。

另外这里的指数并非实际的指数,而是一种编码指数:编码指数 = 实际指数 + 固定值。根据规范,这个固定值是由编码指数的位数 e 来确定的:2 ^ (e - 1) - 1,具体到11位的编码指数的话这个固定值是 2 ^ (11 - 1) - 1 = 1023。由于11位的编码指数的取值范围是 0 ~ 2047(2 ^ 11 - 1),因此实际指数的取值范围是 -1023 ~ 1024 。注意:根据标准,这里的编码指数取值是开区间 (0, 2047)。

令 s = 符号位,e = 编码指数,m = 尾数,则64位双精度浮点格式二进制数的计算公式为:

N = ((-1) ^ s) * (1 + m * 2 ^ (-52)) * 2 ^ (e - 1023)

其中数值部分那个独立的 1 就是那个默认的整数部分,m * 2 ^ (-52) 是尾数表示的那个小数。

关于最大的准确整数

之前提过由于存储空间有限,会有精度的问题。那么64位的浮点能存储的最大精确的整数是多少呢?

指数部分可取的最大值是 1023,即可以将小数点后移1023位,尾数只有52位,所以当编码指数为 1075 (1023 + 52)时正好没有小数部分,整部部分一共53位(52位尾数 + 1位的默认值),所以 2 ^ 53 - 1 = 9007199254740991 就是最大能精确表示的正整数。

怎么来证明呢?其实 Number 有一个常量叫 Number.MAX_SAFE_INTEGER

通过重新编译v8,使其支持调试,我们能查看到运行时的 JavaScript 内存布局:

红色划线部分为实际存储的值:0x433fffffffffffff(x86 及其兼容的体系都是使用小端法表示字节序滴,所以需要反转一下),换成二进制是这样滴:

最高位符号位为0,其次11位的编码指数为:100 0011 0011,也就是 1075,而剩下的尾数位全为1~

大于最大的准确整数会怎样?

当然是精度丢失啰,当 Number.MAX_SAFE_INTEGER + 1 时尾数部分全变成0,编码指数位加1,实际指数为53,相当于有54个整数位(别忘了那个默认的1),这已经超出了整个尾部的长度(52位),低位已经不受控制了(相当于开始在尾部数后面加零了…),所以实际上 Number.MAX_SAFE_INTEGER + 1 == Number.MAX_SAFE_INTEGER + 2。

最大能表示的值

就是除了符号位外,所有都为1啰(当然编码指数部分的最大取值只能是2046):

根据之前的公式:((-1) ^ s) * (1 + m * 2 ^ (-52)) * 2 ^ (e - 1023)
= (-1) ^ 0 * (1 + (2 ^ 52 - 1) * 2 ^ (-52)) * 2 ^ (2046 - 1023)
= (1 + (Math.pow(2, 52) - 1) * Math.pow(2, -52)) * Math.pow(2, 2046 - 1023)
= 1.7976931348623157e+308

同样滴,也有一个叫 Number.MAX_VALUE 的东东:

实际内存布局:

相应的二进制表示:

关于零

由于默认有一个为1的整数位,那双精度浮点数怎么表示零呢?之前有提到编码指数取值是(0, 2047)的开区间,实际这种情况被称之为“规格化值”,而编码指数为0时被特殊称之为“非规范化值”。对于非规范化值规范特殊规定不再默认有一个为1的整数位,而且实际指数调整为:实际指数 = 1 - 固定值。所以只要此时尾数也为0就能真正的表示数零了~(当然还有符号,实际上在某些时候正负零是有区别的…)。另外由于非规范化值的实际指数是一个常量:实际指数 = 1 - 1023 = -1022,所以除了表示零,非规范化值还能表示非常接近于零的数。至于为啥实际指数被指定为 1 - 固定值,是为了让非规格化值与规格化值之间能平滑过渡,这么规定以后最大的非规范化值与最小的规范化值(此处都是指正数范围)的实际指数都为-1022,而两者的数值部分正好相差1(一边儿是52个的1,另一边儿是52个的0加一个默认的1)~

最后,编码指数还有一种特殊的取值是全为1,规范规定此时如果尾数全为0则表示无穷(同样,算上符号位有正负无穷之分),而对于尾数不为0的值则称之为“NaN”。至此,无穷+规范化值+非规范化值 就能表示一个完整的数值域了~

参考

知识共享许可协议