首先来说一个真实的案例。
某天测试提了个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
。这个数字是怎么算出来的呢?我们接下来就来一步一步地拆解。在这里首先声明,本文是综述,没有原创发明的部分,使用的图片也都来自网络,版权归原作者所有。
float64
在js
里是怎么存储的呢?这里其实也不是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
0d
和0b
的意义不做赘述。按照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
的数字是无法准确提取的,更别说计算了。所以在接口通讯中,请务必使用字符串形式。