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
精度丢失原因:第一步和第三步。
(-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位。
其他精度丢失
- 为什么 x=0.1 得到 0.1 ?
0.1.toPrecision(16) //'0.1000000000000000'
0.1.toPrecision(21) //'0.100000000000000005551'
- 大数危机
9999999999999999 == 10000000000000001 //true
- 超过最大数
> 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) 其他方法:
- 第三方库:Math.js、BigDecimal.js
- BigInt
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 变量时可能会丢失精度。