JS 之 浮点数语言层面的终极探究

1,197 阅读8分钟

开场

春天来了,大家都充满活力,干劲十足,又蹦又跳。我也在网上看了一套题,没想到刚开始看就花了眼:

var END = Math.pow(2, 53);
var START = END - 100;
var count = 0;
for (var i = START; i <= END; i++) {
    count++;
}
console.log(count);

只解题也不难,这是关于JS精度系数的问题,只需要记住2的53次方是能正确计算且不失精度的最大整数,即可。但是我工作两年了,自我觉得应该要迈入稍微高级的那种程序员行列了,即使本人一点也不高级也应该用高级程序员的准则来要求自己了,于是便有了这篇探究总结的文章,来窥一窥浮点数语言层面的实现原理。 读完本篇文章你不仅能解开上题且能真正明白为嘛下面的输出结果是这样的。

    > 9007199254740992 + 1
    9007199254740992

    > 9007199254740992 + 2
    9007199254740994

    > 0.1 + 1 - 1
    0.10000000000000009

开整

我们知道,JS中的所有数字均用浮点数值表示,其采用IEEE 754标准定义64位浮点格式表示数字。其表示格式如下图,小数部分(fraction)占用0~51位比特(52 bits), 指数部分(exponent)占用52~62位比特(11 bits),符号位(sign)占用63位比特(1 bit).

首先我们看一看十进制和二进制如何表示数字:

一个十进制的浮点数,例如:abcd.efg (其中a ~ g值得范围为0 ~ 9),其值用多项式为:

a103+b102+c101+d100+e101+f101+g103a*10^3 + b*10^2 + c*10^1+d*10^0+e*10^{-1}+f*10^{-1}+g*10^{-3}

而一个二进制的浮点数,我们也将其表示成:abcd.efg (其中a~g值得范围为0或1),其值表示为:

a23+b22+c21+d20+e21+f21+g23a*2^3 + b*2^2 + c*2^1+d*2^0+e*2^{-1}+f*2^{-1}+g*2^{-3}

十进制科学计数法可表示为:(1)sf10e(-1)^s * f * 10^e。s表示符号,f为尾数,范围为1<=f<10。e为幂,也叫指数。

二进制的科学计数法,也是IEEE的浮点数标准格式可表示为:(1)sf2e(-1)^s * f * 2^e

f的范围为1<=f<2。

再者来解析上图的符号位(sign)、指数位(exponent)、小数位(fraction)的含义分别是什么:

  1. 当表示的浮点数是正数时 符号位 为 0,反之则为1。
  2. 指数有正有负,指数位长度为11比特,所以能表示的数字范围为0~2047。为了能表示负值得指数,我们在这里引入二进制偏移量(offset binary)的概念。在这里IEEE定义偏移量为1023,我们计算出的指数位数值减去1023则为我们真正想要的值。假设我们用11位比特算出的值为e,则我们要带入计算的结果为2e10232^{e-1023}
  3. 尾数范围为1<=f<2, 就是说小数点前面总有一个1,为了节省空间,IEEE规定将此处的1省略,直接将小数点后面的部分放入到小数部分(这也是为什么这部分叫“小数部分(fraction)”而不叫“尾数部分”的原因)。

分析

如何表示小数呢?

在这里我们约定,数字前加%表示二进制数字。下面就是一组表示非负浮点数的例图。JS用有理数表示有效数字, 方式为:1.f 。其中 f 为52位比特小数位(fraction) 。忽略掉正负号,有效数字乘以2p(p=e1023)2^p(p = e - 1023)就是我们最终的二进制数字结果,JS就是用这种方式来表示小数的。

如何表示整数呢?

JS的有效数字有53个,其中一个在小数点的前面固定不变,值为1,其余的在小数点的后面有52个。

当p(p = e - 1023) = 52时, 我们会用53个比特表示数字。由于最高位的数值总是1,这也就表明我们不能总是按着我们的意愿来操纵这些比特。不过IEEE通过两个步骤巧妙的把这个问题解决了:

步骤1: 如图,我们做一个推理,如果53位比特数字最高位为0,次位为1,则我们设 p = 51。若这时的最低位比特(也就是小数点后面)为0,则我们认为该数字为整数。如此反复推导,直到p = 0, f = 0, 这时我们得到的编码整数为1。(通过步骤1,我们可以用53位比特精确的表示数字)

步骤2: 通过步骤1虽然能精确表示部分数字,但是却不能表示0,在这一步我们再定义0的表示方式: 当 p= - 1023 ( 即 e = 0), f = 0 时,该值为0 (后面还会有讲解)。

image.png 综上,我们有 53 比特的精确一一对应的二进制数字来表示整数。

特别的指数,特别的约定

根据IEEE 754 规定, 在指数位上我们有两个值有特殊的定义,分别为 e = 0 和 e = 2047。 特殊约定:

  1. e = 0 时, 若 f = 0, 则 该数值为 0 , 由于 有符号位(sign)的存在,所以我们在JS中有 -0+0之分。

  2. e = 0, f > 0,则该值用来表示一个非常接近于0的数字,计值公式为:

(1)s%0.f×21022(-1)^s * \%0.f × 2^{−1022}

这种表示方式称为非规格化(denormalized)。我们之前提到普通情况下的的表示方式称为规格化(normalized)

最小的规格化的正数为:%1.000... × 2^−1022

最大的非规格化的正数为: %0.111... × 2^−1022 因此,规格化与非规格化的数字就能实现无缝对接。

  1. e = 2047, f = 0 时, 该数值在JS中被表示为无穷(∞/infinity)
  2. e = 2047, f > 0 时, 该数值在JS中被表示为 NaN。 总结一下:

十进制小数

在JS中并不是所有的十进制小数都能被精确的表示出来,看一看下面的例子:

    > 0.1 + 0.2
    0.30000000000000004

那么计算过程是怎样呢? 我们首先来看0.1和0.2在二进制浮点数中的表示方式

0.5 用二进制浮点数的形式储存为 %0.1 
但是 0.1 = 1/10 所以它表示为 1/16 + (1/10-1/16) = 1/16 + 0.0375
0.0375 = 1/32 + (0.0375-1/32) = 1/32 + 00625 ... etc
所以在二进制浮点数中0.1 表示为 %0.00011... 
0.1 -> %0.0001100110011001...(无限)
0.2 -> %0.0011001100110011...(无限)

IEEE 754 标准的 64 位双精度浮点数的小数部分最多支持 53 位二进制位,所以两者相加之后得到二进制为:

%0.0100110011001100110011001100110011001100110011001100  //0.30000000000000004

让我们再看一个例子:

    > 0.1 + 1 - 1
    0.10000000000000009

这个是为什么呢? 首先我们知道

0.1 -> %0.0001100110011001...(无限)

根据其精度所以最终在内存中保存的样子应该为

%0.0001(100110011001...010)
// 由于末尾后面是1,0舍1入,所以...后面为010而非001. 
//上面()中的数字是小数位(fraction)
//()中一共52位()

加1之后可以表示为

%1.(0001100110011...010) 
// 由于末尾后面是1,0舍1入,所以...后面为010而非001.
//()中[即是小数位]一共52位
// 注意()的变化

再减去1之后可以表示为

减去1后的储存二进制:%0.0001(100110011001...0100000) // ()中一共52位

   0.1原始储存大小:%0.0001(100110011001...0011010) 

注意比较最后末尾的7位数字所以值稍微大于实际值。 你也可以按照上面的方式推算0.2+1-1的值,试一试。

最大安全整数

什么是最大安全整数? 在这里我们给最大安全整数(x)下一个定义:它指在 0 ≤ n ≤ x 范围内,每个整数 n 都能被唯一表示出来,大于x时就不能保证该特性。在JS中 25312^{53} -1就符合的要求,所有小于等于它的数都能被表示

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

但是大于它的数字就不一定能被表示出来了:

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

出现上面情况的原因我会分成几个小的问题一一解答,当你明白这些小问题,那么上面的问题肯定也就明白了。

你只要记住限制其最高精确度的是小数位(fraction)部分,但是指数位(exponent)仍然有很大的上升空间。

为啥是53位?

因为我们有53位比特(bits)可以用来表示数字的大小(不包括符号),只是表示小数部分为52比特,整数部分永远是1(二进制科学计数法)。

为啥最大安全整数是(2^53) - 1而不是 2^53 ?

通常情况下,x 比特能表示的整数范围为 0(2x1)0 ~ (2^x - 1)。比如说一个字节(byte)有8 比特(bits)那么一个字节所能表示最大的整数为 255。在 JS中,最大的小数部分的确是(2^53) - 1,多亏有指数位的帮忙,2^53也是可以表示的。 当 小数位 f =0 , 指数位 p = 53:

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

但是由于2532^{53}能表示的数字不是唯一的,所以不认为其为最大安全整数。

后来补充: ES6已经规定出了安全整数 Number.MAX_SAFE_INTEGER = 25312^{53}-1之前写文章的时候我定义的最大整数为2532^{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 ^53 却不能表示 1+2^53呢?我们来看下面的列表: 请看当p = 53的这一行,由于JS能表示的小数位只有52比特,所以第0位比特只能用0来填充。所以,在2^53 ≤ x < 2^54的范围内,只有偶数才能被正确表示出来。 同理当p = 54时, 在 2^54 ≤ x < 2^55 内增长是按照4的倍数来的。

    > 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

然后一直持续下去,直到 p = 1023时都可以以此类推(p=1024有特色含义,详情请看上面 特别的指数,特别的约定模块)。

如何避免浮点精度错误

我们应当避免避免直接进行小数之间的比较。因为总的来说就是没有好办法来解决这些误差。不过我们可以通过设置一个误差上限来确定这个值是不是我们能接受的。这个误差上限就是我们所说的机器精度(machine epsilon)。标准的双浮点精度值为 2522 ^{-52}, ES6有Number.EPSILON来表示机器精度。

   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

我们还可以通过下面有几个不错的类库可以处理精度问题。

Math JS

Sinful JS

BigDecimal

JS原生提供了两种处理精度的方法Number.prototype.toPrecision()Number.prototype.toFixed() 只是这两种方法都是用来展示值的,类型都为String。比如:

function foo(x, y) {
    return x.toPrecision() + y.toPrecision()
}

> foo(0.1, 0.2)
"0.10.2"

所以用的时候一定要小心,最好不要用JS处理浮点数,如果一定要用JS处理数字问题,最好不要自己写,好的类库往往是比较好的选择。

总结

我们走了一大圈,终于完完全全的明白了JS在内部是如何表示数字的。我们得出的深刻结论是:如果你用JS 计算带有小数的数字,你将无法确定你得到的结果

P.S.: 世界是属于理解它的人,很高兴,我们对编程的世界又有了更深一层的理解。如果文章有不足或理解不到位的地方,还请不吝赐教,如果有不明白的地方也可以留言,我们共同讨论 (^-^)

附录

我看的原题集在这里

What Every JavaScript Developer Should Know About Floating Points

What Every Computer Scientist Should Know About Floating-Point Arithmetic

How numbers are encoded in JavaScript

浮点数在计算机中的储存

浮点数在计算机中的表示