头图

从0.1 + 0.2 !== 0.3 聊聊计算机基础

德莱问前端
English

表面工作

在日常的工作和学习中,经常会探测自己的底线,计算机基础好与不好,完全能够决定一个人的代码水平和bug出现率。相信大家对这些知识都学过,只是长时间不用就忘记了,今天带大家来回顾一下。

本着通俗易懂的原则,今天把这个题目讲明白。

我们来聊聊这个非常常规的问题,为什么 0.1 + 0.2 !== 0.3.在正式介绍这个问题之前,需要了解下面几
个前置知识。

  • 计算机二进制的表现形式以及二进制的计算方式?
  • 什么是原码、补码、反码、移码,都是用来做什么的?

差不多这几个就够理解这个常规的 0.1 + 0.2 !== 0.3问题了。

第一个前置知识,二进制

我们知道在日常中,有很多种数据的展现,包括我们日常生活中常规使用的10进制、css中表示颜色的16进制、计算机中进行运算的二进制。

二进制的表现形式

在计算机中的计算都是以二进制的形式进行计算的,也就是全都是0或1来表示数字的,我们拿10进制进行举例,如:

  • 10进制的 1 在计算机中表示为 1
  • 10进制的 2 在计算机中表示为 10
  • 10进制的 8 在计算机中表示为 1000
  • 10进制的 15 在计算机中表示为 1111

二进制的计算方式

对于二进制的计算方式,我们分为两种情况来说,一种是整数的计算,一种为小数的计算。

整数部分的二进制计算

我们先说明10进制如何转化为二进制。10进制转化为二进制的方式称为“除 2 取余法”,即把一个10进制数,一直除以2取其余数位。举两个例子

30 % 2 ········· 0
15 % 2 ········· 1
 7 % 2 ········· 1
 3 % 2 ········· 1
 1 % 2 ········· 1
 0

整数的二进制转换是从下往上读的,所以30的二进制表示即为11110.

100 % 2 ········· 0
 50 % 2 ········· 0
 25 % 2 ········· 1
 12 % 2 ········· 0
  6 % 2 ········· 0
  3 % 2 ········· 1
  1 % 2 ········· 1
  0

整数的二进制转换是从下往上读的,所以100的二进制表示即为1100100.

我还专门写了一个函数来转换这个二进制。

function getBinary(number) {
  const binary = [];
  function execute(bei) {
    if (bei === 0) {
      return ;
    }
    const next = parseInt(bei / 2, 10);
    const yu = bei % 2;
    binary.unshift(yu);
    execute(next);
  }
  execute(number);
  return binary.join('');
}
console.log(getBinary(30)); // 11110
console.log(getBinary(100)); // 1100100

接下来,我们再看看怎么把二进制转换成10进制。通俗点讲就是从右到左用二进制的每个数去乘以2的相应次方并递增。举个例子,拿上面的100举例子吧。100的二进制表示为1100100,我们需要做的是:

1100100
= 1 * 2^6 + 1 * 2^5 + 0 * 2^4 + 0 * 2^3 + 0 * 2^2 + 0 * 2^1 + 0 * 2^0
= 100

简单明了,不用多说,看下实现代码:

function getDecimal(binary) {
  let number = 0;
  for (let i = binary.length - 1; i >= 0; i--) {
    const num = parseInt(binary[binary.length - i - 1]) * Math.pow(2, i);
    number += num;
  }
  return number;
}
console.log(getDecimal('11110')); // 30
console.log(getDecimal('1100100')); // 100

小数部分的二进制计算

小数部分的二进制计算与整数部分的二进制计算不同,十进制的小数转化为二进制的小数的计算方式称为“乘二取整法”,即把一个十进制的小数乘以2然后取其整数部分,直到其小数部分为0为止。看个例子:

0.0625 * 2 = 0.125 ········· 0
 0.125 * 2 = 0.25  ········· 0
  0.25 * 2 = 0.5   ········· 0
   0.5 * 2 = 1.0   ········· 1

且小数部分的读取方向也不一样。小数的二进制转换是从上往下读的,所以0.0625的二进制表示即为0.0001,这个是正好能够除尽的情况,很多情况下是除不尽的,例如题目中的0.1和0.2。写个函数转换下:

function getBinary(number) {
  const binary = [];
  function execute(num) {
    if (num === 0) {
      return ;
    }
    const next = num * 2;
    const zheng = parseInt(next, 10);
    binary.push(zheng);
    execute(next - zheng);
  }
  execute(number);
  return '0.' + binary.join('');
}
console.log(getBinary(0.0625)); // 0.0001

再尝试把二进制的小数转换为十进制的小数,因为上面是乘,所以在这边就是除法了,二进制的除法也是可以表示为负指数幂的乘法的,比如1/2 = 2^-1;我们来看下0.0001怎么转换为0.0625:

0.0001
= 0 * 2^-1 + 0 * 2^-2 + 0 * 2^-3 + 1 * 2^-4
= 0.0625

用函数来实现下这个形式吧。

function getDecimal(binary) {
  let number = 0;
  let small = binary.slice(2);
  for (let i = 0; i < small.length; i++) {
    const num = parseInt(small[i]) * Math.pow(2, 0 - i - 1);
    number += num;
  }
  return number;
}
console.log(getDecimal('0.0001')); // 0.0625

二进制转换这一部分我们就先了解到这里,对于 0.1 + 0.2 !== 0.3这个问题,上面的二进制部分,基本是足够了。当然代码部分仅作参考,边界等问题没有做处理...

做个题巩固一下:
<details>
<summary> 18.625 的二进制表示是什么 ??? => 点击查看详情</summary>
<pre>

18的二进制表示为: 100010
0.625的二进制表示为: 0.101
所以18.625的二进制表示为:100010.101

</pre>
</details>
<sammry></sammry>

第二个前置知识,计算机码

我们知道,计算机中是使用二进制来进行计算的,讲到计算机码,就不得不提 IEEE标准,而涉及到小数部分的运算就不得不提到 IEEE二进位浮点数算术标准的标准编号(IEEE 754)。其标准的二进制表示为

V = (-1)^s * M * 2^E
  • 其中s为符号位,0为正数,1为负数;
  • M为尾数,是一个二进制小数,其中规定第一位只能是1,1和小数点省略
  • E为指数,或者称为阶码

为什么1和小数位要省略呢?因为所有的第一位都为1,省略后可以再末尾再多一位,增加精确度。如果第一位为0的话,那没有任何意义。

一般来说,现在的计算机都支持两种精度的计算浮点格式。一种为单精度(float),一种为双精度(double)。

格式符号位尾数阶码总位数偏移值
单精度182332127
双精度11152641023

以JavaScript为例,js中使用的是双精度格式来进行计算的,其浮点数是64位。

原码

什么是原码,原码是最简单的,就是符号位加上真值的绝对值, 即用第一位表示符号, 其余位表示值。我们用11位表示如下:

  • +1 = [000 0000 0001]原
  • -1 = [100 0000 0001]原
    因为第一位是符号位,所以其取值区间为[111 1111 1111, 011 1111 1111] = [-1023, 1023];

    反码

    什么是反码,反码是在原码的基础上进行反转。正数的反是其本身;负数的反码是符号位不变,其余位取反。

  • +1 = [000 0000 0001]原 = [000 0000 0001]反
  • -1 = [100 0000 0001]原 = [111 1111 1110]反

补码

什么是补码,补码是在反码的基础上补位。正数的补码是其本身,负数的补码是在其反码的基础上,再加1.

  • +1 = [000 0000 0001]原 = [000 0000 0001]反 = [000 0000 0001]补
  • -1 = [100 0000 0001]原 = [111 1111 1110]反 = [111 1111 1111]补
    为什么会有补码这玩意呢?
  • 首先在计算机中是没有减法的,都是加法,比如 1 - 1 在计算机中是 1 + (-1).
  • 如果使用原码进行减法运算:

    1 + (-1) = [000 0000 0001]原 + [100 0000 0001]原 
             = [100 0000 0010]原 
             = -2

    ===>>> 结论:不对

  • 为解决这个不对的问题于是就有了反码去做减法:

    1 + (-1) = [000 0000 0001]反 + [111 1111 1110]反 
             = [111 1111 1111]反 
             = [100 0000 0000]原 
             = -0

    发现值是正确的,只是符号位不对;虽然+0和-0在理解上是一样的,但是0带符号是没有意义的,况且会出现 [000 0000 0000]原 和 [100 0000 0000]原 两种编码方式。

    ===>>> 结论:不大行

  • 为解决上面这个符号引起的问题,就出现了补码去做减法:

    1 + (-1) = [000 0000 0001]补 + [111 1111 1111]补 
             = [000 0000 0000]补 
             = [000 0000 0000]原 
             = 0

    这样得到的结果就是完美的了,0用 [000 0000 0000] 表示,不会出现上面 [100 0000 0000]。

    ===>>> 结论:完美

移码

移码,是由补码的符号位取反得到的,一般用做浮点数的阶码,引入的目的是为了保证浮点数的机器零为全0。这个不分正负。

  • +1 = [000 0000 0001]原 = [000 0000 0001]反 = [000 0000 0001]补 = [100 0000 0001]移
  • -1 = [100 0000 0001]原 = [111 1111 1110]反 = [111 1111 1111]补 = [011 1111 1111]移
    细心一点可以发现规律:
  • +1 = [000 0000 0001]原 = [100 0000 0001]移
  • -1 = [100 0000 0001]原 = [011 1111 1111]移

为什么 0.1 + 0.2 !== 0.3 ?

回到我们的题目,我们来看下为什么 0.1 + 0.2 !== 0.3.来看下0.1和0.2的二进制表示。

0.1 = 0.0 0011 0011 0011 0011 0011 0011 0011 0011 0011 .... 0011无限循环
0.2 =  0. 0011 0011 0011 0011 0011 0011 0011 0011 0011 .... 0011无限循环

可以得知0.1和0.2都是一个0011无限循环的二进制小数。

我们由上面知道,JavaScript中的浮点数是64位来进行表示的,那么0.1和0.2是在计算机中又是如何表示的呢?

0.1 = (-1)^ 0 * 1.1 0011 0011 0011 * 2^(-4)
-4 = 10 0000 0100

根据IEEE 754标准可以得知:

V = (-1)^S * M * 2^E
S = 0  // 1位,正数为0,负数为1
E = [100 0000 0100]原 // 11位
  = [111 1111 1011]反 
  = [111 1111 1100]补 
  = [011 1111 1100]移 
M = 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 // 52位(其中1小数点省略)

同理可知0.2的表示:

0.2 = (-1)^ 0 * 1.1 0011 0011 0011 * 2^(-3)
-4 = 100 0000 0011

V = (-1)^S * M * 2^E
S = 0  // 1位,正数为0,负数为1
E = [100 0000 0011]原 // 11位
  = [111 1111 1100]反 
  = [111 1111 1101]补 
  = [011 1111 1101]移
M = 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 // 52位(其中1小数点

两者相加,阶码不相同,我们需要进行对阶操作。

对阶

对阶就会存在尾数移动的情况。

  • 大的阶码向小的阶码看齐,就需要把大的阶码的数的尾数向左移动,此时就有可能在移位过程中把尾数的高位部分移掉,这样就引发了数据的错误。这是不可取的
  • 小的阶码向大的阶码看齐,就需要把小的阶码的数向右移动,高位补0;这样就会把右边的数据给挤掉,这样也就导致了会影响数据的精度,但是不会影响数据的整体大小。

计算机采取的是后者,小看大的办法。这也就是今天这个问题产生的原因,丢失了精度

那么接下来,我们就看看上面的这个移动。

// 0.1
E = [100 0000 0011]原 = [111 1111 1100]反 = [111 1111 1101]补 // 11位, 对阶后
M = 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 // 移动前
M = 0100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 // 移动后
// 0.2 保持不变
E = [100 0000 0011]原 = [111 1111 1100]反 = [111 1111 1101]补 // 11位,不变
M = 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 // 52位,不变

如上就是二进制中0.1和0.2的对阶后的结果,我们对这个数字进行运算比较麻烦,所以我们直接拿0.1和0.2的真值进行计算吧。

真值计算

0.1 = 0.0 0011 0011 0011 0011 0011 0011 0011 0011 0011 .... 0011无限循环
0.2 = 0. 0011 0011 0011 0011 0011 0011 0011 0011 0011 .... 0011无限循环
0.1 + 0.2
    = 0.0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 (1001...舍弃)
    + 0.0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 (0011...舍弃)
    = 0.0100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 (1100...舍弃)
    = 0.2999999999999998

这特么不对啊!!!

我们在浏览器运行的时候得到的值是:

0.1 + 0.2 = 0.30000000000000004

产生上面问题的原因,是在于计算机计算的时候,还会存在舍入的处理
如上面来看,真值计算后的值舍弃的值是1100,在计算机中还会存在舍0入1,即如下:

0.1 + 0.2
    = 0.0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 (1001...舍弃)
    + 0.0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 (0011...舍弃)
    = 0.0100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 (1100...舍弃)
    = 0.0100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1101 (入1)
    = 0.30000000000000004

到此,我们就把这部分聊明白了,如有不对之处,欢迎指出。感谢阅读。

关注

欢迎大家关注我的公众号[德莱问前端],文章首发在公众号上面。

除每日进行社区精选文章收集外,还会不定时分享技术文章干货。

希望可以一起学习,共同进步。

阅读 1.9k
114 声望
588 粉丝
0 条评论
114 声望
588 粉丝
文章目录
宣传栏