跳到主要内容

精度丢失

六十四位双精度浮点数(double) 所有整数其实都是 double 数抹去了 0,所以 js 实际上全是小数 其中 11 位用于指数长度 1 位用于正负数 52 位用于整数

所以整数安全范围是由 52 位决定 又由于表示一个小数,如二进制 0.10 可以表示为 1.xxxxxxx2^-1 0.01 可以表示 1.xxxxxx2^-2 所有 1 可以省略 52 位可以表示 53 位的小数

由于指数不能表示连续的数字, 所以问到整数范围还是 53 位 为什么有些小数二进制是无限循环数? 0.75 = 2^-1 + 2^-2 所有可用 11 来表示

但是 0.2 = 0.0625+0.0375+....... 用二进制表示,需要无限循环

所以 0.2 + 0.1 =0.300000000000000...4 然后 js 的 double 数位数有限, 所以最后两位相加会 零舍一入 突然变成了 4

背景

道具价格支持精度为两位,接口请求时需要将 道具价格 * 100 转换为整数传输 但是实际结果却仍是小数,后端接口类型检验失败,导致接口报错

探索

存储方式 要研究这个问题,首先得将十进制转为二进制 复习一下:十进制小数如何转为二进制小数 整数小数:除二取余,逆序排列 小数部分:乘二取整,顺序排列

  • 小数的转换过程,就是不断乘以 2,如果结果一直不为整数,对应的二进制就是个无限循环小数
  1. 绝大多数十进制的有限小数,对应的二进制是无限循环小数 (除非这个小数满足 x = sigma 2 ^ n , n 为小于 0 的整数)

Number

周所周知,js 是弱类型语言,整数、小数统一使用 Number 类型 Number 是符合 IEEE 754 规定的双精度 64 位浮点数

浮点数的存储方式:

  • sign: 符号位,0 表示正数,1 表示负数
  • exponent: 指数位, 11 个 bit
  • mantissa: 尾数位, 52 个 bit

其中尾数位的 1 会被省略,用来节省空间。比如, 1.1001101, M = 0.1001101 计算公式:

例如: 十进制 0.08203125 = 二进制 0.00010101 = 1.0101 * 2 ^ (-4), S=0, E=1019, M=0.0101 对应的存储数据

二进制转换网址: http://www.binaryconvert.com/result_double.html

(0.1).toString(2) // 循环节 0011
// 对应的十进制 0.100000000000000005551115123126
"0.0001100110011001100110011001100110011001100110011001101"
(0.2).toString(2) // 循环节 0011
// 对应的十进制 0.200000000000000011102230246252
"0.001100110011001100110011001100110011001100110011001101"
(0.1 + 0.2).toString(2)
// 对应的十进制 0.300000000000000044408920985006
"0.0100110011001100110011001100110011001100110011001101"
(0.3).toString(2)
// 对应的十进制 0.299999999999999988897769753748
"0.010011001100110011001100110011001100110011001100110011"

Q: 为什么大多数情况下不会出现问题? 尾数位有 52 个 bit,2 ^ 52 < 10 ^ 16 因此,如果二进制有效位超过 52,即十进制有效位超过 16 位,就不能被精准表示 这也是为什么 0.1 = 0.1 而不是 0.100000000000000005551115123126 Number.EPSILON = 2 ^ -52 总结 其他运算与加法类似,就不一一列举了 js 的浮点数计算很危险,很容易出现不符合预期的情况

解决方法

  1. 判断是否相等时,可以从 0.1 + 0.2 === 0.3 改为 Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON
  2. 浮点运算后,使用 toFixed() 保留有效位
  3. 使用第三方库,如 bignumber https://github.com/MikeMcl/bignumber.js/
  4. 使用 BigInt,但是只支持整数