Javascript 数字精度丢失的问题,如何解决?
我们在处理数据的时候可能会遇到类似0.1+0.2 !=0.3的问题,让我们来分析下原因:
因为 JS 采用 IEEE 754 双精度版本(64位),并且只要采用 IEEE 754 的语言都有该问题(我知道的java也是这样)。我们都知道计算机是通过二进制来存储东西的,0.1和0.2在转换二进制后都是是无限循环的,这样其实没什么问题,但是 JS 采用的浮点数标准却会裁剪掉后面的数字,导致精度丢失 0.1+0.2=0.30000000000000004。
场景复现
一个经典的面试题:
<pre class="hljs lua" style="padding: 0.5em; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(68, 68, 68); border-radius: 4px; display: block; margin: 0px 0px 0.75em; font-size: 14px; line-height: 1.5em; word-break: break-all; overflow-wrap: break-word; white-space: pre; background-color: rgb(246, 246, 246); border: none; overflow-x: auto;">0.1 + 0.2 === 0.3 // false</pre>
为什么是false呢?
先看下面这个比喻:
比如一个数 1÷3=0.33333333......
3会一直无限循环,数学可以表示,但是计算机要存储,方便下次取出来再使用,但0.333333...... 这个数无限循环,再大的内存它也存不下,所以不能存储一个相对于数学来说的值,只能存储一个近似值,当计算机存储后再取出时就会出现精度丢失问题。
比如18466.67*100,按理说他等于1846667吧,可是他等于1846666.9999999998,效果如下:
浮点数
“浮点数”是一种表示数字的标准,整数也可以用浮点数的格式来存储。
我们也可以理解成,浮点数就是小数。
在JavaScript中,现在主流的数值类型是Number,而Number采用的是IEEE754规范中64位双精度浮点数编码。
这样的存储结构优点是可以归一化处理整数和小数,节省存储空间。
对于一个整数,可以很轻易转化成十进制或者二进制。但是对于一个浮点数来说,因为小数点的存在,小数点的位置不是固定的。解决思路就是使用科学计数法,这样小数点位置就固定了。
而计算机只能用二进制(0或1)表示,二进制转换为科学记数法的公式如下:
其中,a的值为0或者1,e为小数点移动的位置。
举个例子:
27.0转化成二进制为11011.0 ,科学计数法表示为:
前面讲到,javaScript存储方式是双精度浮点数,其长度为8个字节,即64位比特。
64位比特又可分为三个部分:
- 符号位S:第 1 位是正负数符号位(sign),0代表正数,1代表负数。
- 指数位E:中间的 11 位存储指数(exponent),用来表示次方数,可以为正负数。在双精度浮点数中,指数的固定偏移量为1023。
- 尾数位M:最后的 52 位是尾数(mantissa),超出的部分自动进一舍零。
如下图所示:
举个例子:
27.5 转换为二进制11011.1
11011.1转换为科学记数法
符号位为1(正数),指数位为4+,1023+4,即1027
因为它是十进制的需要转换为二进制,即 10000000011,小数部分为10111,补够52位即: 1011 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000`。
所以27.5存储为计算机的二进制标准形式(符号位+指数位+小数部分 (阶数)),既下面所示:
0+10000000011+011 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000`
二进制
- 基数为2
- 有2个数字,即0和1
- 满2进1
八进制
- 基数为8
- 由8个数字组成,分别是0、1、2、3、4、5、6、7
- 满8进1
十进制
- 我们日常生活中所用的都是十进制,也就是满10进1
十六进制
- 基数为16。
- 由16个数字符号组成,分别是0、1、2、3、4、5、6、7、8、9、A、B、C、D、E、F
- 满16进1
在古代中国当时使用的重量单位就是十六进制,16两为1斤,就有了所谓的“半斤八两”
举个例子:
比如 十进制:
1 2 3 4 5 6 7 8 9 10 11 ...
当要数10时,就要进1位,也就是十位数写1,个位数写0, 即满十进一。
二进制:
0 1 10 11 10 11 110 111 101 ...
当要数2的时候,就要进1位了,上一位写1,当前位变成0 即满二进一。
进制之间怎么转换?
不会的话自行百度吧。
问题分析
再回到问题上:
<pre class="hljs lua" style="padding: 0.5em; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(68, 68, 68); border-radius: 4px; display: block; margin: 0px 0px 0.75em; font-size: 14px; line-height: 1.5em; word-break: break-all; overflow-wrap: break-word; white-space: pre; background-color: rgb(246, 246, 246); border: none; overflow-x: auto;">0.1 + 0.2 === 0.3 // false</pre>
通过上面的学习,我们知道,在javascript语言中,0.1 和 0.2 都转化成二进制后再进行运算。
<pre class="prettyprint hljs fsharp" style="padding: 0.5em; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(68, 68, 68); border-radius: 4px; display: block; margin: 0px 0px 1.5em; font-size: 14px; line-height: 1.5em; word-break: break-all; overflow-wrap: break-word; white-space: pre; background-color: rgb(246, 246, 246); border: none; overflow-x: auto;">// 0.1 和 0.2 都转化成二进制后再进行运算
0.00011001100110011001100110011001100110011001100110011010 +
0.0011001100110011001100110011001100110011001100110011010 =
0.0100110011001100110011001100110011001100110011001100111
// 转成十进制正好是 0.30000000000000004</pre>
所以输出false。
再来一个问题,那么为什么x=0.1得到0.1?
主要是存储二进制时小数点的偏移量最大为52位,最多可以表达的位数是2^53=9007199254740992,对应科学计数尾数是 9.007199254740992,这也是 JS 最多能表示的精度。
它的长度是 16,所以可以使用 toPrecision(16) 来做精度运算,超过的精度会自动做凑整处理。
<pre class="prettyprint hljs less" style="padding: 0.5em; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(68, 68, 68); border-radius: 4px; display: block; margin: 0px 0px 1.5em; font-size: 14px; line-height: 1.5em; word-break: break-all; overflow-wrap: break-word; white-space: pre; background-color: rgb(246, 246, 246); border: none; overflow-x: auto;">.10000000000000000555.toPrecision(16)
// 返回 0.1000000000000000,去掉末尾的零后正好为 0.1</pre>
但看到的 0.1 实际上并不是 0.1。不信你可用更高的精度试试:
<pre class="hljs" style="padding: 0.5em; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(68, 68, 68); border-radius: 4px; display: block; margin: 0px 0px 0.75em; font-size: 14px; line-height: 1.5em; word-break: break-all; overflow-wrap: break-word; white-space: pre; background-color: rgb(246, 246, 246); border: none; overflow-x: auto;">0.1.toPrecision(21) = 0.100000000000000005551</pre>
如果整数大于 9007199254740992 会出现什么情况呢?
由于指数位最大值是1023,所以最大可以表示的整数是 2^1024 - 1,这就是能表示的最大整数。但你并不能这样计算这个数字,因为从 2^1024 开始就变成了 Infinity。
<pre class="prettyprint hljs css" style="padding: 0.5em; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(68, 68, 68); border-radius: 4px; display: block; margin: 0px 0px 1.5em; font-size: 14px; line-height: 1.5em; word-break: break-all; overflow-wrap: break-word; white-space: pre; background-color: rgb(246, 246, 246); border: none; overflow-x: auto;">> Math.pow(2, 1023)
8.98846567431158e+307
> Math.pow(2, 1024)
Infinity</pre>
那么对于 (2^53, 2^63) 之间的数会出现什么情况呢?
- (2^53, 2^54)之间的数会两个选一个,只能精确表示偶数
- (2^54, 2^55)之间的数会四个选一个,只能精确表示4个倍数
- ... 依次跳过更多2的倍数
要想解决大数的问题你可以引用第三方库 bignumber.js,原理是把所有数字当作字符串,重新实现了计算逻辑,缺点是性能比原生差很多。
浮点型的存储机制(单精度浮点数,双精度浮点数)。
浮点型数据类型主要有:单精度float、双精度double
单精度浮点数(float)
单精度浮点数在内存中占4个字节、有效数字8位、表示范围:-3.40E+38 ~ +3.40E+38
双精度浮点数(double)
双精度浮点数在内存中占8个字节、有效数字16位、表示范围:-1.79E+308 ~ +1.79E+308
浮点型常量 数有两种表示形式:
- 1. 十进制数形式:由数字和小数点组成,且必须有小数点,如0.123,123.0。
- 科学计数法形式:如:123e3或123E3,其中e或E之前必须有数字,且e或E后面的指数必须为整数(当然也包括负整数)。
浮点型简单来说就是表示带有小数的数据,而恰恰小数点可以在相应的二进制的不同位置浮动,可能是这样就被定义成浮点型了。不得不佩服这文化程度,定义个数据名称都这么有深度
但是!!!
JavaScript 存储小数和其它语言如 Java 和 Python 都不同,JavaScript 中所有数字包括整数和小数都只有一种类型 即 Number类型 它的实现遵循 IEEE 754 标准,IEEE 754 标准的内容都有什么,这个咱不用管,我们只需要记住以下一点:
小结
计算机存储双精度浮点数需要先把十进制数转换为二进制的科学记数法的形式,然后计算机以自己的规则{符号位+(指数位+指数偏移量的二进制)+小数部分}存储二进制的科学记数法。
因为存储时有位数限制(64位),并且某些十进制的浮点数在转换为二进制数时会出现无限循环,会造成二进制的舍入操作(0舍1入),当再转换为十进制时就造成了计算误差。
解决方案
理论上用有限的空间来存储无限的小数是不可能保证精确的,但我们可以处理一下得到我们期望的结果。
当你拿到 1.4000000000000001 这样的数据要展示时,建议使用 toPrecision 凑整并 parseFloat 转成数字后再显示,如下:
<pre class="hljs lisp" style="padding: 0.5em; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(68, 68, 68); border-radius: 4px; display: block; margin: 0px 0px 0.75em; font-size: 14px; line-height: 1.5em; word-break: break-all; overflow-wrap: break-word; white-space: pre; background-color: rgb(246, 246, 246); border: none; overflow-x: auto;">parseFloat(1.4000000000000001.toPrecision(12)) === 1.4 // True</pre>
封装成方法就是:
<pre class="prettyprint hljs javascript" style="padding: 0.5em; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(68, 68, 68); border-radius: 4px; display: block; margin: 0px 0px 1.5em; font-size: 14px; line-height: 1.5em; word-break: break-all; overflow-wrap: break-word; white-space: pre; background-color: rgb(246, 246, 246); border: none; overflow-x: auto;">function strip(num, precision = 12) {
return +parseFloat(num.toPrecision(precision));
}</pre>
对于运算类操作,如 +-*/,就不能使用 toPrecision 了。正确的做法是把小数转成整数后再运算。以加法为例:
<pre class="prettyprint hljs javascript" style="padding: 0.5em; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(68, 68, 68); border-radius: 4px; display: block; margin: 0px 0px 1.5em; font-size: 14px; line-height: 1.5em; word-break: break-all; overflow-wrap: break-word; white-space: pre; background-color: rgb(246, 246, 246); border: none; overflow-x: auto;">/**
* 精确加法
*/
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;
}</pre>
最后还可以使用第三方库,如Math.js、BigDecimal.js
我们可以这样处理:
parseFloat((0.1 + 0.2).toFixed(10))
parseFloat((18466.67*100).toFixed(0))