2

一 前言

这篇文章主要解决以下三个问题:

问题1:浮点数计算精确度的问题
     0.1 + 0.2; //0.30000000000000004
     0.1 + 0.2 === 0.3; // false
     0.3 / 0.1; // 2.9999999999999996
     (0.3 - 0.2) === (0.2 - 0.1); // false
     
问题2: 浮点数精确表示的范围问题以及超出精确范围后哪些能精确表示的问题
      Math.pow(2, 53); // 9007199254740992
      Math.pow(2, 53) + 1; // 9007199254740992
      Math.pow(2, 53) + 2; // 9007199254740994
      
问题3:浮点数可以表示的范围的问题
     Math.pow(2, 1024) // Infinity
     Math.pow(2, -1075); // 0

二 正文

JavaScript 内部,所有数字都是以64位浮点数形式储存,即使整数也是如此。
所以,1与1.0是相同的,是同一个数。

1 === 1.0 // true

这就是说,JavaScript 语言的底层根本没有整数,所有数字都是小数(64位浮点数)。容易造成混淆的是,某些运算只有整数才能完成,此时 JavaScript 会自动把64位浮点数,转成32位整数,然后再进行运算。

由于浮点数不是精确的值,所以涉及小数的比较和运算要特别小心。

0.1 + 0.2 === 0.3
// false

0.3 / 0.1
// 2.9999999999999996

(0.3 - 0.2) === (0.2 - 0.1)
// false

// 建议的方式:变成整数处理方式
0.1*10+0.2*10===0.3*10 // true

1.Javascript numbers

根据国际标准IEEE 754 ,JavaScript 浮点数的64个二进制位,从最左边开始,是这样组成的:

图片描述

第1位:符号位,0表示正数,1表示负数
第2位到第12位(共11位):指数部分
第13位到第64位(共52位):小数部分(即有效数字)

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

(-1)^符号位 1.xx...xx 2^指数部分

2.The fraction

IEEE 754 规定,有效数字的第一位默认总是1,不保存在64位浮点数之中。
也就是说,有效数字这时总是1.xx...xx的形式,其中xx..xx的部分保存在64位浮点数之中,最长可能为52位。因此,JavaScript 提供的有效数字最长为53个二进制位:

关于有效数字的第一位默认总是1,下面有提及,这里先了解一下:

正规化normalized:有效数位的最高位始终是1
非正规化denormalized:
当数值非常趋近0的时候(指数位全是0),逐渐失去精确度,这时候可以用有效数位的最高位是0. 例如2e-1022.

图片描述

其中:%表示是二进制表示方式,f表示有效数字位的值,p表示指数位的值

2-1 Representing integers

编码为整数提供了多少位?
有效数字有53个数字,1个在小数点之前,52个在小数点之后。当p = 52时,我们有一个53位的自然数。唯一的问题是最高位始终为1。也就是说,我们没有全部位可供我们随意使用。
分两步去除这个限制:
首先,如果需要最高位为0的53位数字,0后面是默认的1,则设置p = 51。这样有效数位的最低位将成为小数点之后的第一个小数数字,整数部分为0。以此类推,直到你在p = 0和f = 0,编码数字1。

例如 1.xxxx...11 * 2e52 = 1xxxx...11 -> 1.xxxx...11 * 2e51 = 1xxxx...1.1 = 01xxxx...1

图片描述

其次,对于全部53位,我们仍然需要表示零。 如何做到这一点在下一节中解释。 请注意,由于符号是单独存储的,因此整数的幅度(绝对值)为53位。

3.The exponent

3-1 Common exponent

根据标准,64位浮点数的指数部分的长度是11个二进制位,意味着指数部分的最大值是2047(2的11次方减1)。也就是说,64位浮点数的指数部分的值最大为2047。
因为指数位两个指数值是保留的:最低的一个0和最高的一个2047(下文会有讲解),为了支持负数指数部分,进行二进制数偏移,1023 ---> 0,0以下的全为负数。因此JavaScript 指数部分能够表示的数值范围为(-1023,1024),指数部分超出这个范围的数无法表示。

图片描述

(负数表示:取补码 = 反码 +1;符号位不变)

如果一个数大于等于2的1024次方,那么就会发生“正向溢出”,即 JavaScript 无法表示这么大的数,这时就会返回Infinity。

Math.pow(2, 1024) // Infinity

如果一个数小于等于2的-1075次方(指数部分最小值-1023,再加上小数部分的52位),那么就会发生为“负向溢出”,即 JavaScript 无法表示这么小的数,这时会直接返回0。

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

下面是一个实际的例子。

var x = 0.5;

for(var i = 0; i < 25; i++) {
  x = x * x;
}

x // 0

上面代码中,对0.5连续做25次平方,由于最后结果太接近0,超出了可表示的范围,JavaScript 就直接将其转为0。

JavaScript 提供Number对象的MAX_VALUE和MIN_VALUE属性,返回可以表示的具体的最大值和最小值。

Number.MAX_VALUE // 1.7976931348623157e+308
Number.MIN_VALUE // 5e-324

3-2 Special exponent
指数位两个指数值是保留的:

最低的一个(0)和最高的一个(2047)

2047的指数用于无穷大和NaN(非数字)值。
IEEE 754标准有许多NaN值,但JavaScript都将它们表示为单个值NaN。

指数0用于两种情况:

(1)如果有效数位值是0,那么整数就是0。由于符号是分开存储的,我们同时具有-0和+0。
(2)0的指数也用于表示非常小的数字(接近零)。 然后该有效数位的值必须是非零的,如果是正数,则通过计算该数字:

%0.f × 2^(e − 1022)

这种表示被称为非规范化。 先前讨论的表示被称为标准化。 可以以规范化方式表示的最小的正数(非零)数是:

%1.0 × 2^(e - 1022)

最大的非正规化数字是:

%0.1 × 2^(e - 1022)

因此,在标准化和非标准化数字之间可以完美切换。

特数值列表:

图片描述

+0:sign=0,e=0,f=0
-0:singn=1,e=0,f=0

Infinity:sign=0,e=2047(全是1)
-Infinity:sign=1,e=2047(全是1)

NaN:e=2047(全是1),f>0(f不全是0)

3-3 Summary:exponents

图片描述

由于p = e - 1023, 指数部分的范围是:

−1023 < p < 1024

4.Decimal fraction

并非所有小数都可以用JavaScript精确表示,如下所示:
    

0.1 + 0.2 // 0.30000000000000004

小数部分0.1和0.2都不能精确地表示为二进制浮点数。但是,与实际值的偏差通常太小而不能显示。
加法导致偏差变得可见。看一个例子:

0.1 + 1 - 1 // 0.10000000000000009

    
表示十进制分数1/10来说是个挑战,困难的部分是分母10。其分母的因子分解是2×5,指数只允许你用2的幂除整数,所以没有办法得到因子5。相反,将二进制小数表示为小数部分总是可能的。

4-1 Comparing decimal fractions
由于浮点数计算的结果不能保证精确,因此,当你使用具有小数值的浮点数数输入时,不应直接比较它们。 相反,考虑舍入误差的上限。 这样的上界称为机器epsilon。 双精度的标准epsilon值是2^(-53)。

var epsEqu = function () { // IIFE, keeps EPSILON private
    var EPSILON = Math.pow(2, -53);
    return function epsEqu(x, y) {
        return Math.abs(x - y) < EPSILON;
    };
}();

上面的运行结果:

0.1 + 0.2 === 0.3 //false
epsEqu(0.1+0.2, 0.3)//true

5.The maximum integer

有效数含52位,但是有效数位前总有一个默认1,因此除零之外的所有53位数都可以表示,并且它有一个特殊值来表示零(连同零的一部分)。
所以浮点数能精确表示的范围是: [2^(-53),2^53]

> Math.pow(2, 53)
9007199254740992
> Math.pow(2, 53) - 1
9007199254740991
> Math.pow(2, 53) - 2
9007199254740990

当超出这个范围时,不能保证准确显示:


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

你可能疑惑,最高的整数不应该是是2^53-1的嘛?通常x位:表示最低数字是0,最高数字是2^X-1。例如,最高的8位数字是255.在JavaScript中,最高分数确实用于数字2^53-1,但可以准确表示2^53,这要归功于指数的帮助--2^53是一个小数部分f = 0,指数p = 53(转换后):

%1.f × 2p = %1.0 × 2^53 = 2^53

为什么有些大于2^53的数能被精确表示呢?看下面例子:


 > Math.pow(2, 53)
9007199254740992
> Math.pow(2, 53) + 1  // not OK
9007199254740992
> Math.pow(2, 53) + 2  // OK
9007199254740994

> Math.pow(2, 53) * 2  // OK
18014398509481984

2^53×2能正常表示是因为指数可以使用。 每乘以2只是将指数递增1并且不影响有效数位。 因此,就最大有效数位值而言,乘以2的幂不是问题。 为了明白为什么我们可以加2到2^53,但不是1,我们扩展了前面的表,其中53和54的附加位以及p = 53和p = 54的行:
图片描述

查看行(p = 53),很明显JavaScript数字可以将位53设置为1.但是由于小数f只有52位,所以位0必须为零。 因此,只有偶数x可以在2^53≤x<2^54的范围内表示。在行(p = 54)中,该间距增加到4的倍数,在2^54≤x<2^55的范围内:

 > Math.pow(2, 54)
18014398509481984
> Math.pow(2, 54) + 1
18014398509481984
> Math.pow(2, 54) + 2
18014398509481984
> Math.pow(2, 54) + 3
18014398509481988
> Math.pow(2, 54) + 4
18014398509481988

...

6.IEEE 754 exceptions

IEEE 754标准描述了五个特殊情况,其中一个不能计算精确的值:
(1)Invalid(不合法):执行了不合法操作。例如,计算负数的平方根。返回NaN。

Math.sqrt(-1) // 为NaN

(2)Division by zero(除以零):返回正负无穷。

3/0; // 无穷
-5/0; //-无穷

(3)Overflow(上溢):结果太大而无法表示。这意味着指数太高(p≥1024)。根据符号,有正面和负面溢出。返回正负无穷。

Math.pow(2,2048); // Infinity
-Math.pow(2,2048); // -Infinity

(4)Underflow(下溢):结果太接近零来表示。这意味着指数太低(p≤-1023)。返回非规格化的值或零。

Math.pow(2,-2048); // 0

(5)Inexact(不精确):操作产生了不准确的结果 - 要保留的分数有太多有效数字。返回一个舍入结果。

0.1 + 0.2; // 0.30000000000000004
9007199254740992 + 1; //9007199254740992

#3和#4是关于指数,#5是关于有效数。 #3和#5之间的区别非常微妙:在第五个例子中,我们超过了有效数的上限(这将是整数计算中的溢出)。但只有超过指数的上限才称为IEEE 754中的溢出。

三 后记

让我们回顾一下前言中的三个问题:

问题一:为什么0.1+0.2===0.3 //false
经过对正文部分的阅读,你已经知道浮点精确度的问题,对于超出浮点精确度的部分计算机是无法管理的。
下面让我们回到计算机对十进制数用二进制数表示方法上:
对于二进制小数,小数点右边能表达的值是 1/2, 1/4, 1/8, 1/16, 1/32, 1/64, 1/128 ... 1/(2^n)
现在问题来了, 计算机只能用这些个 1/(2^n) 之和来表达十进制的小数。
我们来试一试如何表达十进制的 0.2 吧。

0.01  = 1/4  = 0.25  ,太大
0.001 =1/8 = 0.125 , 又太小
0.0011   = 1/8 + 1/16 = 0.1875 , 逼近0.2了
0.00111 = 1/8 + 1/16 + 1/32 = 0.21875  , 又大了
0.001101 = 1/8+ 1/16 + 1/64 = 0.203125  还是大
0.0011001 = 1/8 + 1/16 + 1/128 =  0.1953125  这结果不错
0.00110011 = 1/8+1/16+1/128+1/256 = 0.19921875

已经很逼近了,计算机不可能提供无限的空间让程序去存储这些二进制小数的, 就这样吧。 用二进制小数没法精确表达10进制小数
对于不能精确表示的小数部分的计算,无法保证一致的正确结果(小数结果非常接近十进制正确值了,但是会取相对靠近这个正确值的能用二进制表示的那个十进制数)。

那么如何解决算数比较的问题?

    var epsEqu = function () { // IIFE, keeps EPSILON private
    var EPSILON = Math.pow(2, -53);
    return function epsEqu(x, y) {
            return Math.abs(x - y) < EPSILON
        };
    }();

问题二: 浮点数精确表示的范围问题以及超出精确范围后哪些能精确表示的问题
由正文部分的内容,我们知道了总共有53(52+1)位有效数位,所以

浮点数精确表示的范围:[-2e53,2e53]
超出精确范围后哪些能精确表示:
+-*/ 计算有效数位超出范围的位上含有1的则不能精确表示计算结果(1会被舍弃,影响结果),超出位上的值全是0的可以准确的表示计算结果(指数位数值变化后舍弃有效数字位最后一位0,对结果无影响)。

一般来说如果是53位,那么最大值应该是2e53-1,但是如上面解释的那样,2e53是可以被精确表示的,所以浮点数精确表示的范围:[-2e53,2e53]。

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

Math.pow(2, 53) * 2  // OK
18014398509481984


参考链接:
浮点数计算不精确
阮一峰--数值
Javascript and more


specialCoder
2.2k 声望168 粉丝

前端 设计 摄影 文学