js浮点数存储精度丢失原理

5,262 阅读6分钟

前言

曾几何时我们惊讶于在控制台看到这样的情况

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精度丢失过程

  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的值,不同的二进制数据,可能是前移可能是右移,对应的是指数的正负范围,转换后是这样子的

1.1001100110011001100110011001100110011001100110011001100110011001..... * 2 ^ -4
  1. 提取数据,进行数值截取,导致精度丢失

    这里可以看到向右移动了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浮点数存储过程就结束了,为了表示我不是忽悠大家的,大家可以对照第一部分输出的数据值。下面将顺便介绍一下怎么转回十进制

丢失精度的数据转回十进制

  1. 提取尾数位数据
1001100110011001100110011001100110011001100110011010
  1. 先前添加 1. 恢复为指数格式 并提取指数位
1.1001100110011001100110011001100110011001100110011010
01111111011 => 1019
1019 - 1023 = -4
1.1001100110011001100110011001100110011001100110011010 * 2 ^ -4
  1. 移位
0.00011001100110011001100110011001100110011001100110011010
  1. 二进制转化为十进制 小数的二进制转化为十进制网上的资料也有很多,我也简单介绍一下过程,以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了。