烦人的精度丢失 - IEEE754

548 阅读6分钟

1. 什么是精度丢失

举个例子:

0.1+0.2 //0.30000000000000004
0.3-0.2 //0.09999999999999998
0.55*100 //55.00000000000001
0.56*100 //56.00000000000001
0.3/0.1 //2.9999999999999996

JavaScript, Java, python在处理浮点数时都会出现精度丢失问题,罪魁祸首是由于进制转换导致。Java有单精度类型float,双精度double,JavaScript将浮点数和整数都定义为Number类型。

JavaScript的Number类型为双精度IEEE 754 64位浮点类型。 ——MDN

ECMAScript 中最有意思的数据类型或许就是 Number 了。Number 类型使用IEEE 754 格式表示整数和浮点值(在某些语言中也叫双精度值)。 ——红宝书

在讲精度丢失前,先看几个概念:

  • 概念1: Number.MAX_SAFE_INTEGER

JavaScript存储的最大安全数字,-(2^53 - 1) 到 2^53 - 1 之间的数值(包含边界值)。所谓的安全数字,指的是这个范围内的整数只存在唯一的浮点数与其对应。

为什么是2^53?

  • 概念2: Number.MAX_VALUE

MAX_VALUE 属性值接近于 1.79E+308。大于 MAX_VALUE 的值代表 "Infinity"。

  • Number.EPSILON

属性表示 1 与Number可表示的大于 1 的最小的浮点数之间的差值。EPSILON 属性的值接近于 2.2204460492503130808472633361816E-16,或者 2^-52。

2. 为什么出现精度丢失

宋丹丹老师

问:把浮点数储存在计算机需要几步?

  • 第一步:转换成二进制
  • 第二步:表示成科学计数法
  • 第三步:转换成 IEEE 754 格式

0.1 + 0.2 第一步:将 0.1 转成二进制 -(1)将 0.1乘以 2,得 0.2,则整数部分为 0,小数部分为 0.2。 -(2)将小数部分 0.2 乘以 2,得 0.4,则整数部分为 0,小数部分为 0.4。 -(3)将小数部分 0.4 乘以 2,得 0.8,则整数部分为 0,小数部分为 0.8。 -(4)将小数部分 0.8 乘以 2,得 1.6,则整数部分为 1,小数部分为 0.6。

  • (5)将小数部分 0.6 乘以 2,得 1.2,则整数部分为 1,小数部分为 0.2。
  • (6)将小数部分 0.2 乘以 2,得 0.4,则整数部分为 0,小数部分为 0.4。
  • ... 可以看到,从(6)开始,将无限循环第1-5步,一直乘下去,最后不可能得到小数部分为 0。因此,这个时候只好学习十进制的方法进行四舍五入了。但是二进制只有 0 和 1 两个,于是就出现 0 舍 1 入的 “口诀” 了,这也是计算机在转换中会产生误差的根本原因。

0.1>>>0.0001100110011001100110011001100110011001100110011001101>>> 1.100110011001100110011001100110011001100110011001101 * 2^(-4)>>>

IEEE754: 0 - 01111111011 - 1001100110011001100110011001100110011001100110011010

第一步:将 0.2 转成二进制 0.2>>>0.001100110011001100110011001100110011001100110011001101>>> 1.100110011001100110011001100110011001100110011001101 * 2^(-3)>>>

IEEE754 0 - 01111111100 - 1001100110011001100110011001100110011001100110011010

相加

0.00011001100110011001100110011001100110011001100110011010

+ 0.00110011001100110011001100110011001100110011001100110100 =0.01001100110011001100110011001100110011001100110011001110

第二步:则0.1 + 0.2的结果的二进制数科学记数法表示为为1.001100110011001100110011001100110011001100110011010 * 2^(-2), 省略尾数最后的0,即 1.00110011001100110011001100110011001100110011001101 * 2^(-2),

第三步:因此(0.1+0.2)实际存储时的形式是

0 - 011111111010 - 011001100110011001100110011001100110011001100110100

因计算机存储位数的限制而截断的二进制数字,再转换为十进制,就成了0.30000000000000004

二进制转换工具

精度丢失原因:第一步和第三步。

IEEE_754_wikipedia

(-1)^S * M * 2^E

各符号的意思如下:S,是符号位,决定正负,0时为正数,1时为负数。M,是指有效位数,大于1小于2。E,是指数位。

  • 符号位 sign:第 0 位,标示正负数
  • 指数位 exponent:第 1-11 位用于指数。这允许指数最大到 1024
  • 尾数位 fraction:剩下的 52 位代表的是尾数,超出的部分自动进一舍零

0.15625转换为二进制数是0.00101,用科学计数法表示就是 1.01 * 2^(-3),所以符号位为0,表示该数为正。注意,接下来的8位并不直接存储指数-3,而是存储阶数,阶数定义如下:

阶数 = 指数+偏置量, 即移码

对于单精度型数据其规定偏置量为127,而对于双精度来说,其规定的偏置量为1023。所以0.15625的阶数为124,用8位二进制数表示为01111100。

再注意,存储有效数字时,将不会存储小数点前面的1(因为二进制有效数字的第一位肯定是1,省略),所以这里存储的是01,不足23位,余下的用0补齐。

当然,这里还有一个问题需要说明,对于0.1这种有效数字无限循环的数该如何截断,IEEE754默认的舍入模式是:

Round to nearest, ties to even

也就是说舍入到最接近且可以表示的值,当存在两个数一样接近时,取偶数值。

IEEE 754规定了四种表示浮点数值的方式:单精确度(32位)、双精确度(64位)、延伸单精确度(43比特以上,很少使用)与延伸双精确度(79比特以上,通常以80位实现)。

为什么是2^53?

因为在转成尾数位不存储1.,在转成十进制的时候会自动加上,所以为53位01

为什么十进制精度是16位?

双精度的尾数用52位存储,2^(52+1) = 9007199254740992,

10^16 < 9007199254740992 < 10^17,所以双精度的有效位数是16位。

其他精度丢失

  1. 为什么 x=0.1 得到 0.1 ?
0.1.toPrecision(16) //'0.1000000000000000'
0.1.toPrecision(21) //'0.100000000000000005551'
  1. 大数危机
9999999999999999 == 10000000000000001 //true
  1. 超过最大数
> Math.pow(2, 1023)
8.98846567431158e+307

> Math.pow(2, 1024)
Infinity

> Math.pow(2, 1024)

3.怎么解决

1) Number.EPSILON

适用于:判断

var a = Math.abs(0.1+0.2-0.3)
return a < Number.EPSILON ? true : false
2) 组合使用

toFixed() 不建议直接使用

1.35.toFixed(1) // 1.4 正确
1.335.toFixed(2) // 1.33  错误
1.3335.toFixed(3) // 1.333 错误
1.33335.toFixed(4) // 1.3334 正确
1.333335.toFixed(5)  // 1.33333 错误
1.3333335.toFixed(6) // 1.333333 错误
1.005.toFixed(2) //1.00 而不是 1.01

1.005.toPrecision(16) //1.005000000000000
1.005.toPrecision(20) //1.0049999999999998934

parseFloat(x.toFixed())

parseFloat(x.toPrecision())

/**
 * 精度处理
 * @param num 数字
 */
 export function formatEpsilon(num) {
    if (num > Number.MAX_SAFE_INTEGER || num < Number.MIN_SAFE_INTEGER) {
        console.log("超出数字安全范围");
        return 0;
    }
    return parseFloat(num.toFixed(10));
  }
  
  export const safeFen2Yuan = (price) => formatEpsilon(price / 100);
  
  export const safeYuan2Fen = (price) => formatEpsilon(price * 100);

 export function formatEpsilon_(num) {
    if (num > Number.MAX_SAFE_INTEGER || num < Number.MIN_SAFE_INTEGER) {
        console.log("超出数字安全范围");
        return 0;
    }
    return parseFloat(num.toPrecision(12));
  }
3) 扩大法

对于运算类操作,如 +-*/,就不能使用 toPrecision 了。正确的做法是把小数转成整数后再运算。

/**
 * 精确加法
 */
function add(num1, num2) {
  const num1Digits = (num1.toString().split('.')[1] || '').length;
  const num2Digits = (num2.toString().split('.')[1] || '').length;
  const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
  return (num1 * baseNum + num2 * baseNum) / baseNum;
}
4) 大数转成 string 进行处理

处理id等大数

5) 其他方法:

BigInt 是一种内置对象,它提供了一种方法来表示大于 2^53 - 1 的整数。这原本是 Javascript中可以用 Number 表示的最大数字。BigInt 可以表示任意大的整数。

const theBiggestInt = 9007199254740991n;

const alsoHuge = BigInt(9007199254740991);
// ↪ 9007199254740991n

const hugeString = BigInt("9007199254740991");
// ↪ 9007199254740991n

const hugeHex = BigInt("0x1fffffffffffff");
// ↪ 9007199254740991n

const hugeBin = BigInt("0b11111111111111111111111111111111111111111111111111111");
// ↪ 9007199254740991n

⚠️ 注意:BigInt不能用于 Math 对象中的方法;不能和任何 Number 实例混合运算,两者必须转换成同一种类型。在两种类型来回转换时要小心,因为 BigInt 变量在转换成 Number 变量时可能会丢失精度。

cite:

  1. juejin.cn/post/688014…
  2. juejin.cn/post/684490…
  3. www.zhihu.com/question/29…
  4. vue3js.cn/interview/J…