从精度丢失来看JS的Number类型

1,032 阅读5分钟

前言

在前端面试中经常会碰到一道经典的面试题,为什么在 JS 中会出现 0.1+0.2 != 0.3 的情况。作为前端我们应该很熟悉这道题了,对于它的答案想必也早已了解 。但是 JS 的 Number 类型为什么会出现产生这样的特点 ,在计算机内部是如何对其进行存储的?带着疑问开始重新学习 JS的原始类型之一的Number。

JS 的 Number 类型是如何存储在计算机内部

在探究 JS 的 Number 类型为什么会出现精度丢失问题之前,要先了解计算机内部是如何存储 Number 类型的值,在ECMA-262标准可以查到

primitive value corresponding to a double-precision 64-bit binary format IEEE 754-2019 value

JS 中 Number 类型的值是依据 IEEE 754 中的 64位双精度浮点数的格式来进行存储的。而对于 IEEE 754 中定义的 64位双精度浮点数,它是由 符号位 (sign) 、指数 (exponent)、 尾数( mantissa )三部分组成。

IEEE 754 双精度浮点数.png

其中:

  • 符号位:存储数值的符号,0 表示正数,1表示负数;
  • 指数位:存储的是 二进制的幂 , 对浮点数做加权处理;
  • 尾数位:存储的是二进制的小数,即有效数值部分,代表浮点数的精度;

浮点数的二进制偏移

为了便于使用无符号整数来表示全部的指数取值 (方便直接比较两个浮点数的大小) , IEEE 754 采用了 移码 (Offset binary) 的方式来进行编码,即规定了在指数域中的编码值等于实际的编码值加上一个固定的偏移值,按照64位双精度浮点数来说,原本的 2112^{11}(即 0 ~ 2047)范围内的数值 要分出一半的数值来表示 负数 , 所以64 位双精度浮点数的固定偏移值为 21112^{11-1}-1 = 1023 (0与2047 要表示特殊值的处理)。当在计算浮点小数时要用指数减去这个固定的偏移值才能得到真正的指数。 所以64位双精度浮点数的实际指数的范围为 -1022~1023 , 所以 JS 中可以表示的数值范围就是 [22013522^{-2013-52}, 210232^{1023}] (其中当指数位 -1023 且有效数字二进制位全部都为 0 时 ,表示的数值为 0) 。

Math.pow(2,-1074)
// 5e-324

Math.pow(2,-1075)
// 0

而且 IEEE 754 规定 规约形式的浮点数 的第一位总是 1 ,即规约形式的浮点数表现形式为 1.xx...xxx 。 其中 1 为隐藏位,不保存在计算机中, 计算机内部仅存储 xx...xxx 这一部分的有效数字。所以对于 JS 中的 Number 类型来说在 计算机内部可以有 53 个有效数字的二进制位 ,这也意味着 JS 中可以 精确的表示出 2532^{-53}2532^{53}之间的任意整数

Math.pow(2,53) 
// 9007199254740992

Math.pow(2,53) + 1
// 9007199254740992

JS 中 Number 类型的特殊值

JS 中存在 +0 、-0 、+Infinity 、-Infinity 以及 NaN,根据 IEEE 754 规定 (针对 64位双精度浮点数),

  • 当指数为0 且尾数的二进制表示全为0 时 表示 为 0值 ;
  • 当指数为2047 且尾数的二进制表示全为0 时 表示 为 Infinity值;
  • 当指数为2047 且尾数的二进制表示不全为0 时 表示 为 NaN值(在 JS 中 NaN 的值为 9007199254740990 即(2532^{53}-2) );

在 JS 中通常情况下+0、-0都是一样的,他们仅仅是 64位浮点数的符号位不同 ,只有他们在做分母的时候返回的结果值是不同的,

1/+0 === 1/-0
// false

1/+0
// Infinity

1/-0
// -Infinity

JS 中 Infinity 表示 无穷 ,一般出现在非0数值除以0 以及数值过大超出 JS 的数值范围如

Math.pow(2,1024) 
// Infinity

而对于 NaN 通常表示位非数值 ,但NaN 仍然是 Number 类型 ,可以通过 Number.isNaN() 来判断是否为 NaN

浮点数的舍入

上面了解了 JS 的 Number 类型在 计算机中的存储形式,所以可以知道通常我们写的十进制的数值代码如 const a = 106.3在计算机的内部都是要转为二进制的形式来进行存储,对于十进制整数来说都可以完美的转为二进制形式来进行存储,但是对于十进制小数来说,大部分转换成二进制的时候都会形成循环小数,就像众所周知的圆周率 3.1415926... , 通常计算机不可能用很大的存储空间来存放对于日常中并没有那么重要的小数。所以计算机就会采取丢弃多余的 bit 的策略来进行存储 ,就会导致出现精度丢失的情况 ,而当再次转为十进制的时候就会出现0.1 + 0.2 != 0.3

解决方案

在 ES6 中给Number 类型添加了许多的属性与方法,其中有一个属性就是 Number.EPSILON , 它表示 1 与大于 1 的最小浮点数之差 , 相当于是 1.0...(51个0)...1 - 1 的值 ,相当于 2522^{-52} 的值所以可以通过将 Number.EPSILON当作参考值 , 与它比较 ,当等号左右两边的差值小于 Number.EPSILON 时可以认为他们是相等的,例如

function isEqual(){ 
    return Math.abs(x - y) < Number.EPSILON 
}
isEqual(0.1+0.2 , 0.3)
// true

结语

通过这两天利用空余时间查找资料对JS 的基础知识重新认识使我收获很多,以前觉得很简单的 Number 类型原来深究都会有许多的知识等待着去探索学习。

文中如有错漏之处还请各位同行斧正,最后🙏 感谢大家的阅读。