前后端大数精度不一致可能引起问题

JS中Number类型以64位的双精度浮点型表示,其能表示的整数范围有限,且大于一定值时可表达的整数值不再连续。 因此,如果后端传给前端一个JS中无法表达的整数值(譬如64位的大整数),则JS在存储时将寻找一个近似值代替,导致前后端保存的是不一样的值,从而在某些使用场景中出现问题,譬如作为对象的key。

所以,后端要尽量避免传给前端大整数值,因为这个整数值前端根本用不了,一使用就可能出错。

本文将介绍Number类型可表达的值范围,并总结一些因为该范围限制而可能出现的问题。

浮点数形式

IEEE 754-2008这个规范定义了一种浮点数的形式,以及近似规则。其浮点数有以下形式:

V = (-1)^s * M * 2^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

HelpIEEE 754-2008中关于近似的说明看,上述规则是默认的规则,似乎是可以由实现覆盖的。 譬如上面例子中 Math.pow(2, 53) + 1 与 Math.pow(2, 53) + 3 的近似就很难用这个默认规则说明。 也许是Chrome的实现并未使用这个默认规则?

Help 9007199254740995 (Math.pow(2, 53) + 3)是不可表示的,但 18014398509481990 (=9007199254740995 * 2) 又是可表示的,这是为什么?

Number中几个特殊值含义

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

Number.prototype.toFixed(precision)与Math.round(float)取整规则不一样

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

位操作时会当作32位整数处理

Math.pow(2, 31)
// 2147483648

// 取半
2147483648 >> 1
// -1073741824

Math.pow(2, 31) - 2
// 2147483646

// 取半
2147483646 >> 1
// 1073741823

因此,位操作时需要确保不超过32位可表示的整数范围。

更多参考