JavaScript里的数字问题

1,109 阅读5分钟

首先来说一个真实的案例。

某天测试提了个bug,大致是说编辑一个数据的时候,提示成功了但实际上没有生效。我一看是数据问题,心想那必然是后端的锅啊,直接就甩了出去。过了三分钟,后端又甩回来了,说你数据提交时传的id根本不存在啊!不是我传给你的那个id啊!

他说的那个id长这个样子:

{
  "id": 1663029898857414656,
  ...
}

我心想我还能害你啊?谁没事儿会手贱去改这个id啊?结果我抓了个包一看,传回去的id还真跟拿到的id不一样。

传回去的id长这样:

{
  "id": 1663029898857414700,
  ...
}

我心想真是见了鬼了,一怒之下打了个log:

const n = 1663029898857414656;
console.log(n);  // 1663029898857414700

结果还真实出乎意料,打出来的数字和声明的数字不一样。于是我开始了google之旅,得到的结果大致是这样的:

JavaScript里的数字都是float64,后端传过来的int64类型的数字,在大到一定程度的时候就会有精度丢失。

那么这个“大到一定程度”是多大呢?先说结果,是9007199254740991。这个数字是怎么算出来的呢?我们接下来就来一步一步地拆解。在这里首先声明,本文是综述,没有原创发明的部分,使用的图片也都来自网络,版权归原作者所有。

float64js里是怎么存储的呢?这里其实也不是js的发明,而是遵循通用的IEEE 754(二进制浮点数算术)标准。这个标准是怎么规定的呢?考虑到float64太过复杂,我们先来一个float14

上图是虚拟的float14在内存中的存储方式。整体分为三段,第一段是符号位,第二段是指数位,第三段是尾数位。什么是指数位?什么是尾数位呢?这里要先说科学计数法。

科学记数法最早由阿基米德提出。在科学记数法中,一个数被写成一个实数a,与一个10的n次幂的积。在电脑或计算器中一般用e来表示10的幂,比如1.7e1,实际上就是17。

好了,在1.7e1这个科学计数法里,1就是指数,1.7就是尾数。那么这个数字在float14里怎么存储呢?我们需要先把十进制转化成二进制:

0d17 * 0d10^0d0 
  => 0b10001 * 0b10^0b0 
  => 0b0.10001 * 0b10^0b101

0d0b的意义不做赘述。按照IEEE 754的标准放到内存里,就变成了以下形式:

看上去so easy对吧!好,我们再来一个0.25练练手。首先我们也把0.25转换成二进制:

0d0.25 
  => 0d1 *  0d2^(-0d2) 
  => 0b1 * 0b10^(-0b10) 
  => 0b0.1 × 0b10^(-0b1)

现在问题来了,我们的指数位是负的,那该怎么存呢?按照常规的想法,我们会引入一个符号位,但是IEEE 754标准没有这样做,而是使用了偏移指数的概念。所谓偏移指数,就是规定一个偏移值,比如16,实际的指数要加上这个偏移值再填写到指数部分,这样比16大的就表示正指数,比16小的就表示负指数。要表示0.25,指数部分应该填16-1=15。这样一来0.25就可以表示成:

这里仍然要先做二进制转换:

0d0.25 
  => 0d1 *  0d2^(-0d2) 
  => 0b1 * 0b10^(-0b10)
  => 0b0.1 × 0b10^(-0b1) 
  => 0b0.1 × 0b10^(-0b1 + 0b10000) 
  => 0b0.1 × 0b10^(0b1111) 

事情到这一步还不算完,我们再考虑一个问题:浮点数17既可以写成0b0.10001 * 0b10^0b101,也可以写成0b0.010001 * 0b10^0b110,那我们究竟该用哪一种呢?这就涉及到了一个唯一性问题。

为了解决唯一性问题,IEEE 754引入了一个叫正规化的概念,即规定尾数部分的最高位必须是1,也就是说尾数必须以0.1开头。由于尾数部分的最高位必须是1,这个1就不必保存,可以节省一位提高精度。真是一举两得啊!

正规化后,17就有了唯一的存储形式:

搞明白了float14,我们再来理解float64就容易多了。float64的存储形式如下:

float64包含1个符号位,11个指数位,52个尾数位。指数偏移量是1023,取该值的原因是为了保证正负指数可以对称。至此我们就可以理解开篇提出的问题了,float64的精度完全取决于尾数,考虑到正规化因素,52位可以存储的最大数字是:

2^53 - 1
  === 9007199254740991
  === Number.MAX_SAFE_INTEGER

鉴于JavaScript里只有float64这一种数据类型,对于超过9007199254740991的数字是无法准确提取的,更别说计算了。所以在接口通讯中,请务必使用字符串形式。

参考资料

浮点数