美文网首页
JS基础-数字-0.1+0.2!=0.3(四)

JS基础-数字-0.1+0.2!=0.3(四)

作者: 火锅伯南克 | 来源:发表于2021-03-30 00:09 被阅读0次
    IEEE754双精度存储格式

    (此图分成3部分,但是实际在内存中,它们是连续的一个整体,这里只是为了方便展示区分)。
    这个就是JS中的数字存储到内存中的真实模样,你可能纳闷,难道我存一个数字1也要这么长吗?是的,没错。

    一、先介绍下分开的这三部分

    假设浮点数为 ±m × 2e

    符号位: 存储正负,0 代表数值为正 ,1 代表数值为负。
    指数(阶码)位:存储指数部分,也就是 e。
    小数(尾数)位:存储数值部分,也就是 m。

    假设现在要存储 -1.1001 x 2110 (注意2的指数110,这个是2进制的数,以下所有涉及到2的指数部分都为2进制数,特此说明,不再赘述)。

    由于是负数,所以符号位就是 1,存储在符号位;
    110 存储在指数位;
    1.1001 存储在小数位;

    底数2是不用存的,因为IEEE754默认底数就是2.

    二、10进制转换IEEE754双精度步骤

    1. 先把10进制的数转化成2进制数

    注:我选择了三个具有代表性的数字,进行转化过程演示。

    纯小数:0.1
    0.0001100110011001100110011001100110011001100110011001100......(2)

    整数:300
    100101100(2)

    小数:300.1
    100101100.0001100110011001100110011001100110011001100110011001100......(2)

    2. 把2进制数规格化

    还记得上一章说的规格化吗?2进制的规则化非常符合10进制科学计数法的原则,那就是小数点前的值必须小于基数。

    10进制的基数就是10
    10进制 12345 科学计数法为:1.2345 x 104
    10进制 0.0012 科学计数法为:1.2 x 10-3

    2进制的基数就是2
    2进制 11001 规格化的值为:1.1001 x 2100
    2进制 0.0011 规格化的值为:1.1 x 2-11

    0.0001100110011001100110011001100110011001100110011001100......
    规格化后:1.100110011001100110011001100110011001100110011001100...... x 2-100

    100101100
    规格化后:1.001011 x 21000

    100101100.0001100110011001100110011001100110011001100110011001100......
    规格化后:1.001011000001100110011001100110011001100110011001100110011001100...... x 21000

    3. 填充尾数(小数)部分

    由于规格化的2进制数都是以 1. 开头,所以不需要使用bit去存储,截取1.后面的 52 + 16 = 68位。

    为什么要多存16位?
    因为10进制小数转换2进制的小数时,经常有除不尽的情况,比如10进制的0.1、0.2、0.3、0.4、0.6、0.7等等,都不能完全转化成2进制,如果不明白的话还是回到第一章温习下吧,《JS基础-数字-0.1+0.2!=0.3(一)》,但是小数位只能存储52位,超出的部分就要舍弃,但是又不能直接都丢掉,这样会损失很多精度,那么就需要暂时把这些值存储起来,以便后面的舍入单元根据具体情况来决定是进1,还是舍弃,怎么舍入马上就会讲到,至于为什么是16位,而不是10位,12位或者其他位数,是因为浮点数运算通用寄存器(用于存储中间值的的寄存器)一般为80位,去掉符号位的1位,指数位的11位,和小数位的52位,就剩下了16位。

    存储的就是带颜色的2进制数值部分。

    注意:以下所有图示中,只有带背景颜色的方块内数值(红 = 符号位,绿 = 小数位,黄 = 舍入位的第一位,灰 = 舍入位的后15位),才是真实存储在内存中的值,前面带的 1. 的目的,只不过是为了让大家清楚,这个值是隐性存在的。

    以下所有图示都是有工具计算生成,工具下载地址:https://github.com/fengjinlovewei/ieee754.git

    1.100110011001100110011001100110011001100110011001100......x 2-100

    1.001011 x 21000

    1.001011000001100110011001100110011001100110011001100110011001100...... x 21000

    4. 填充阶码(指数)部分

    阶码部分占用11位,和尾数不同的是,阶码部分不能直接把指数数值放进去,而是要转化成移码后再放入。

    初始状态的11位阶码

    IEEE754指数位的移码标准移码 还不太一样:

    标准移码 - 1 = IEEE754指数位的移码

    为什么要使用移码表示阶码?
    这个问题说起来有点麻烦,假想一下,如果现在要我们自己设计这个指数,该怎么设计?首先能确定的是,指数是11个位,那么能表示的值的个数就是 211 = 2048 个。并且需要指数既能存储正数,也能存储负数,还有0,并且正数和负数的数量要尽可能的一样,因为正数太多或者负数太多都不符合实际应用场景。

    接下来做个排除法:

    1. 假如使用原码来表示:
      原码可以把正负数平均分成2份,
      00000000000 - 01111111111 【+0 ~ +1023】
      10000000000 - 11111111111 【 -0 ~ -1023 】
      确实能满足我们的需求,但是有缺点:
      首先,他有两个0,我们只需要1个0就够了,浪费了一个空间,不过问题不大;
      其次,也是最主要的缺点,判断两个数值的大小时,很麻烦,你可能纳闷判断数值大小和指数有什么关系?因为小数部分是规格化的,都是以1. xxxxxx开头,两个数在符号位相同的情况下,肯定是指数大的那个数值更大,这是毫无疑问的,并且浮点数的比较运算也正是如此,比较过程是:先看两个数是不是特殊值,如果不是,判断符号位,如果符号位一致,就判断指数位大小,如果指数位也一致,那么就再比较小数位的大小。而实际上,大部分情况在比较指数这一步就能比较出大小了,所以指数部分的比较效率就尤为重要,如果采用原码比较,还要比较符号位,符号位一致,还要比较数值位,这就显得很啰嗦了。
    2. 假如使用补码来表示:
      补码可以把正负数分成大概的平均两份:
      00000000000 - 01111111111 【+0 ~ +1023】
      11111111111 - 10000000000 【 -1 ~ -1024 】
      补码中0只有一个表示方法,但是仍然有判断数值大小麻烦的缺点,因为补码不能直观判断两个值的大小,就拿-1和 +1023来说吧,-1的11位补码为 11111111111,+1023的11位补码为01111111111(正数的补码就是正数的原码),配置出能判断01111111111 比 11111111111大的逻辑电路,要比配置出能判断 11111111111 比 01111111111 大的逻辑电路要麻烦不少,对吧(联想一下在第二章所说的逻辑电路,应该会给你一些启示)。所以,站在逻辑电路配置的角度上,补码的判断大小比原码的判断大小还要麻烦许多。
    3. 假如使用移码来表示
      移码就是在补码的基础上符号位取反,相当于在补码的基础上再加一个 2n-1,n为总位数,那么在11位表示法中,就相当于加上210(1024),也就是加上2进制的10000000000
      移码可以把正负数大概平均分成2份:
      10000000000 - 11111111111 【0 ~ +1023】
      01111111111 - 00000000000 【 -1 ~ -1024 】
      首先,他也有唯一的数值0
      其次,判断指数大小也变得容易了,00000000000(-1024)~ 01111111111(-1)~ 10000000000(0) ~ 11111111111(1023),配置出判断这样的大小关系的逻辑电路就很简单了。
      很符合我们的需求,其实,移码就是为了浮点数阶码部分而生的。
      但在IEEE754中,阶码部分的编码和移码还是有点区别,11位移码是在补码的基础上加10000000000(1024),而 IEEE754中,是在补码的基础上加 01111111111(1023),比常规的移码少了1,所以就变成了如下所示,至于为什么这样做,我的猜想是,IEEE754想让正数比负数多一个吧,毕竟使用正数的情况比使用负数的情况要多,这块需要好好消化一下。

    机器零:在IEEE754中有两个0,一个是指数0(10000000000),一个是机器0(00000000000),这两个0代表的意义可不一样,指数0就代表指数为0的情况,而机器0用来代表浮点数中真实的数值0,说起来有点绕,因为在IEEE754浮点数中,规格化得的数都是以1. xxxxxx开头,而1.又是隐藏属性,哪怕小数位都为0,也是代表的1.00000....,所以无论指数为多少,都没有办法表示真实数值0,因此就规定,当指数全为0、并且小数位也全为0时,强行代表数值0,这也是之后要提到的4个特殊值的其中之一(指数全为0,和指数全为1都是表示特殊的值)。

    可以变相的认为,阶码部分初始化时就有值,这个值就是10进制的 1023,2进制的01111111111,然后把求出的指数与它相加就可以了,这样比较直观。

    可以验证一下:
    假设指数为 -100 ,把他转化成11位移码过程就是
    取其绝对值的原码:00000000100
    取补码:各位取反,然后 +1,11111111100
    取移码:在补码基础上,符号位取反,01111111100
    IEEE754指数位的移码标准移码 少 1,所以要减去1,得:01111111011

    再用简单方法求结果试试看:
    01111111111 - 100 = 01111111011
    结果是一样的对吧。
    简单地方法是我们在学习中,为了快速求值的一种手段,但是一定要清楚,在计算机求值时,是使用第一种复杂方法的。

    可以认为初始化就有值

    1.100110011001100110011001100110011001100110011001100...... x 2^ -100
    相加:01111111111 + (-100) = 01111111011
    存储到指数位

    1.001011 x 2^1000
    相加:01111111111 + 1000 = 10000000111
    存储到指数位

    1.001011000001100110011001100110011001100110011001100110011001100...... x 2^1000
    相加:01111111111 + 1000 = 10000000111
    存储到指数位

    5. 组合:符号位 + 指数位 + 小数位

    阶码(指数位)和尾数(小数位)部分已经拿到,只要将他们组装到一起就完事了,别忘了符号位也要加上。


    6. 舍入处理

    这一步极其关键。
    绿色的是位数的最后一位,黄色是舍入参考位的第一位,灰色为舍入参考位的后几位。

    四舍和六入很好理解,但是五取偶有点玄学,在这解释一下。
    比如 1.0001 1000,要取四位小数,对比下舍和入:

    舍:得,1.0001
    1.0001 1000 - 1.0001,值为 0.0000 1000
    入:得,1.0010
    1.0001 1000 - 1.0010,值为 -0.0000 1000

    无论是舍还是入,距离原数值的距离都是 0.0000 1000 ,那么按照最近舍入原则,舍和入都行,但是,为了规范的统一性就必须要选其一,在大家争论不休时,有一个人站了出来说:取偶比取奇好;也就是刚才说过的,如果最后一个尾数是奇数,那么就进1,让这个尾数变成偶数,如果尾数本身就是0,说明这就是一个偶数,不需要变化,则舍弃。这个人就是Knuth(高德纳)。



    Knuth著作《The Art of Computer Programming》(计算机编程艺术)第二卷,有这个问题的回答,不怕秃顶的就去看吧。

    纯小数0.1舍入后的结果

    由于符合舍入规则中的六入,所以进1

    整数300舍入后的结果

    由于符合舍入规则中的四舍,所以直接舍弃

    小数300.1舍入后的结果

    由于符合舍入规则中的六入,所以进1

    五取偶

    尝试运行如下代码

    const a = 9007199254740993;
    const b = 9007199254740995;
    console.log(a) // 9007199254740992
    console.log(b) // 9007199254740996
    

    9007199254740993 = 253 + 1
    9007199254740995 = 253 + 3
    借用工具查看一下他们的奇怪现象如何产生的。

    数值:9007199254740993

    由于符合舍入规则五取偶中的 “如果X的值为0,则舍弃”,所以舍弃

    数值:9007199254740995

    由于符合舍入规则五取偶中的 “如果X的值为1,则进1”,所以进1

    由于数值90071992547409939007199254740995 都命中了“五取偶”,所以在存入内存时,都已经和原数值不相等了,这也是为什么JS(IEEE754双精度)中,超出±253 上下限的整数值不能保证精度的原因,因为超出界限的值必定会经过舍入规则的过滤。

    7. 四种特殊值(也叫非规格化的值)

    当指数全为0,或者全为1时,代表特殊值,特殊值的隐性特性就是
    原先的隐藏头 1. 变成了 0. ,这一点要格外注意。

    1.如果阶码全为1,尾数位是全0,代表数值:Infinity;根据符号位可得 ± Infinity。

    实际上,± Infinity两个特殊值就是代表数值向上溢出了,在计算机中,存储的数值一定要在规定的位数范围内,如果溢出了(就是超出了),就要有特殊的处理,否则,程序就会中断或者出错,在IEEE754中,向上的溢出全部终结在 ± Infinity,向下的溢出,全部终结在 ± 0。这也是一种容错的处理。

    2.如果阶码全为1,尾数不全为零,代表数值:NaN;不区分符号位。

    根据NaN的规则,实际上应该有253 - 2 个编码代表NaN,这个值怎么来的?因为当指数全为1时,后面的52个小数位,相当于有 252 种编码,NaN是不区分符号位的,所以还要乘上一个2,就是 253 种编码,但是这其中还包含了± Infinity,所以要减去2,得出 253 - 2 。这个多的NaN的表示方法,有什么用呢?因为在处理数字时,多多少少会出现异常情况,在低层逻辑判断中,这些异常还要需要区分,以便做后续的处理,但是在应用层不需要关心这些,所以表现给我们的都是NaN。

    在ECMA规范中,“8.5 Number 类型” 有一段话:
    精确地,数值类型拥有 18437736874454810627(即,264 - 253 + 3)个值。
    这个值实际上就是把所有数字编码 - NaN不需要的那一部分,IEEE754 - 64位浮点数总共有 264 个编码,根据上面得出的NaN的个数,应该是264 - 253 + 2,可是少了一个值,仔细想下,253 - 2 中都表示NaN,但其中还包含一个NaN,所以是264 - 253 + 3。

    0/0 为什么 = NaN ?
    假设 0/0 = x,那么 0/x = 0,0除以任何数都为0,IEE754决定,此时的商为NaN最为合适。听着挺奇怪,但是细想想好像也是这么回事。

    3.如果阶码全为0,尾数位是全0,代表数值:0;根据符号位可得 ± 0。

    4.如果阶码全为0,尾数不全为零,代表数值:2-1022 x 0.尾数值;根据符号位可得 ± 值。

    当指数位为全0,小数位都为0,代表了0,上面说了,但是小数位不为0时,总得干点什么吧,不然不就浪费了吗,所以就表示了比 2-1022 更小的小数值。

    当前这段编码表示的数值为:2-1022 x 2-52 = 2-1074
    这个值也是JS中能表示的绝对值最小的值。
    2-1074 = 5e-324
    Number.MIN_VALUE = 5e-324

    8. 极限值

    js能精准计算整数的范围:±(253 - 1)之间

    + (2^53 - 1) = 9007199254740991

    - (2^53 - 1) = - 9007199254740991

    js能表示的绝对值最大的值为:1.7976931348623157e+308


    js能表示的绝对值最小的值为:5e-324


    这回你该知道,JS中的那些界限值是怎么来的了吧。

    9. 数值的精度问题

    先来看0.2的转换图


    0.2

    IEEE754 64位 浮点数转化10进制公式为

    S代表符号位的值,E代表阶码部分的值

    当把0.2舍入处理后,得到的64位浮点数编码已经存储到了内存中,相当于JS代码

    const n = 0.2
    

    后的情况。
    但是请注意,此时的值,由于舍入向上进了1,所以其值必定比0.2要大。
    0.20000000000000001110223510260473
    但当取出这个值时,比如使用dom.html(n) 或者 console.log(n) 显示值时,还是0.2,这是为什么呢?
    当取值时,应用程序会调用一些软件库对值进行判断,是否命中终结条件,命中就将终结后的值返回,没有命中就将原值返回,这个终结条件大概是在机器精度(符号ε表示)值附近,这也是猜测,因为具体算法我也不知道。

    JS中有个属性与IEEE754 64位浮点数ε 对应, Number.EPSILON,


    这个值也是IEEE754 64位浮点数中1 与 大于1的最小浮点数之差。

    从图中可知,这两个数的差 和 Number.EPSILON 的值相等。并且,可以在“四舍 六入 五取偶”的舍入规则中得知,相对舍入误差不可能大于机器ε的一半,这块有点烧脑,需要结合“四舍 六入 五取偶”好好理解。

    如果 | x - y | < Number.EPSILON 为 true,那么就可以断定 x === y 。

    (0.1 + 0.2) - 0.3 < Number.EPSILON  // true
    

    总之就知道,返回给你的值,都是经过处理后的就行了。

    10. 总结

    本章介绍了IEEE754双精度浮点数的存储规则,下一章,将进行 0.1 + 0.2 在IEEE754规范中的计算部分。

    相关文章

      网友评论

          本文标题:JS基础-数字-0.1+0.2!=0.3(四)

          本文链接:https://www.haomeiwen.com/subject/utzphltx.html