扒去JS中Number那最后一层“衣服”

​JavaScript采用“IEEE 754 标准定义的双精度64位格式”表示Number数字。也就是说,js没有所谓的整数,所有数字实质上都是浮点数 . . . . . .

然后,完了。

呃 . . . . . .  我果然是一个失败的标题党,话还没说完,“衣服”就扒光了。 

好吧,失败就失败,问题不大,这并不妨碍我们对Number进行探究。

我们说JS中的Number实质上就是IEEE 754 标准定义的双精度浮点数,学习过编程语言的都知道双精度是个什么东西,无非就是使用64位(8字节)表示一个数值,但是对于表示方式以及双精度和数值类型Number之间的纠葛可能就存在一层“衣服”,所以我接下来探究如何扒掉这一层衣服。

IEEE 754 标准定义的双精度64位的双精度浮点数有“三段式”,划分为“1-11-52”,就是1位最高位(最左边那一位)表示符号位(S),0表示正,1表示负;接下去的11位表示指数部分(E);最后52位表示尾数部分(M),也就是有效域部分。

IEEE754标准规定一个实数V可以用一个形式来表示

v = (-1)^S*M*2^E
  • 符号S(sign)决定实数是正数(s=0)还是负数(s=1)。

  • 有效数字M是二进制小数,M的取值范围在1≤M<2或0≤M<1。

  • 指数E(exponent)是2的幂,它的作用是对浮点数加权。

说明:以上分布图中的M是指64位浮点数保存表示法中的52位,其保存的是小数点右边的二进制位,其实就是小数段,隐藏位默认不保存。而表达式中那个的M是包含隐藏位的有效部分,其值是1.xxx...xxxxx,也就是说在计算的时候其隐藏位不能计入其中,实际上有53位。而下面的分类图表中的m就是上方分布图中的小数段M。

要注意的是,符号位决定了一个数的正负,指数部分决定了数值的大小,小数部分决定了数值的精度。

对于双精度来说,指数域E是使用11位二进制位表示,也就是说,指数域可表示0 ~ 2047范围内的2048个值。IEEE754中为了使得E也可以表示负数,实际的指数值按要求需要加上一个偏差(Bias)值作为保存在指数域中的值,双精度数的偏差值为 1023(01 1111 1111)(10位),相当于E = e + 1023(e为实际指数值)。比如,实际指数值 0 在指数域中将保存为 1023;而保存在指数域中的0 则表示实际的指数值 -1023。偏差的引入使得实际可以表达的指数值e的范围就变成 -1023 到 1024之间(包含两端)[-1023, 1024],这两端的两个极端值结合不同的尾数部分代表了不同的含义。

尾数域也叫浮点数有效部分,指能被有效表示的位数,双精度浮点数的尾数是52位,除了一些后面要讲的特殊值之外,IEEE754标准要求浮点数必须是规范的,这意味着尾数小数点的左侧必须是1,也就是1.×××××,而在保存尾数时,1可以省略掉,只保存小数点右侧的数位,所以用了52位尾数域来表示53位的尾数,小数点后超出52位的会被“舍弃”。比如,二进制的 1001.101(对应于十进制的 9.625)可以表达为 1.001101 × 2^3,所以实际保存在尾数域中的值为 0011010000000000000000000000000000000000000000000000,即去掉小数点左侧的 1,并用 0 在右侧补齐。

根据指数E和尾数M的不同,浮点数分为五大类。首先就是我们能使用IEEE754标准规范化表示的普通常规指,但我们要知道在浮点数计算的过程中,有可能出现一些特殊情况或者说是错误,使得浮点数无法按照IEEE754的标准来进行表示,所以在IEEE754标准中,专门定义了一些特殊值来用于特殊情况或者错误处理。比如在程序对一个负数进行开平方时,一个特殊的返回值将用于标记这种错误,该值为 NaN(Not a Number)。没有这样的特殊值,对于此类错误只能粗暴地终止计算。除了 NaN 之外,IEEE 标准还定义了 ±0,±∞ 以及非规范化数(Denormalized Number)。

从上面我们知道双精度浮点数的实际指数值范围为[-1023, 1024],但在IEEE754标准中,规范化数值的指数值并不包括-1023和1024这两个端点,它们其实是用于表示特殊值所保留的特殊指数值,如果我们分别用 emin 和 emax 来表达规范化数值的指数值范围的边界,即 -1022 和 1023,则保留的特殊指数值可以分别表达为 emin - 1 (即-1023)和 emax + 1(即1024)。基于这个表达方式,我们可以得到五大类浮点数值

其中 m表示尾数部分,即标准记法中的有效部分(1.×××××)减去1得到小数点后的差就是尾数m,其不包含隐藏位。

我们为了更直观的看分类图表的各类区间分布,画了一个横轴,除了非数字NaN之后,其余几类的分布如下图。

接下来我们将分别对它们加以介绍。

神奇的零

在IEEE754标准的浮点数格式中,小数点左侧的1是隐藏的,而零显然尾数也必须为零,也就是隐藏位也为0,这就使得无法使用规范化格式来表示零,所以IEEE754标准里只能将零作为特殊值处理。特殊值零的表示和上表的一样,尾数部分f为0,指数部分e为emin - 1 (-1023),即指数全为0,但是因为有符号位的存在,所以使得零也具有正负零,也就是存在两个零,分别是+0和-0。

我们要注意的是,虽然0有正负,但是在IEEE743标准中规定正负0是无序的,也就是说大小相等,这点不同于下面要讲的正负无穷,其是有序的,因为正无穷要大于负无穷,这是毋庸置疑的。

+0 == -0 // true

也许我们会觉得正负0既然大小相等,那么为何还要保留正负符号?其实,正因为0具有正负,在某些运算中才不会产生矛盾。当涉及到无穷的运算时,如若0不分正负,可能会出现符号的丢失,举个例子

1/(1/x)=x

如果0不存在符号,那么以上等式当x = ±∞ 时是不可能成立的,因为1 和正负无穷的比值为同一个零,然后 1 与 0 的比值为正无穷,最后等号左边为正无穷,而等号右边却是正负无穷,显然等式不成立。而要解决这个问题,除非无穷也没有符号,但是无穷的符号表达了上溢发生在数轴的哪一侧,所以显然符号对于无穷来说是必要的。另一方面,零具有正负也造成了其它问题,例如

1/x = 1 / y 

当 x=y并且 x 和 y 分别为 +0 和 -0 时,等式两端分别为正无穷和负无穷,可知等式不成立,除非规定零也是有序的。可是如果零是有序的,则使得类似 if (x==0) 这样简单的判断也因为 x 可能是 ±0 而变得不妥当,最后还是无序较好,其所造成的的影响较小。

非规格化

什么是非规范化呢?我们先来考察一下浮点数的一种特殊情况,选择两个极小的浮点数,例如1.001 × 2^-1021和1.0001 × 2^-1021,可以知道以上的两个数的指数都大于规范化数值指数允许的最小值-1022,现在我们来看两个数的差值,由前者减去后者得到0.0001 × 2^-1021,规范化表示为1  × 2^-1025,其问题在于运算结果的指数小于规范化数值指数允许的最小值-1022,所以导致其无法保存为规范化浮点数。

为了解决这种特殊情况,IEEE标准引入了非规范化浮点数,规定当浮点数指数值为规范化允许的最小指数值,即emin(-1022)时,尾数不必是规范化的,也就是说隐藏位为0,比如上面运算结果1  × 2^-1025可以表示为非规范化浮点数0.001  × 2^-1022。为了保存非规范浮点数,IEEE 标准采用了类似处理特殊值零时所采用的办法,即用特殊的指数域值 emin - 1 加以标记,但是此时的尾数域不能为零,尾数也就是相当于是0.000xx...,没有隐含的尾数位。

有了非规范浮点数,去掉了隐含的尾数位的制约,可以保存绝对值更小的浮点数,所以实际上,非规范数就是介于规范化数和0之间的数值区间

通过上面,我们可以知道在绝对值情况下,双精度非规范浮点数的最大值和最小值,其最大值为指数值 emin-1( -1023 ),尾数0.11.....1(尾数全为1),即

Math.pow(2,-1023)*(Number.parseInt( "1".repeat(52) ,2) * Math.pow(2,-52))// 1.1125369292536007e-308

其最小值为指数值 emin-1( -1023 ),尾数0.00...01(尾数最后一位为1,其余位为0),即

0.00...01 * 2^-1023 = 2^-52*2^-1023 = 2^-1075// 0

规格化

规格化是指浮点数的指数域不全为0或者不全为1,并且尾数有效部分隐藏位为1,其隐藏位在小数点左边。隐藏位不计入52个二进制位中,也就是说规格化浮点数有效位实际上是53位,例如1.1001xxx...xxx。再啰嗦讲一下,就是规格化的浮点数的有效部分必须是1开头,1之后以小数点分隔,小数点之后的位数存入尾数M中,不够52位的使用0补齐。

规格化是除却其他四种特殊值之外所能表示最多浮点数的一种类型,是一种普遍化、大范围的类型,它作为一个表示区间,也有相应的的最大值和最小值,其最大值是指数域位emax,即1023,有效部分全为1,也就是1.1111...111

Math.pow(2,1023)*(Number.parseInt( "1".repeat(53) ,2) * Math.pow(2,-52))// 1.7976931348623157e+308

规格化浮点数的最小值是指数域为1,有效部分为1.0000...0

Math.pow(2,-1022)*1.0// 2.2250738585072014e-308

其中规格化的最大值其实就是JavaScript中number数值类型的最大值,我们可以通过Number.MAX_VALUE获得

Number.MAX_VALUE // 1.7976931348623157e+308

无穷

无穷的指数部分是emax+1,即1024,不过无穷的尾数部分必须为0。也就是

v = (-1)^s * 2^1024 * 1.0

无穷也存在正无穷和负无穷,具体由符号位s决定,我们使用JS的Math.pow()方法计算,会知道大于或等于该值的都会返回无穷Infinity

Math.pow(2, 1024) // InfinityMath.pow(2, 1024) + 1 // Infinity

无穷用于表达计算中产生的上溢(Overflow)问题。比如两个极大的数相乘时,尽管两个操作数本身可以用保存为浮点数,但其结果可能大到无法保存为浮点数,这时候会产生上溢问题,则必须进行舍入。据 IEEE 标准,此时不是将结果舍入为可以保存的最大的浮点数(因为这个数可能离实际的结果相差太远而毫无意义),而是将其舍入为无穷。对于负数结果也是如此,只不过此时舍入为负无穷,也就是说符号域为 1 的无穷。特殊值无穷使得计算中发生的上溢错误不必以终止运算为结果。

要知道的是,除 NaN 以外的任何非零值除以零,结果都将是无穷,而符号则由作为被除数和除数的零的符号决定。

1 / 0 // Infinity1 / -0 // -Infinity-1 / 0 // -Infinity

而当0除以0时返回的却是NaN

0 / 0  // NaN

这是因为当除数和被除数都逼近于零时,其商可能为任何值,所以 IEEE 标准决定此时用 NaN 作为商比较合适。

NaN

NaN是Not a Number的缩写,它指非数字值。但是非数字值并不代表NaN不是Number类型的值,相反,它是属于Number类型的,这点我们可以使用JS中的typeof检验一下

typeof NaN // number

这是因为NaN实际上还是浮点数的一种,它只是无法使用IEEE754标准来进行规范化表示而已。但是IEEE为了处理一些在计算中出现错误的情况(例如上面说的对一个负数进行开方),将NaN定义为特殊值,用一种组特定的指数和尾数组合来表示NaN,从上表可以看到,NaN表示为指数部分emax+1,尾数部分不等于0的浮点数,因为IEEE754没有对NaN的尾数部分有具体的要求,所以实际上NaN不是一个,而是一簇。

要注意的是,所有的NaN值都是无序的,故在数值比较操作中任一操作数为NaN最终都会返回false,等于操作符(==)也同样返回false,即使是两个具有相同位模式的 NaN 也一样,但是NaN != NaN 却是返回true

NaN < 0 // falseNaN > 0 // falseNaN == NaN //falseNaN != NaN // trueNaN + 1 // NaN

此外,任何有NaN作为操作数的操作也将产生NaN,而NaN的意义也在于此,通过使用特殊值 NaN 来表达上述运算错误,避免了因这些错误而导致运算的不必要的终止。

在JS中存在一个全局函数isNaN()来判断一个值是否为NaN,此后延伸为判断一个值是否为Number类型值。但是,使用isNaN()函数判断一个值是否为数值的话,其中存在一个问题,它实际是将不能转换成number类型的其他类型及其自身NaN都判断为true,而除了其自身NaN外所有的可转换位number类型的都判断为false,例如

isNaN(null) // false null => 0isNaN('') // false '' => 0isNaN('123') // false '123' => 123isNaN([]) // false [] => 0isNaN(['123']) // false ['123'] => 123

所以,能转化为number的都为false

当然,判断一个值是否为数字类型的方法有许多种,这里就稍稍列举一种我觉得比较好的方法

function myIsNaN(value) {  return typeof value === 'number' && !isNaN(value);}myIsNaN('') //falsemyIsNaN(213) //true

值为数字类型时返回true,非数字类型则返回false

以上介绍就到此结束,我们接下来来研究几个具体的问题。

数值范围

实际上,通过以上我们可以知道,浮点数值具有一个区间范围,64位浮点数的指数部分的长度是11个二进制位,意味着指数部分的最大值是2047(211-1)。也就是说,64位浮点数的指数部分的值最大为2047,分出一半表示负数,则浮点数能够表示的数值范围为2^1024到2^-1023(开区间),超出这个范围的数无法表示,而JavaScript的number数值范围也是如此。

当指数部分等于或超过最大正值1024时,JavaScript会返回Infinity,这称为“正向溢出”,上面在讲无穷的时候也有提到。而JavaScript所能表示的最大正数和最大负数为

Number.MAX_VALUE    //1.7976931348623157e+308-Number.MAX_VALUE   //-1.7976931348623157e+308

超过以上值则会发生“正向溢出”。

当指数部分等于或超过最小负值-1023(即非常接近0),JavaScript会直接把这个数转为0,这称为“负向溢出”。而JavaScript所能表示的最小正数和最小负数为

Number.MIN_VALUE    //5e-324-Number.MIN_VALUE   //-5e-324

这里提一下,上面的Number.MIN_VALUE计算方式在规格化中有提到,其就是最大规格化数值,而最小值的计算方式是取分数值2^-52,即尾数部分只有最后一位为1,指数部分排除全零的情况,取最后一位的1,计算实际指数值e=1 - 1023 = -1022,所以最后为2^-52 * 2^-1022 = 2^-1074,即获得Number最小值

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

精度问题

接下来我们看一个神奇的比较语句

0.1 + 0.2 === 0.3 // false

what?不应该是相等的么?我们看一下左边的和是多少

0.1 + 0.2 // 0.30000000000000004

怎么会这样?我们上面说javasscript是采用“IEEE 754 标准定义的双精度64位格式”表示数字的,所以javascript都是先将数字转化为双精度64位的格式,然后再进行运算的。而实际上使用双精度格式来进行表示,则必然存在着精度问题,因为有些数字转化为二进制位后并不能被这52位的尾数表示完毕,表示不了的位数会被“丢弃”,也叫“舍入”。

我们看一下0.1和0.2的计算过程

0.1(10)=> 0.0001100110011001100110011001100110011001100110011001101...(2)0.2(10)=> 0.001100110011001100110011001100110011001100110011001101...(2)

拿出有效指数和尾数

-4  0.1001100110011001100110011001100110011001100110011001 ①-3  0.1001100110011001100110011001100110011001100110011001 ②

①式转化为纯小数,小数最低位1001被高位的0000挤出有效范围,得到③式;②式转化为纯小数,小数最低位001被高位的000挤出有效范围,得到④式。

0.0000100110011001100110011001100110011001100110011001 ③0.0001001100110011001100110011001100110011001100110011 ④

③ + ④ 相加得到

0.0100110011001100110011001100110011001100110011001100 // 0.30000000000000004(十进制)

可以看到上面0.1 + 0.2的运算中,当0.1和0.2被转化为双精度64位时,这时已经不是精确的0.1和0.2了,而是精度发生一定丢失的值。但是精度丢失还没有完,当这个两个值发生相加时,精度还可能进一步丢失,注意几次精度丢失的叠加不一定使结果偏差越来越大哦。

所以我们已经明白了,在对数值进行64位的二进制转化时,就很大可能会因为转化后尾数超过52位而被舍弃,进而导致精度问题的发生。除此之外,浮点数参与计算时,精度也有可能进一步丢失,因为浮点数计算有一个步骤叫对阶,以加法为例,要把小的指数域转化为大的指数域,也就是左移小指数浮点数的小数点,一旦小数点左移,必然会把52位有效域的最右边的位给挤出去,这个时候挤出去的部分也会发生“舍入”。这就又会发生一次精度丢失。所以0.1+0.2精度在两个数转为二进制过程中和相加过程中都已经丢失了精度,那么最后的结果肯定会出现偏差的问题。

那么,javascript的精度是多少呢?

IEEE 754规定,有效数字第一位默认总是1,不保存在64位浮点数之中。也就是说,有效数字总是1.xx...xx的形式,其中xx..xx的部分保存在64位浮点数之中,最长可能为52位。因此,JavaScript提供的有效数字最长为53个二进制位,这意味着,绝对值小于2的53次方的整数,即-(2^53-1)到2^53-1,都可以精确表示。当数值大于2的53次方后,整数运算的结果开始出现错误,故大于等于2的53次方的数值,都无法保持精度。

Math.pow(2, 53)        //9007199254740992Math.pow(2, 53) + 1    //9007199254740992Math.pow(2, 53) + 2    //9007199254740994Math.pow(2, 53) + 3    //9007199254740996Math.pow(2, 53) + 4    //9007199254740996

从上面示例可以看到,大于2的53次方以后,整数运算的结果开始出现错误。所以可以知道javascript十进制的精度是在15位及更小位数,大于这个值会越来越不准确。

延伸

ES6新增的一个极小变量,它就是Number.EPSILON,表示 1 与大于 1 的最小浮点数之间的差,对于 64 位浮点数来说,大于 1 的最小浮点数相当于二进制的1.00…001,小数点后面有连续 51 个零。这个值减去 1 之后,就等于 2 的 -52 次方。

Number.EPSILON===Math.pow(2, -52) // true

Number.EPSILON实际上是 JavaScript 能够表示的最小精度。误差如果小于这个值,就可以认为已经没有意义了,即不存在误差了。引入一个这么小的量的目的,在于为浮点数计算,设置一个误差范围。我们知道浮点数计算是不精确的。因此,Number.EPSILON的实质是一个可以接受的最小误差范围。

通过上面我们已经知道直接使用比较语句0.1 + 0.2 === 0.3会返回false,因为计算存在误差,所以这样子判断是得不到我们想要的效果的,这时我们就可以通过最小精度极值Number.EPSILON来进行判断。

function sequal(a,b){      return Math.abs(a-b)<Number.EPSILON;}var a=0.1+0.2, b=0.3;console.log(sequal(a,b));//这里就为true了

以下是我新开的公众号,专门推送学习前端的一些总结文章,有兴趣就关注一下呗。

菜鸟札记

发布了39 篇原创文章 · 获赞 30 · 访问量 4万+

猜你喜欢

转载自blog.csdn.net/HU_YEWEN/article/details/105070056
今日推荐