前言
曾几何时我们惊讶于在控制台看到这样的情况
- 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 了.
来源: https://juejin.im/post/5b372f106fb9a00e6714aa21