前言
曾几何时我们惊讶于在控制台看到这样的情况
0.1 + 0.2 === 0.3
false
复制代码
而我们也得出一个原因,因为精度丢失所致。下面我将一步一步地以最简单的0.1为例告诉你们精度为什么丢失,什么时候开始丢失的,这里没有深奥的公式,也没有晦涩的概念,只要你知道进制转换就能看懂了。
0.1在内存中的样子
有一点我们是知道的,js中一般的数值是以64位浮点数存储在内存中的,也就是这64个二进制数字映射着一个具体的数字,具体是按照IEEE754 这个标准来的,这个标准权衡了精度和表示范围,也就是如何有效利用这64个二进制数字的前提下提出的。下面的所有流程都是按这个标准来的,其中把64位划分出了3个区域
区域 S 符号位 用 1 位表示 0表示正数 1表示负数
区域 E 指数位 用 11 位表示 有正负范围,临界值是1023 后面看转换过程就能看明白
区域 M 尾数位 用 52 位表示
S + E + M 刚好就等于64位 在开始前先看看 0.1 在内存中是长什么样子的
let bytes = new Float64Array(1);// 64位浮点数
bytes[0] = 0.1;// 填充0.1进去
let view = new DataView(bytes.buffer);
console.log(view.getUint8(0).toString(2));// 10011010
console.log(view.getUint8(1).toString(2));// 10011001
console.log(view.getUint8(2).toString(2));// 10011001
console.log(view.getUint8(3).toString(2));// 10011001
console.log(view.getUint8(4).toString(2));// 10011001
console.log(view.getUint8(5).toString(2));// 10011001
console.log(view.getUint8(6).toString(2));// 10111001
console.log(view.getUint8(7).toString(2));// 00111111 这里补齐了8位
复制代码
这里的bytes.buffer代表的就是一串内存空间,为了方便大家理解我使用 DataView用无符号8位的格式一个一个地读取内存的数据再转为二进制格式。 由于读取内存的顺序会受字节序的影响,可能在你们的电脑打印得到相反的顺序 如果按SEM的排列,那么其二进制就像下面这样子的
s(0)E(01111111011)M(1001100110011001100110011001100110011001100110011010)
现在已经知道了0.1在内存的样子,下面就开始说说具体的转化过程,也就是精度丢失的过程
0.1精度丢失过程
- 转换为二进制
在转换之前,首先看十进制小数要如何转化为二进制数小数的,这也是理解精度丢失十分关键的步骤,这个网上也有很多资料,我下面简单写一下流程。
0.1 => 0.2 => 0.4 => 0.8 => 1.6 => 1.2 => 0.4 => 0.8 => 1.6 => 1.2 => 0.4 => 0.8 => 1.6 => 1.2 => 0.4 ..............
复制代码
就是小数部分不断乘以2,并取整数部分的值,直到小数部分为0为止,应该也是很好理解的,可以看出这样下去是一个无限循环的过程,转化后是这样子的
0.00011001100110011001100110011001100110011001100110011001100110011001.....
复制代码
有限空间传入无限的数很明显是不可能,那么应该怎么做呢
-
转换为二进制指数格式
转换为指数格式其实就是移动小数点,让小数点前面出现的是第一个为1的值,不同的二进制数据,可能是前移可能是右移,对应的是指数的正负范围,转换后是这样子的
1.1001100110011001100110011001100110011001100110011001100110011001..... * 2 ^ -4
复制代码
-
提取数据,进行数值截取,导致精度丢失
这里可以看到向右移动了4位,这个数据会保存在指数区域E内,在没有移位的情况下指数区域的值是1023,向左移动几位就加几位,向右移动几位就减几位,所以这里是
1023 - 4 = 1019
1019 转二进制并补齐11位 01111111011
复制代码
也就是E为 01111111011 由于尾数位最多只有52位,所以小数点后面的52位全部提取到尾数位,其中要注意的是,类似四舍五入,如果末位后是1会产生进位,这里就产生了进位
1001100110011001100110011001100110011001100110011001100110011001.....
1001100110011001100110011001100110011001100110011001 100110011001.....
进位后截取
1001100110011001100110011001100110011001100110011010
复制代码
也就是M为 1001100110011001100110011001100110011001100110011010
这里由于丢掉了部分数据,所以导致精度丢失
由于0.1是正数,所以 S 为 0
到此整个js浮点数存储过程就结束了,为了表示我不是忽悠大家的,大家可以对照第一部分输出的数据值。下面将顺便介绍一下怎么转回十进制
丢失精度的数据转回十进制
- 提取尾数位数据
1001100110011001100110011001100110011001100110011010
复制代码
- 先前添加 1. 恢复为指数格式 并提取指数位
1.1001100110011001100110011001100110011001100110011010
复制代码
01111111011 => 1019
1019 - 1023 = -4
复制代码
1.1001100110011001100110011001100110011001100110011010 * 2 ^ -4
复制代码
- 移位
0.00011001100110011001100110011001100110011001100110011010
复制代码
- 二进制转化为十进制 小数的二进制转化为十进制网上的资料也有很多,我也简单介绍一下过程,以0.0111为例子
0.0111 小数点后一位 0 / 2^1 0
小数点后2位 1 / 2^2 0.25
小数点后3位 1 / 2^3 0.125
小数点后4位 1 / 2^4 0.0625
然后相加 0 + 0.25 + 0.125 + 0.0625 = 0.4375
复制代码
按以上方法进行装换
0.00011001100110011001100110011001100110011001100110011010 =>
0.100000000000000005551
复制代码
关于最后这个输出值其实也是不精确的,因为我就是用js计算的,如果大家有更准确的计算方法可以帮我算一下,精确的值末尾数应该是5才对。但是你试一下在控制台中计算下面的表达式
0.1.toPrecision(21)
"0.100000000000000005551"
复制代码
这个也证明了上述的推理过程是正确的
总结
相信到这里你已经知道为什么精度会丢失了,很多人都说js做浮点数计算很坑,其实也只是遵守标准而已,如果是坑的话,这个坑就不止是js了。
原文发布时间为:2018年06月30日
本文作者:changli2018
本文来源:掘金 如需转载请联系原作者