JS中Number类型以64位的双精度浮点型表示,其能表示的整数范围有限,且大于一定值时可表达的整数值不再连续。 因此,如果后端传给前端一个JS中无法表达的整数值(譬如64位的大整数),则JS在存储时将寻找一个近似值代替,导致前后端保存的是不一样的值,从而在某些使用场景中出现问题,譬如作为对象的key。
所以,后端要尽量避免传给前端大整数值,因为这个整数值前端根本用不了,一使用就可能出错。
本文将介绍Number类型可表达的值范围,并总结一些因为该范围限制而可能出现的问题。
IEEE 754-2008这个规范定义了一种浮点数的形式,以及近似规则。其浮点数有以下形式:
V = (-1)^s * M * 2^E
可以看出,这种形式的浮点数,其值由三个因素决定:
- 符号位 s。有两种可能值,1 表示负数,0 表示正数。需要占用 1 位。
- 尾数 M。有效数字。
- 指数 E。控制小数点位置。
假设用 m 位二进制表示浮点数 V,则将其分为三部分(从最高位到最低位):符号位(1位)、指数(k位)、尾数(n位)。
例如,
m | s | k | n | |
---|---|---|---|---|
单精度 | 32 | 1 | 8 | 23 |
双精度 | 64 | 1 | 11 | 52 |
假设决定尾数的 n 位二进制为 f,决定指数的 k 位二进制为 e。 根据 e 的模式,这种形式可表示的浮点数可分为三类:
e | E | M | V | |
---|---|---|---|---|
非规范值 | 全 0 | 1 - Bias | 0.f | (-1)^s * 0.f * 2^(1 - Bias) |
特殊值 | 全 1 | e | 任意 | Infinity, -Infinity 或 NaN |
规范值 | 其它 | e - Bias | 1.f | (-1)^s * 1.f * 2^(e - Bias) |
上面的表格中 Bias = 2^(k - 1) - 1。对于双精度而言,Bias = 1023。
对于特殊值,f 为全 0 时,表示 Infinity 或 -Infinity,符号取决于符号位 s;f 为其它值时,表示 NaN。 可见,特殊值一共有 2^(n + 1) 种,其中包括 Infinity、-Infinity 和 2^(n + 1) - 2 个视作 NaN 的数。
非规范值(不包含0)的绝对值最小值为 2^(-n) * 2^(1 - Bias),最大值为 (1 - 2^(-n)) * 2^(1 - Bias)。 f 为 0 时 V = 0,随 s 的不同有正负 0 两个。
规范值的绝对值最小值为 2^(1- Bias),最大值为 (2 - 2^(-n)) * 2^( 2^(k - 1) - 1 )。
设 epsilon = 2^(-n),对于双精度而言,epsilon = 2^(-52),规范值与非规范值(不包含0)的绝对值范围为:
MIN | MAX | |
---|---|---|
非规范值 | epsilon * 2^(-1022) | (1 - epsilon) * 2^(-1022) |
规范值 | 2^(-1022) | (2 - epsilon) * 2^1023 |
可表示的浮点数一定在上面的范围中,但在范围内的浮点数不一定能被表示,则会根据规则做一定近似。
从前面的浮点数值范围可以知道,可表示的整数都是规范值。
尾数(1.f)一共有 (n + 1) 位,通过指数移动小数点可使 V 为整数,其值范围为 [1, 2^(n+1) - 1],这个区间内的任意整数都是可以表示的,这是一个连续的整数区间。 从表示形式可以看出,任何 2^x 的整数都是可表示的,所以,也可以说[1, 2^(n+1)]区间是一个连续的整数区间。但是大于2^(n+1)的整数,则不一定能被表示。
Math.pow(2, 53)
// 9007199254740992
// 可表示
Math.pow(2, 53) + 1
// 9007199254740992
// 不可表示,用 9007199254740992 近似
Math.pow(2, 53) + 2
// 9007199254740994
// 可表示
Math.pow(2, 53) + 3
// 9007199254740996
// 不可表示,用 9007199254740996 近似
Math.pow(2, 53) + 4
// 9007199254740996
// 可表示
Number可安全表示的正整数范围 :[1, 2^53]。超过2^53,便开始出现不可表示的情况。
近似的规则:
- 寻找最近的可表示数
- 如果有两个最近的可表示数,则取最小有效位为偶数的
这种近似并非四舍五入,且值越大的区间,可表示的数就越稀疏(绝对误差越大)。
(Math.pow(2, 53) + 3) * 4
// 36028797018963980
36028797018963988
// 36028797018963980
36028797018963989
// 36028797018963990
这种近似规则的说明可见rounding。
Help 从IEEE 754-2008中关于近似的说明看,上述规则是默认的规则,似乎是可以由实现覆盖的。 譬如上面例子中 Math.pow(2, 53) + 1 与 Math.pow(2, 53) + 3 的近似就很难用这个默认规则说明。 也许是Chrome的实现并未使用这个默认规则?
Help
9007199254740995 (Math.pow(2, 53) + 3
)是不可表示的,但 18014398509481990 (=9007199254740995 * 2) 又是可表示的,这是为什么?
Number.EPSILON
// 2.220446049250313e-16
// 即二进制表示中有效数字,其含义是大于数字 1 的最小数字与 1 的差。
Number.MAX_SAFE_INTEGER
// 9007199254740991
// Math.pow(2, 53) - 1
// 连续整数区间的终点
Number.MIN_SAFE_INTEGER
// -9007199254740991
Number.NaN
// NaN
Number.POSITIVE_INFINITY
// Infinity
Number.NEGATIVE_INFINITY
// -Infinity
Number.MAX_VALUE
// 1.7976931348623157e+308
// (2 - Number.EPSILON) * Math.pow(2, 1023)
Number.MIN_VALUE
// 5e-324
// Number.EPSILON * Math.pow(2, -1022)
如果后端使用了64位的无符号整数,就有可能超过 2^53,前端在解析时就可能会丢失精度。 如果前端将丢失精度的值当作key值去查找后端提供的数据,就会找不到对应的数值。 因此,**后端传给前端的整数不应当超过 2^53 ** 。
0.2 + 0.4
// 0.6000000000000001
前端用于展示的浮点数(如价格),如果需要计算,不应当直接使用原生的操作符。 解决方案可以参考math-hacker。
Math.round(2.4)
// 2
Math.round(2.5)
// 3
(2.385).toFixed(2)
// '2.38'
(2.3851).toFixed(2)
// '2.39'
(2.384).toFixed(2)
// '2.38'
因此,想通过toFixed去控制显示的小数位数可能会与用户的习惯(四舍五入)不一致。 解决方案可以参考math-hacker。
Math.pow(2, 31)
// 2147483648
// 取半
2147483648 >> 1
// -1073741824
Math.pow(2, 31) - 2
// 2147483646
// 取半
2147483646 >> 1
// 1073741823
因此,位操作时需要确保不超过32位可表示的整数范围。