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

开场

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

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),其值用多项式为:
a10^3 + b10^2 + c10^1+d100+e*10(-1)+f10^(-1)+g10^(-3)。
而一个二进制的浮点数,我们也将其表示成:abcd.efg (其中a~g值得范围为0或1),其值表示为:
a2^3 + b2^2 + c2^1+d20+e*2(-1)+f2^(-1)+g2^(-3)。
十进制科学计数法可表示为:(-1)^s * f* 10^e。s表示符号。f为尾数,范围为1<=f<10。e为幂,也叫指数。
二进制的科学计数法,也是IEEE的浮点数标准格式可表示为:(-1)^s * f* 2^e。f的范围为1<=m<2。
再者来解析上图的符号位(sign)、指数位(exponent)、小数位(fraction)的含义分别是什么:
1、当表示的浮点数是正数时 符号位 为 0,反之则为1。
2、指数有正有负,指数位长度为11比特,所以能表示的数字范围为0~2047。为了能表示负值得指数,我们在这里引入二进制偏移量(offset binary)的概念。在这里IEEE定义偏移量为1023,我们计算出的指数位数值减去1023则为我们真正想要的值。假设我们用11位比特算出的值为e,则我们要带入计算的结果为2^(e-1023)。
3、尾数范围为1<=f<2, 就是说小数点前面总有一个1,为了节省空间,IEEE规定将此处的1省略,直接将小数点后面的部分放入到小数部分(这也是为什么这部分叫“小数部分(fractiong)”而不叫“尾数部分”的原因)。

分析

如何表示小数呢?

在这里我们约定,数字前加%表示二进制数字。下面就是一组表示非负浮点数的例图。JS用有理数表示有效数字, 方式为:1.f 。其中 f 为52位比特小数位(fraction) 。忽略掉正负号,有效数字乘以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 × 2^−1022
这种表示方式称为非规格化(denormalized)。我们之前提到普通情况下的的表示方式称为规格化(normalized)
最小的规格化的正数为:%1.0 × 2^−1022
最大的非规格化的正数为: %0.1... × 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.1 + 1 - 1
    0.10000000000000009

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

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

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

%0.0001(100110011001...010) // 由于末尾后面是1,0舍1入,所以...后面为010而非001. ()中一共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中 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

出现上面情况的原因我会分成几个小的问题一一解答,当你明白这些小问题,那么上面的问题肯定也就明白了。你只要记住限制其最高精确度的是小数位(fraction)部分,但是指数位(exponent)仍然有很大的上升空间。
为啥是53位?
因为我们有53位比特(bits)可以用来表示数字的大小(不包括符号),只是表示小数部分为52比特,整数部分永远是1(二进制科学计数法)。
为啥最大整数不是(2^53) - 1 ?
通常情况下,x 比特能表示的整数范围为 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

大于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)。标准的双浮点精度值为 2 ^-52。

   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
浮点数在计算机中的储存
浮点数在计算机中的表示

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容