Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
dravenww opened this issue Jul 22, 2021 · 0 comments
Open

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

dravenww opened this issue Jul 22, 2021 · 0 comments

Comments

@dravenww
Copy link
Owner

表面工作

在日常的工作和学习中,经常会探测自己的底线,计算机基础好与不好,完全能够决定一个人的代码水平和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这个问题,上面的二进制部分,基本是足够了。当然代码部分仅作参考,边界等问题没有做处理...

做个题巩固一下:

18.625 的二进制表示是什么 ??? => 点击查看详情
    18的二进制表示为: 100010
    0.625的二进制表示为: 0.101
    所以18.625的二进制表示为:100010.101
  

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

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

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

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

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

格式 符号位 尾数 阶码 总位数 偏移值
单精度 1 8 23 32 127
双精度 1 11 52 64 1023

以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

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

公众号

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

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

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

@dravenww dravenww reopened this Mar 21, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant