一个在面试经常会问到的问题,就是为什么 0.1 + 0.2 !== 0.3 ,都知道是因为 JS 中数字精度的丢失导致的。今天就具体深入的了解下这个问题。
计算机中的数字
我们在计算机的基础知识中学过,计算机内部是二进制的,也就是不管什么代码、数字,实际在计算机内部都是一堆的二进制。
也就是计算机在存储数字的时候会把十进制的数字转换成二进制然后再存储。
二进制和十进制
十进制,没什么好说的,从小用到大。
二进制(binary)在数学和数字电路中指以2为底数的记数系统,以2为基数代表系统是二进位制的。这一系统中,通常用两个不同的数字0和1来表示
简单的说,二进制就是只用0和1表示,进位的时候不一样,十进制是逢十进一,二进制就是逢二进一。
十进制整数转二进制
方法:将整数除于2,反向取余数
示例:将十进制的42转换为二进制数。
42 / 2 = 21 ······ 0
21 / 2 = 10 ······ 1
10 / 2 = 5 ······ 0
5 / 2 = 2 ······ 1
2 / 2 = 1 ······ 0
1 / 2 = 0 ······ 1
对应的二进制就是 101010
十进制小数转二进制
方法:将小数部分乘以2,然后取整数部分,至到小数部分为0。若小数部分一直都无法等于0,那么就采用取舍,0舍1进。顺序区结果的整数部分 示例:将0.125换算为二进制
0.125 * 2 = 0.25 -> 0
0.25 * 2 = 0.5 -> 0
0.5 * 2 = 1 -> 1
对应的二进制就是 0.001
二进制转化为十进制
方法:将二进制每位上的数乘以权,然后相加之和即是十进制数。
示例:将二进制数110.101转换为十进制数
1 * 2^2 + 1 * 2^1 + 0 * 2^0 + 1 * 2^(-1) + 0 * 2^(-2) + 1 * 2^(-3) = 6.625
对应的十进制就是 6.625
我们日常使用都是用十进制来表示的,计算也是十进制的。实际上计算机在处理的时候,是先把十进制的数字转换成二进制,再对二进制数做运算,得出结果后再转换成十进制,最后显示出来给我们。这一系列操作都是计算机内部操作的。
其实这时候可以猜到导致精度问题肯定是和进制之间的转换是有关系的,是问题产生的一个原因。
科学计数法
科学记数法,又称为科学记数法或科学记法,是一种数字的表示法。
在科学记数法中,一个数被写成一个 a 和一个底数 b 的 n 次幂的积a * b^n
例如,1023 用科学计数法表示就是 1.023 * 10^3 。
如果是表示二进制的话,同样是上面这个数字,转换成二进制是 1111111111 ,科学计数法记作 1.111111111 * 10^1000 。
注意 10^1000 也是二进制数,也就是十进制的 2^8
JS 中的数字
在其他的语言中,例如 Java 这种,整数、浮点数的不同类型。但是在 JS 里,所有的数字都是一种类型,也就是 Number 类型。遵循 IEEE-754 标准。使用标准的64位双精度浮点数。
IEEE-754 中数字最终都是转换成科学表达式的形式保存的。它将一个数分成三个部分:
- 尾数位 M:有效数字,共52位。
- 指数位 E:用来表示次方数,可以为正负数,有11位
- 符号位 S:表示正数或负数,0代表正数,1代表负数。
尾数 M
IEEE-754 规定,在计算机内部保存 M 时,默认这个数的第一位总是 1,因此可以被舍去,只保存后面部分,这样可以节省 1 位有效数字,也就是说可以保存的尾数有 52 + 1 = 53 位。
指数 E
E 为一个无符号整数,共 11 位,2^11 = 2048 即表示它的取值范围是 0 ~ 2047 。
因为 E 是可以为负数的,IEEE-754 规定指数偏移值的固定值为 2^(e - 1) - 1。在64位双精度中,2^(11 - 1) - 1 = 1023 。
也就是指数是1023表示0次方,那 [0, 1022] 就是负数, [1024, 2047] 是正数。
符号 S
对于一个负数,一般我们都是直接加个负号来表示的,但是在计算机中,肯定也只能是0和1了,所以最高一位也是用来表示这个数的正负的。如果是正数,那就不用特殊处理,直接0表示,如果是负数,那就给最高位赋值1来表示。
最终,一个数字可以用下面这个公式来表示:
复现精度丢失的情况
就拿最常见的一个,0.1 和 0.2 这两个浮点数相加的情况来深入研究一下。
0.1 + 0.2 = 0.30000000000000004
首先,JS将这两个数保存到内存时,会先根据 IEEE- 754 转换成二进制
0.1 * 2 = 0.2 -> 0
0.2 * 2 = 0.4 -> 0
0.4 * 2 = 0.8 -> 0
0.8 * 2 = 1.6 -> 1
0.6 * 2 = 1.2 -> 1
0.2 * 2 = 6.4 -> 0
0.4 * 2 = 0.8 -> 0
0.8 * 2 = 1.6 -> 1
0.6 * 2 = 1.2 -> 1
可以发现这最终会得到一个循环数:0.00011···(0011循环) 。
先将 0.00011···(0011循环) 写成科学计数法的形式:
1.10011······ * 2^(-4)
明显的,这就是导致精度丢失的问题了。
尾数M只有52位,也就是最多只能保存52位小数,在 IEEE-754 中采用就近舍入(round to nearest)模式(进一舍零) 进行存储。
那M就应该是:
1001100110011001100110011001100110011001100110011001 // +1
1001100110011001100110011001100110011001100110011010
指数E保存的是 1023 - 4 = 1019 ,转成二进制就是:
1019 / 2 = 509 ······ 1
509 / 2 = 254 ······ 1
254 / 2 = 127 ······ 0
127 / 2 = 63 ······ 1
63 / 2 = 31 ······ 1
31 / 2 = 15 ······ 1
15 / 2 = 7 ······ 1
7 / 2 = 3 ······ 1
3 / 2 = 1 ······ 1
1 / 2 = 0 ······ 1
1111111011
// 不足11位 0补齐 结果就是
01111111011
符号位S的话,正数直接就是0。 那最终得到的就是:
0 01111111011 1001100110011001100110011001100110011001100110011010
binaryconvert 这个站点可以很直观的展示浮点数的每一位。
同样的过程得到0.2,这里直接省略了。最终结果是:
0 01111111100 1001100110011001100110011001100110011001100110011010
对阶
这里要插一下浮点数的运算规则。在数学中,进行两个小数的加减运算时,首先要将小数点对齐,然后对每一位进行加减运算。
计算机中也一样,在运算之前先要对指数做判断,不一样的话要对阶,也就是通过移动尾数改变价码的大小,使二者最终相等,尾数向右移动1位,则阶码值加1,反之减1。如果向左移会使高位被移出,对结果造成的误差更大,所以 IEEE-754 规定对阶的移动方向为向右移动,就是选择小的数进行操作,向大的对齐。
明显了,运算时的对阶也是导致精度丢失的原因。
现在回到 0.1 + 0.2
0 01111111011 1001100110011001100110011001100110011001100110011010
+ 0 01111111100 1001100110011001100110011001100110011001100110011010
指数不一样,首先要对阶
// 0.1 右移 1 位 最后一位的0舍去
// 注意这里尾数最高位的1是因为整数部分省略的1
// 如果再右移的话高位就得用0来补了
0 01111111100 1100110011001100110011001100110011001100110011001101
这时候两数相加
0 01111111100 1100110011001100110011001100110011001100110011001101
+ 0 01111111100 1001100110011001100110011001100110011001100110011010
// 尾数部分
0.1100110011001100110011001100110011001100110011001101
+ 1.1001100110011001100110011001100110011001100110011010
= 10.0110011001100110011001100110011001100110011001100111
由于产生进位,需要处理成符合标准的数。
// 把符号位和指数位放进来
0 01111111100 10.0110011001100110011001100110011001100110011001100111
// 也就是
10.0110011001100110011001100110011001100110011001100111 * 2^(-3)
规格化处理:
1.00110011001100110011001100110011001100110011001100111 * 2^(-2)
由于尾数位只有52位,就近舍入处理:
00110011001100110011001100110011001100110011001100111 // +1
0011001100110011001100110011001100110011001100110100
也就是最终求和结果得到的是:
0 01111111101 0011001100110011001100110011001100110011001100110100
将这个数转换成十进制显示出来就是:
0.30000000000000004
OK,这就是 0.1 + 0.2 这个运算的详细解读了,至此,可以得出导致精度丢失的原因了:
由于浮点数在进制转换和对阶运算的舍去导致的。
大数危机
根据上面的深入了解,其实处理浮点数导致精度丢失之外,还有就是大数也会有这样的问题,就是需要储存的位数超出了。
IEEE-754 64位双精度的尾数是52位,加上省略的1位,实际可以保存的值为:
2^53 = 9007199254740992
尾数都有53位,但只要52个位置,所以会忽略最后一位,也就是在 JS 中:
所以 JS 中规定最大安全整数和最小安全整数:
// 2^53 - 1
Number.MAX_SAFE_INTEGER // 9007199254740991
Number.MIN_SAFE_INTEGER // -9007199254740991
也就是在 [-(2^53 - 1), 2^53 - 1] 这个范围外都是不安全的,也就是不准确的值。
用 Number.prototype.toPrecision() 放大一个数的精度,可以发现0.1实际上并不是0.1:
0.1.toPrecision(17) // '0.10000000000000001'
解决方案
数字转换成字符串
如果后端返回的数据中,例如 ID 这样的字段如果是超出16位的数字,那在前端是会出问题的,这种情况一般是让后端将字段类型直接返回字符串,避免出现精度丢失
浮点数转换成整数
对于浮点数的运算,主要会出现精度丢失的情况主要是在转换成二进制的时候出现的无限循环,那么将浮点数放大为整数来处理不就好了嘛。
0.1 + 0.2
= (0.1 * 10 + 0.2 * 10) / 10
= 0.3
BigInt
Bigint 是 JS ES6 新增的一个数据类型,可以用来操作超出 Number 最大安全范围的整数。
// 字面量创建
const num1 = 10000000000000000n
// 函数创建
const num2 = BigInt('10000000000000000')
num1 === num2 // true
num1 + num2 // 20000000000000000n
三方库
现在社区有很多大佬写好的库,直接使用就行,事件处理方法也是跟上面类似,具体看项目是否需要。
参考
IEEE-754 维基百科
二进制的科学计数法?白话谈谈计算机如何存储与理解小数:IEEE 754
从标准原理出发理解 JavaScript 数值精度
JavaScript 浮点数之迷:大数危机
0.1 + 0.2不等于0.3?为什么JavaScript有这种“骚”操作?
为什么 0.1 + 0.2 = 0.300000004
JavaScript 浮点数陷阱