学过前端的开发人员在项目开发的时候, 都会遇到 0.1+0.2!=0.3 的诡异问题. 按照常规的逻辑来思考, 这肯定是不符合我们的数学规范. 那么 JavaScript 中为啥会出现这种基本运算错误呢, 其中的原理又是什么. 这篇文章将从原理给大家梳理此问题的缘由
JavaScript 数值问题
在进入原理解析之前, 笔者先抛出三个基本问题, 大家可以先思考一下.
问题一:
JavaScript 规范中的数量值如何计算, 出现 NaN 的原因, 以及 NaN 的数量值
The Number type has exactly 18437736874454810627 values...(为什么是这个数)
问题二:
- Number.MAX_SAFE_INTEGER === 9007199254740991 // 为什么是这个数
- Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2 //true
问题三:
0.1 + 0.2 != 0.3 // 原因是什么?
计算机中的二进制
接下来进入正文, 学过计算机基础的人都知道, 计算机底层是通过二进制来进行数据之间的交互的. 其中我们应该要明白为什么计算机通过二进制来进行数据交互, 以及二进制是什么
1. 计算机为什么要通过二进制来进行数据交互?
在我们日常使用的电子计算机中, 数字电路组成了我们计算机物理基础构成, 这些数字电路可以看成是一个个门电路集合组成, 门电路的理论基础是逻辑运算. 那么当我们的计算机的电路通电工作, 每个输出端就有了电压. 电压的高低通过模数转换即转换成了二进制: 高电平是由 1 表示, 低电平由 0 表示.
说得简单点, 就是计算机的基本运行是由电路支持的, 电路容易识别高低电压, 即电路只要能识别低, 高就可以表示 "0" 和 "1".
2. 二进制是什么
二进制就跟我们的十进制一样, 十进制是逢十进一, 二进制就是逢二进一.
比如 001 如果增加 1 的话, 在十进制中就是 002, 在二进制中则变成了 010, 因为 002 的 2 需要进一位.
那么我们平常在计算机中的计算都是十进制的, 所以计算机在处理我们的运算的时候, 会把十进制的数字转化为二进制的数字之后, 再进行二进制加法, 得到的结果转化为十进制, 从而呈现在我们的屏幕中. 这些转化都是通过计算机内部操作的, 平常我们是看不到他们转化的过程. 那么机智的你肯定就明白了 0.1 + 0.2 != 0.3 这个问题, 肯定跟十进制转二进制, 然后二进制转回十进制的处理 (精度丢失) 有关系.
计算机的十进制运算
从上面可知, 我们已经定位到了问题所在, 不着急, 我们先确定二进制转十进制, 十进制转二进制怎么实现, 才能分析精度丢失的原因.
十进制转二进制
十进制整数转二进制
示例: 将十进制的 21 转换为二进制数.
方法: 将整数除于 2, 反向取余数
- 21 / 2 = 10 -- 1
- 10 / 2 = 5 -- 0
- 5 / 2 = 2 -- 1
- 2 / 2 = 1 -- 0
- 1 / 2 = 0 -- 1
二进制(反取余数):10101
十进制小数转换为二进制
示例: 将 0.125 换算为二进制
方法: 将小数部分乘以 2, 然后取整数部分, 至到小数部分为 0 截止. 若小数部分一直都无法等于 0, 那么就采用取舍. 如果后面一位是 0, 那么就舍去. 如果后面为 1, 那么就进一. 读数要从前面的整数读到后面的整数
- 0.125 * 2 = 0.25 -- 0
- 0.25 * 2 = 0.5 -- 0
- 0.5 * 2 = 1.0 -- 1
二进制: 0.001
二进制转化为十进制
二进制转化为十进制, 整数部分和小数部分的方法都是相同的.
示例: 将二进制数 101.101 转换为十进制数
方法: 将二进制每位上的数乘以权, 然后相加之和即是十进制数
计算机中将十进制转化为二进制之后, 进行了二进制的相加.
注意: 在计算机的运算中, 只有加法运算. 如 5 - 5 会变成 5 + (-5)
在二进制的运算中, 为了防止运算不正确, 以及最高位溢出问题. 引入了原码, 反码, 补码等概念. 由于篇幅有限, 在这里就不展开对原码, 反码, 补码的概念, 有兴趣的读者可以自行查阅资料.
JavaScript 中的数值 -- 浮点数 IEEE 754
那么讲完基础内容, 回归到我们的 JavaScript 中来. 众所周知 JavaScript 仅有 Number 这个数值类型, 而 Number 采用的是 IEEE 754 64 位双精度浮点数编码. 所以在 JavaScript 中, 所有的数值都是通过浮点数来表示, 那么 IEEE 754 标准是怎么样的呢, 在 JavaScript 中又是怎么约定 Number 值的.
IEEE 754 的标准, 个人理解就是通过科学计数法的方式控制小数点的位置, 来表示不同的数值.
在 wiki 中, IEEE 754 规定了四种表示浮点数值的方式: 单精确度 (32 位), 双精确度(64 位), 延伸单精确度(43 比特以上, 很少使用) 与延伸双精确度(79 比特以上, 通常以 80 位实现), 通常我们只会使用到单精确度(32 位), 双精确度(64 位)
单精确度 (32 位) 表示
双精确度 (64 位) 表示
从上面两张图, 可以看出数值用 IEEE 754 标准表示时, 被划分为三个区段, 有 sign,exponent 以及 fraction. 而理解这三个区段是学习 IEEE 754 标准的重点所在. 那么这三个区段分别表示什么呢? 不急, 我们先了解一下经过 IEEE 754 标准之后, 我们的二进制的数值应该怎么表示, 然后再来学习这三个定义.
在国际规定的 IEEE 754 的标准中, 不管是 32 位单精确度, 还是 64 位双精确度, 任何一个二进制浮点数 V 都可以有如下图的表示, 图源自于阮一峰老师博客
其中:
(-1)^s 表示符号位, 当 s=0,V 为正数; 当 s=1,V 为负数.
M 表示有效数字, 大于等于 1, 小于 2.
2^E 中的 E 表示指数位.
举个例子, 十进制的 7 转二进制就是 111, 就相当于 1.11*2^2, 那么此时 s = 0,M = 1.11,E = 2;
如果十进制的 - 7 转二进制就是 - 111, 就相当于 - 1.11*2^2, 那么此时 s = 1,M = 1.11, E = 2;
其实, 在公式中的 s 就相当于 sign(符号位)判断数值正负, M 就相当于 fration(有效数字),E 就相当于 exponent(指数).
在 32 位单精确度下, 符号位 sign 是最高位, 占一位大小, 接着的 8 位是指数 E, 剩下的 23 位为有效数字 M.
在 64 位单精确度下, 符号位 sign 是最高位, 占一位大小, 接着的 11 位是指数 E, 剩下的 52 位为有效数字 M.
那么我们接下来讨论, 指数 E 以及有效数字 M 是怎么定义的. 前面提及了有效数字 M 是大于等于 1, 小于 2 的. 其实这很好理解, 在我们的科学计数法中, 有效数字开头通常都是 1, 即 1.XXXX 的形式, 其中 XXXX 就是小数部分, 那么在 32 位精确度中, 有效数字 M 占了 23 位, 那么是否 XXXX 只能占 22 位呢, 其中 1 位留给整数部分 1. 聪明的标准制定者们为了使 32 位精确度能够表示更多的有效数字, 决定整数部分的 1 不占有效数字 M 的一位. 于是 XXXX 能够占 23 位, 这样等到读取的时候, 再把第一位的 1 加上去, 那么就等于可以保存 24 位有效数字了. IEEE 754 规定, 在计算机内部保存 M 时, 默认这个数的第一位总是 1, 因此可以被舍去, 只保存后面的 xxxxxx 部分. 同样, 64 位精确度的 M 也相当于可以保存 53 位有效数字
那么指数 E 就比较复杂了, 由于 E 是一个无符号的整数, 那么在 32 位精确度中 (E 占 8 位), 可以表示的取值范围为 0 ~ 255, 在 64 位精确度中可以表示的取值范围为 0 ~ 2047. 但是其实我们的科学计数法指数部分是可以出现负数的. 那么如何使用 E 来表示负数呢, 可以将 E 取一个中间值, 左边的就为负指数, 右边就为正指数了. 于是 IEEE 754 就规定, E 的真实值(即在 exponent 中表示的值) 必须再减去一个中间数, 32 位精确度中的中间数是 127,64 位精确度中的中间数是 1023;, 看到加粗的字就可以明白, 指数范围其实表示的是 - 127~128; 这样我们就可以在 32 位精确度中表示从
例子: 十进制的 7 转二进制就是 111, 就相当于 1.11*2^2, 此时 E = 2, 那么这时候的 E 其实已经减了中间值了, 所以 E 的真实值为 2 + 127 = 129, 二进制为 10000001;
同时指数 E 还可以根据规定分为三种情况讨论(以 32 位精确度作为讨论)
1.E 不全为 0 或不全为 1 这个阶段就是正常的浮点数表示, 通过计算 E 然后减去 127 即为指数
2.E 全为 0 浮点数的指数 E 等于 0-127 = -127, 当指数为 - 127 时, 有效数字 M 不再加上第一位的 1, 而是还原为 0.xxxxxx 的小数. 这样做是为了表示 ±0, 以及接近于 0 的很小的数字
3.E 全为 1 此时如果有效数字 M 全为 0, 那么就表示 +∞或者 -∞, 取决于第一位符号位. 但是如果有效数字 M 不全为 0, 则表示这不是一个数(NaN)
回到 JavaScript
在上面的讨论中, 我们很少提及 JavaScript, 似乎跟我们的文章主题不搭边, 但是在了解了上述的原理之后, 你将会对 JavaScript 中的数字的理解有质的飞跃.
接下来的内容将会带领大家一步一步解决上面提出的这些疑问:
1.JavaScript 规范中的数值量, 为什么是这个数?
首先需明白在 JavaScript 中的数字是 64-bits 的双精度, 所以有 2^64 种可能性, 在上述中提到, 当 E 全为 1 的时候, 表示的要么为无穷数, 要么为 NaN. 所以不是数值的可能为 2^53 种, 同时 JavaScript 中把 +∞和 -∞,NaN 定义为数值. 所以 JavaScript 数值的总量为
同时我们也可以直接推算出 JavaScript 中 NaN 的具体数量有多少, 因为上述中 NaN 的定义为在 E 全为 1 的情况下, 如果有效数字 M 不全为 0, 则表示这不是一个数. 即排除掉有效数字 M 全为 0 的情况就行(+∞,-∞)
2.JavaScript 中的最大安全整数值为什么为 9007199254740991
上述提及, 有效数字有 53 个(包括最前面一位的 1.xxxx 中的 1), 如果超出了小数点后面 52 位以外的话, 就遵从二进制舍 0 进 1 的原则, 那么这样的数字就不是一一对应的, 会有误差, 精度就丢失了. 也就不是安全数了. 所以 JavaScript 中的最大安全整数值为
3. 0.1 + 0.2 != 0.3 ?
这个问题也许是大家最关心的问题, 也是最经典的 JavaScript 面试问题. 不过学习了上面的知识之后, 大家已经明白了问题产生的原因(精度丢失), 那么具体是如何丢失的呢?
首先, 0.1 + 0.2 这个运算是十进制的加法, 上述提及, 计算机处理十进制的加法其实是先将十进制转化为二进制之后再运算处理. 那么我们需要计算出 0.1 的二进制, 0.2 的二进制以及 0.3 的二进制来进行对比校验.
根据上述的计算方法, 我们很容易得出 0.1 的二进制是无限循环的, 即
- 0.1D = (-1)^0 * 1.1001..(1001 循环 13 次)1010B * 2^-4
- 0.2D = (-1)^0 * 1.1001..(1001 循环 13 次)1010B * 2^-3
- 0.3D = (-1)^0 * 1.0011..(0011 循环 13 次)0011B * 2^-2
可以看出, 当 0.1,0.2 转化为二进制的时候, 有效数字都是 52 位(4 * 13 + 4), 因为在 64 位精确度中, 只能保持 52 位有效数字, 如果没有 52 位有效数字的约束, 其实在第 53 位中, 0.1 转二进制本来是 1, 但是有了 52 位约束之后, 根据二进制的取舍 , 最后五位数就从 1001 1(第 53 位) 变成了 1010.
我们可以手动计算一下 0.1 的二进制加上 0.2 的二进制
那么相加结果转换为十进制其实等于 0.30000000000000004, 这就是为什么 0.1 + 0.2 != 0.3 的原因了.
结尾
从一个诡异的问题出发, 去理解为什么会出现这样的现象, 以及里面的原理, 想必这就是一个程序员的执着, 实事求是, 刨根问底, 就会得到更多的收获. 相信大家看完文章之后, 对 JavaScript 的数值也会有更深的理解.
来源: https://juejin.im/post/5c3db8b7e51d45515817bdeb