什么, 0.3 - 0.2 ≠ 0.1 ?

标签: 公众号文章


惨痛的历史教训

记得还在上学那会儿,给我们上《运筹学》的老师留了一个课程实验,就是让我们每个人都去实现一个书中所讲的算法。由于当时完全没有什么分层、模块化的啥概念,写代码就是一股脑往里塞逻辑,写出来的代码用一坨形容完全不为过。

当我实现完算法之后,开始弄几个值作为输入进行测试,发现有的值可以测试成功,有的却不行,怎么办呢?调试呗,面对着那么一大坨乱糟糟的代码,调试简直是灾难,好像花了整整一下午加晚上的时间去调试,调试的我两眼冒金星,脖子转不动,真是:写代码一时爽,调试火葬场

最后我竟然发现了一个神奇的现象(也是后来一直铭记的教训):

0.2 - 0.1 == 0.1 这个表达式的结果为true,但是0.3 - 0.2 == 0.1的这个表达式的结果竟然为false,我滴个乖乖,真不敢相信自己的眼睛。后来查了查书说是浮点数表示的数字并不是精确的,到底怎么个不精确法,本篇文章就来唠叨唠叨~

浮点数到底是怎么表示的?

浮点数其实是用来表示小数的,我们平时用的十进制小数也可以被转换成二进制后被计算机存储。比如9.875,这个小数可以被表示成这样:

9.875 = 8 + 1 + 0.5 + 0.25 + 0.125 
      = 1 × 2³ + 1 × 2⁰ + 1 × 2⁻¹ + 1 × 2⁻² + 1 × 2⁻³ 
复制代码

也就是说,如果十进制小数9.875转换成二进制小数的话就是:1001.111。为了在计算机里存储这种二进制小数,我们统一把它们表示成a × 2ⁿ的科学计数法的形式,其中1≤|a|<2,比如1001.111可以被表示成1.001111 × 2³。我们把小数点之后的001111称为尾数,把中的3称为指数,又因为一个数字有正负之分,所以为了表示一个小数,只需要下边这几部分就可以了:

  • 符号部分。

  • 尾数部分。

  • 指数部分。

根据表示尾数和指数所使用的存储空间大小不同,浮点数又被具体细分为:

  • 单精度浮点数(一般编程语言中的float类型):

    单精度浮点数总共占用4个字节:

    • 使用1个比特位表示符号部分,值为0时表示正数,值为1时表示负数。

    • 使用8个比特位表示指数部分。

    • 使用23个比特位表示尾数部分。

    画个示意图就是这样:

    image_1df37645t1v4mml1eo3t3u1v04m.png-78.2kB

  • 双精度浮点数(一般编程语言中的double类型):

    双精度浮点数总共占用8个字节:

    • 使用1个比特位表示符号部分。

    • 使用11个比特位表示指数部分。

    • 使用52个比特位表示尾数部分。

    示意图就不画了。

对于某个使用科学技术法表示的二进制小数,直接把指数对应的数字和尾数对应的数字填到对应的位置就完了么?比如对于二进制小数1.001111 × 2³(也就是十进制的9.875),如果用单精度浮点数表示的话应该是这样么(为了清楚的表示各个部分,我们用空格把各个部分分隔开,实际上是没有间隔的):

0 00000011 00111100000000000000000
复制代码

其中:

  • 第一个比特位0表示这是一个正数。

  • 00000011表示指数3

  • 00111100000000000000000表示尾数001111

哈哈,事实并没有这么简单,设计浮点数的大叔出于某种目的而把事情搞的稍微有些复杂(这个复杂是针对我们人类说的)。他们把指数部分当作一个无符号数,这个无符号数的字面量并不是真实的指数值,而是经过一些曲折的计算手段才能得到最后的指数值。他们把浮点数的存储方式分为了3种情况讨论:

  • 当指数部分的比特位既不全为0(数值0),也不全为1(单精度时为255,双精度时为2047)时:

    这时真实的指数值等于指数部分的字面量减去一个偏置值,这个所谓的偏置值在单精度浮点数中是127,在双精度浮点数中是1023

    比方说我们想使用单精度浮点数来存储二进制小数1.001111 × 2³,它的指数值为3,我们需要让指数部分的字面量减去127的值为3,所以指数部分的字面量就是130,用二进制表示就是:10000010。尾数部分是001111,所以表示二进制小数1.001111 × 2³的真正的单精度浮点数形式就是:

    0 10000010 00111100000000000000000
    复制代码

    小贴士: 这是为毛呀?为啥要在字面量的基础上减去一个所谓的偏置值。其实设计浮点数的大叔是这样考虑的,对于表示指数的一堆比特位来说,它们总共能表示的数字个数是确定的,比方说单精度浮点数的指数部分占用8个字节,那么就总共能表示2⁸个数字,也就是256个数字,不过比特位全为0和全为1时有特殊用途,所以指数部分最多能表示254个数字,能表示的数字范围就是-126~127。他们想让作为无符号数字面量最小的那个二进制数,也就是00000001来表示这254个数字中最小的那个,也就是-126,然后随着字面量的增大,表示的真实指数值也逐渐增大,直到最大的字面量11111110来表示真实的指数值127。这样只需要在字面量的基础上减去127就能达到这个效果。类似的,对于双精度浮点数来说,就得在指数部分的无符号字面量的基础上减去1023才能得到真实的指数值。看到了吧,设计浮点数的大叔只是想让字面量越大,真正的指数值越大这个目的才引入了偏置值这个怪怪的概念。

  • 当指数部分的比特位都为0时:

    这是一种特殊的情况,此时的指数并不代表0,而代表1减去偏置值,对于单精度浮点数来说,也就是指数代表1 - 127 = -126

    不过上一种情况中不是已经表示了指数值为-126的情况了么,为啥还要把这这个指数值为-126的情况单独提出来呢?这个还得从我们表示1.001111 × 2³这个二进制小数的例子说起,别忘了我们在浮点数的尾数部分存储的是001111,也就是自动忽略了小数点左边的那个1,这样就节省了1个比特位。不过在指数部分全为0的情况下,尾数部分是不包含小数左边的那个1的,比方说有一个单精度浮点数:

    0 00000000 01010000000000000000000
    复制代码

    这个浮点数表示的二进制小数就是:0.0101 × 2-¹²⁶

    可以看到,当单精度浮点数的尾数部分的比特位都为0时,表示的都是比1 × 2-¹²⁶小的数字,也就是接近0的那部分数字。

    当浮点数尾数部分的比特位都是0时,可以表示数值0.0,不过由于符号位(就是第一个二进制位)的存在,所以有+0.0-0.0之分。

  • 当指数部分的比特位都为1时:

    此时可以再细分为两种情况:

    • 当尾数部分的比特位都是0时:

      此时表示一个无穷大的值,当符号位为0时,表示正无穷大,当符号位为1时,表示负无穷大。

    • 当尾数部分的比特位不都为0时:

      此时表示一个NaN值,NaN的全称就是Not a Number,也就是不代表一个数,这在某些情况下是有用的。

至此,浮点数存储方式的三种情况就唠叨完了。不知道大家有没有发现一个规律,就是在不考虑符号位时,假设我们把浮点数的其余比特位看成一个无符号数,那么这个无符号数的值越大,它所表示的浮点数值也越大,这样在做浮点数比较大小操作时便十分简单。

再看浮点数运算

看完浮点数的存储格式之后,我们再回过头看一下最初提出的0.2 - 0.1 == 0.1值为true,而0.3 - 0.2 == 0.1值为false的情况。我们以单精度浮点数为例,看一下这些表达式里涉及到的这些数字该如何表示:

  • 0.1

    十进制小数0.1无法转成尾数在23位以内的二进制小数,所以只能经过舍入,得到近似值:

    1.10011001100110011001101 × 2⁻⁴
    复制代码

    指数值是-4,根据我们上边所述的第一种情况,指数部分的字面量就是123,表示成二进制小数就是01111011,所以我们可以得到十进制小数0.1对应的单精度浮点数就是:

    0 01111011 10011001100110011001101
    复制代码
  • 0.2

    它的二进制小数近似值就是:

    1.10011001100110011001101 × 2⁻³
    复制代码

    同理,可以得到如下单精度浮点数

    0 01111100 10011001100110011001101
    复制代码
  • 0.3

    它的二进制小数近似值就是:

    1.00110011001100110011010 × 2⁻²
    复制代码

    同理,可以得到如下单精度浮点数:

    0 01111101 00110011001100110011010
    复制代码

那么:

  • 计算0.2 - 0.1的值

    就相当于计算:

    1.10011001100110011001101 × 2⁻³ - 1.10011001100110011001101 × 2⁻⁴
    复制代码

    得到的结果就是:

    1.10011001100110011001101 × 2⁻⁴
    复制代码

    而这个值正好是十进制小数0.1的二进制小数表示形式,所以0.2 - 0.1 == 0.1这个表达式的结果就为true

  • 计算0.3 - 0.2的值

    就相当于计算:

    1.00110011001100110011010 × 2⁻² - 1.10011001100110011001101 × 2⁻³
    复制代码

    得到的结果就是:

    1.10011001100110011001110 × 2⁻⁴
    复制代码

    而这个值并不是十进制小数0.1的二进制小数表示形式,所以0.3 - 0.2 == 0.1这个表达式的结果就为false

    这种计算结果的差异主要是因为十进制小数转换为二进制小数需要非常多的比特位,甚至转为的二进制小数是无限小数,而使用浮点数来表示二进制小数时使用的存储空间是有限的,必须进行一定程度的舍入操作,这样表示的二进制小数就不精确,采用浮点数进行运算的结果就不精确,大家在日常使用浮点数的过程中要多加注意。

题外话

写文章挺累的,有时候你觉得阅读挺流畅的,那其实是背后无数次修改的结果。如果你觉得不错请帮忙转发一下,万分感谢~ 这里是我的公众号「我们都是小青蛙」,里边有更多技术干货,时不时扯一下犊子,欢迎关注:

猜你喜欢

转载自juejin.im/post/5d67656ff265da03d316d502