hgq's docs
主页
ES6-阮一峰 (opens new window)
Vue文档 (opens new window)
Axios文档 (opens new window)
Vue Router (opens new window)
Vuex文档 (opens new window)
面试题-Vue (opens new window)
面试题-JS (opens new window)

guoguoqiqi

漫不经心的向往
主页
ES6-阮一峰 (opens new window)
Vue文档 (opens new window)
Axios文档 (opens new window)
Vue Router (opens new window)
Vuex文档 (opens new window)
面试题-Vue (opens new window)
面试题-JS (opens new window)
  • JS数据类型
  • JS数据类型的转换
  • 判断数据类型的方法
  • null和undefined的区别
  • parseInt方法解析
  • promise简答
  • var和let以及const的区别
  • 本地存储的几种方式
  • 闭包是什么
  • 调用new的过程
  • 防抖和节流
  • 简单讲讲异步的理解
  • 简单讲讲原型和原型链
  • 判断变量的数据类型
  • 深浅拷贝
  • 数组去重的方法
  • 说一说JS中的this
  • 作用域和作用域链
  • JS的内置对象
  • JS继承的几种实现方式
  • 谈谈你对this、call、apply和bind的理解
  • 说一说原型、原型链
  • 什么是闭包,为什么使用闭包
  • JS中事件冒泡与捕获
  • 说一说JS的事件模型
  • 常用的遍历数组的方法
  • 如何创建一个Ajax
  • js 延迟加载的方式有哪些
  • 谈谈你对模块化开发的理解
  • js 的几种模块规范
  • ES6 模块与 CommonJS 模块、AMD、CMD 的差异
  • JS的运行机制、单线程、事件循环、宏任务微任务
  • arguments 的对象是什么
  • 简单介绍一下 V8 引擎的垃圾回收机制
  • 哪些操作会造成内存泄漏
  • ES6有哪些新特性
  • 说一说箭头函数
  • 什么是高阶函数
  • 为什么函数被称为一等公民
  • js的深浅拷贝
  • 函数柯里化
  • 说一说new操作符
  • 说一说对Promise的理解
  • Generator函数是什么,有什么作用
  • 说一说async和await
  • instanceof的原理是什么,如何实现
  • js 的节流与防抖
  • 相关面试题集合
  • 如何更好的处理async、await的异常
  • JS的事件委托
  • 浏览器和node中事件循环的区别
  • mouseover 和 mouseenter 的区别
  • ===与Object is的区别
  • 数组去重有哪些方法
  • 页面在浏览器中渲染出来的原理和流程
  • js和css是否会阻塞页面的渲染
  • Set、Map、WeakSet 和 WeakMap 的区别
  • 说一说Promise的实现原理
  • 说一说JS的事件循环
  • == 和 === 与隐式转化
  • 说一说回流(重排)和重绘
  • script标签中添加async或defer的作用与区别
  • 如何中断一个请求
  • 遍历一个对象身上属性的方法有哪些
  • 常用的数组方法(不改变原数组)
  • 常用的数组方法(改变原数组)
  • 常用字符串操作方法
  • 对象身上与原型相关的方法
  • 数组的reduce方法
  • 常用的位运算符有哪些
  • 浮点数运算有误差的问题
    • 现象
    • 原因
    • 产生误差过程
    • IEEE 754
    • 解决方案
  • typeof和instanceof的区别
  • 有关js和css对页面渲染阻塞的问题
  • 说说对闭包的理解
  • DOMContentLoaded方法
  • es6中对象新增的方法
  • es6中数组新增的方法
  • es6中字符串新增的方法
  • es6新增的Reflect
  • 如何判断一个变量是否是数组
  • onload 和 DOMContentLoaded的区别
  • 大文件上传问题
  • 上传、下载和普通请求的区别
  • Javascript
guoguoqiqi
2022-03-03

浮点数运算有误差的问题

# 现象

话不多说,直接上图!

image

通过上图我们知道,答案是不相等,而且还有一个很神奇的问题,0.1+1-1 也不等于 0.1,并且先加后减和先减后加的结果竟然还不一样!

# 原因

其实这个问题不能完全怪 JavaScript,导致这样的问题是因为 JavaScript 中使用基于 IEEE 754 标准的浮点数运算,所以会产生舍入误差。
也就是说所有遵循 IEEE 754 标准的语言进行浮点数运算的时候,都会有这个问题。

# 产生误差过程

接下来我们来揭秘产生误差的过程。
首先这里我们需要知道,浮点数运算的时候需要先转成二进制,然后再进行运算。那么十进制浮点数是如何转二进制的呢?

浮点数转二进制的过程如下:
1.整数部分采用 /2 取余法

3 => 3/2 = 1 余 1  
1 => 1/2 = 0 余 1  
所以 3(十进制)= 11(二进制)
复制代码
1
2
3
4
4 => 4%2 = 2 余 0  
2 => 2%2 = 1 余 0  
1 => 1%2 = 0 余 1  
所以 4(十进制)= 100(二进制) 
复制代码
1
2
3
4
5

2.小数部分采用 *2 取整法

0.5 => 0.5*2 = 1 取整 1
0.5(十进制)= 0.1(二进制)
复制代码
1
2
3
0.1 => 0.1*2 = 0.2 取整 0
0.2 => 0.2*2 = 0.4 取整 0
0.4 => 0.4*2 = 0.8 取整 0
0.8 => 0.8*2 = 1.6 取整 1
0.6 => 0.6*2 = 1.2 取整 1
0.2 => 0.2*2 = 0.4 取整 0
0.4 => 0.4*2 = 0.8 取整 0
0.8 => 0.8*2 = 1.6 取整 1
0.6 => 0.6*2 = 1.2 取整 1
...发生循环
得到结果 0.1(十进制)= 00011001100110011001100110011... (0011)循环(二进制)
复制代码
1
2
3
4
5
6
7
8
9
10
11
12

同理,既有整数又有小数的数值进行二进制转换,就是分别对整数和小数部分进行二进制转换,再相加即可。

上面的例子中可以看到 0.1 转二进制会发生无限循环,而 IEEE 754 标准中的尾数位只能保存 52 位 有效数字(具体原因我们稍后讲解),所以 0.1 转二进制就会发生舍入,所以就产生了误差。

在讲解运算过程之前,我们需要 2 个前置知识:

  1. 十进制浮点数转换二进制后尾数的 52 位 有效数字是从第一个 1 开始向后保留 52 位 有效数字,所以接下来你会发现 0.1 和 0.2 保留 52 位 尾数后长度会不同。
  2. 在舍入的过程中,遵循 0 舍 1 入 的规则。
  3. 下面的过程中为了方便大家理解,我对所有的保留 52 位 尾数后后面没有 52 位 的情况进行了补零,对部分数字为了方便运算进行了超过 52 位 的补充和转换(比如 1)。

接下来我们一起看一下示例中的运算过程:

0.1
转二进制
0.0001100110011001100110011001100110011001100110011001100110011
保留52位尾数
0.00011001100110011001100110011001100110011001100110011010

0.2
转二进制
0.001100110011001100110011001100110011001100110011001100110011
保留52位尾数
0.0011001100110011001100110011001100110011001100110011010

进行相加
0.00011001100110011001100110011001100110011001100110011010
0.0011001100110011001100110011001100110011001100110011010
----------------------------------------------------------
0.01001100110011001100110011001100110011001100110011001110
相加后的结果保留52位尾数
0.010011001100110011001100110011001100110011001100110100
转十进制
0.30000000000000004
复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

接下来是 0.1+1-1 的运算过程:

0.1
转二进制
0.0001100110011001100110011001100110011001100110011001100110011
保留52位尾数
0.00011001100110011001100110011001100110011001100110011010

1
转二进制并保留52位尾数
1.0000000000000000000000000000000000000000000000000000

进行相加
0.00011001100110011001100110011001100110011001100110011010
1.0000000000000000000000000000000000000000000000000000
----------------------------------------------------------
1.00011001100110011001100110011001100110011001100110011010
相加后的结果保留52位尾数
1.0001100110011001100110011001100110011001100110011010
再减1
0.00011001100110011001100110011001100110011001100110100000
转十进制
0.10000000000000009
复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

接下来是 0.1-1+1 的运算过程:

0.1
转二进制
0.0001100110011001100110011001100110011001100110011001100110011
保留52位尾数
0.00011001100110011001100110011001100110011001100110011010

1
转二进制并保留52位尾数
1.0000000000000000000000000000000000000000000000000000

进行相减,这里其实等价于 1-0.1 转负数
为了方便相减,我们将 1 的二进制进行转换和补零
0.11111111111111111111111111111111111111111111111111111120
0.00011001100110011001100110011001100110011001100110011010
----------------------------------------------------------
0.11100110011001100110011001100110011001100110011001100110 这里是一个接近 0.9 的负数
相减后的结果保留52位尾数
0.11100110011001100110011001100110011001100110011001101

此时 -0.9+1 等价于 1-0.9
同样,为了方便相减,我们将 1 的二进制进行转换和补零
0.11111111111111111111111111111111111111111111111111112
0.11100110011001100110011001100110011001100110011001101
-------------------------------------------------------
0.00011001100110011001100110011001100110011001100110011
相减后的结果保留52位尾数
0.00011001100110011001100110011001100110011001100110011000
转十进制
0.09999999999999998
复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

至此,我们就搞清楚了示例中的运算过程,从而知道了出现这些情况的原因,接下来,我们聊一下 IEEE 754,从而解开:

  1. 什么是尾数位
  2. 为什么是 52 位尾数位
  3. 为什么 0 舍 1 入

以及更多的浮点数神秘面纱~

# IEEE 754

IEEE 754 中双精度浮点数使用 64 bit 来进行存储:

  • 第一位存储符号表示正负号 0 正 1 负
  • 2-12位存储指数表示次方数
  • 13-64位存储尾数表示精确度

符号位没有什么可说的,就是用来表示正负数的。
指数位表示次方数,这里的次方数是以当前的进制数为底,比如次方数为 5:

  • 如果当前为十进制,就是 10 的 5 次方
  • 如果当前为二进制,就是 2 的 5 次方

尾数位储存尾数表示精确度,用来表示一个大于等于 1 小于 2 的数值

综上所述,如果我们以 s 表示正负号,h 表示进制数,e 表示次方数,f 表示尾数,则浮点数 value 可以表示为:

value=s∗f∗hevalue = s*f*h^evalue=s∗f∗he

相信到了这一步,小伙伴们对指数位和尾数位的理解会更清楚一点,也解释了前两个问题。

  1. 尾数位就是 64 bit 浮点数存储尾数的部分,可以表示数值的精确度
  2. 52 位 是在 IEEE 754 标准制度的时候规定如此

而我们上面直接转二进制运算的情况下,实际上是糅合了指数位和尾数位的一个结果,所以我们保留 52 位,是在第一个 1 后面保留 52 位 有效数字。
不知道你有没有发现一个问题: 尾数位只有 52 位,但是我们现在在第一个 1 后面保留 52 位 有效数字,那再加上前面的 1 不就是 53 位 位了吗?
这是因为,尾数部分的整数部分一定是一个 1,那为了充分利用 52 位 空间表示更高的精确度,可以把一定等于 1 的整数部分省略,52 位 都用来表示小数。

# 最大安全整数

同理,因为只有 52 位 尾数,所以 JavaScript 中的最大安全整数是 2^53-1,其中 53 是 52 位 尾数加上前面省略的 1,而 -1 是因为 2^53 已经是一个边界值了,大于它的值会和它相等,所以最大的安全整数是 2^53-1。

image

# 舍入规则

IEEE 754 标准列出4种不同的方法:

  • 舍入到最接近:舍入到最接近,在一样接近的情况下偶数优先(Ties To Even,这是默认的舍入方式):会将结果舍入为最接近且可以表示的值,但是当存在两个数一样接近的时候,则取其中的偶数(在二进制中是以0结尾的)。
  • 朝+∞方向舍入:会将结果朝正无限大的方向舍入。
  • 朝-∞方向舍入:会将结果朝负无限大的方向舍入。
  • 朝0方向舍入:会将结果朝0的方向舍入。

第一种规则(也就是默认的舍入方式)可以简单理解为我们常用的 四舍五入,而转化到我们这里的二进制浮点数运算,就是 0 舍 1 入。

# 解决方案

  1. 使用 JavaScript 提供的最小精度值判断误差是否在该值范围内
    Math.abs(0.1 + 0.2 - 0.3) <= Number.EPSILON
  2. 转为整数计算,计算后再转回小数
  3. 保留几位小数 比如金额,只需要精确到分即可
  4. 使用别人的轮子,例如:math.js
  5. 转成字符串相加(效率较低)

# 参考文章链接

用了一天时间,我终于彻底搞懂了 0.1+0.2 是否等于 0.3! (opens new window)

← 常用的位运算符有哪些 typeof和instanceof的区别→

最近更新
01
vuex数据持久化怎么做
05-22
02
vue的动态路由怎么配置使用
05-22
03
vue权限控制一般怎么做
05-22
更多文章>
Theme by Vdoing | Copyright © 2022-2022 Guoquoqiqi | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式