JAVASE基础教程
——
Java语法基础卷1Java基础语法
补充2有符号整数

已最新版本|V4.0
长风
COPYRIGHT ⓒ 2021. 王道版权所有

概述

>(green!)

「 该节补充知识主要涉及一些计算机基本原理,并不会很困难,建议都了解一下~」

对于任何开发语言而言,整数都是必须要使用的数据类型之一,拿Java而言,整型包括byte、short、int和long四种基本数据类型。

这些数据类型,都是以符合人类世界和自然世界的逻辑而出现的,说白了设计这些数据类型,都是为了让人能更好得理解数据。但是计算机显然是不能直接识别这些数据类型的,因为计算机只能存储和处理二进制数据。

因此,符合我们人类思维的数据都要通过一定的转换才能被正确的存储到计算机中。

由于篇幅限制,这里仅讲解数据在计算机中的存储方式的一部分——有符号整数Java的整型基本数据类型都是以有符号数的形式存储的。

进制

>(green!)

要想理解数据的存储,首先要明白最基本的进制概念。

[-] 关于进制

进制也就是进位制,是人们规定的一种进位方法。对于任何一种进制——X进制,就表示某一位置上的数运算时是逢X进一位。常见的进制有:

  1. 十进制,日常生活中应用最多的进制,逢十进一位,数字只能取值[0,9]之间,英文单词对应 DecimalJava代码中规定,一个数字不加任何限定的情况下,默认就是十进制的。

  2. 二进制,计算机底层使用的数据存储方式,逢二进一位,数字只能取值[0,1]两个,英文单词对应 Binary 。Java代码中规定,一个数字如果以"0b"或"0B"开头(注意是零)时,它就是二进制的。

  3. 十六进制,十六进制的出现可以看成是二进制的补充,逢十六进一位,数字的取值除了十进制的0到9,还包括用A表示10,B表示11,依次推直到F表示15。英文单词对应 Hex

    Java代码中规定,一个数字如果以"0x"或"0X"开头(注意是零和字母X)时,它就是十六进制的。二进制是计算机理解数据的方式,对于人来说,虽然不难理解,但是用二进制表示数实在太长了,十分不方便。而直接用十进制,又不利于理解计算机思维,所以就出现了十六进制,既简洁又能体现计算机思维。比如对于二进制的一个数:11111111,转换成十六进制就是:FF,恰好四位二进制对应一位十六进制数。

  4. 八进制,介于十六进制和二进制之间,用途不是很广泛,了解一下即可。逢八进一位,数字只能取值[0,7]之间。英文单词对应 OctalJava代码中规定,一个数字如果以"0"(注意是零)开头时,它就是八进制的。

最后,进制之间还有一个非常重要的——转换问题,感兴趣可以在网上搜一搜资料学习一下它们之间都是怎么转换的。

在这里,我推荐大家直接使用电脑自带的计算器去转换进制——手动转换太慢了,没必要!

有符号整数

>(green!)

在学习有符号整数的表示之前,先看三个小概念:

  1. 计算机中的数据存储最小单位是位(bit),每一个位存储一个1位的二进制码,即1位存储一个0或者1。

  2. 机器数,一个数在计算机中的二进制表示形式,就叫做这个数的机器数。机器数是带符号的,它最高位是符号位,0表示正数,1表示负数。

    比如,十进制中的数+3,选择8位存储的话,其二进制表示就是00000011,如果是 -3 ,就是 10000011 。

    那么,这里的 00000011 和 10000011 就是机器数。

  3. 真值,既然机器数是带符号位的,那么机器数的值就不等于真正的数值。其中机器数直接对应的值称之为"机器数的形式值",真实对应的值是"机器数的真值"。比如上面的例子,机器数10000011的形式值是131,真值是-3。

    即形式值是直接二进制转换成十进制的一个整数,而真值需要考虑符号位、位数等因素。

  4. 有符号整数就是机器数的一种形式,用于表示整数,下面看一个案例。

假如是一个二进制的8位机器数:

一个十进制的整数1,表示为二进制有符号数就是 (0000 0001)

一个十进制的整数-1,表示为二进制有符号数就是 (1000 0001)

将上述两个数相加,十进制相加很容易得到结果是0,但二进制有符号数相加却是(0000 0001)+(1000 0001)=(1000 0010) 这个数按照有符号数的规则转换为十进制是-2,这显然是不对的。

所以计算机中的二进制有符号数的表示,不可能如此简单就使用二进制去表示。

为了说清楚这个问题,就需要引入计算机中机器数表示的三种形式:原码、反码、补码

原码

>(red!)

原码是最简单的表示形式,直接用符号位的数字,加上真值的绝对值。如果是8位二进制:

[+1] = [0000 0001]

[-1] = [1000 0001]

原码是人脑最容易理解的机器数表示形式,无非就高位加一个符号位。但是我们上面已经说过了,如果计算机直接存储原码,进行计算将会出错,所以机器数还需要其它表示形式。

反码

>(red!)

反码的含义就和它的名字一样,是在原码的基础上取反。但需要注意:

  1. 正数的反码是其本身,不需要取反。

  2. 负数的反码是在其原码的基础上, 符号位不变,其余各个位取反。

举个例子,如果是8位的二进制数:

[+1] = [0000 0001] = [0000 0001]

[-1] = [1000 0001] = [1111 1110]

[-2] = [1000 0010] = [1111 1101]

可以看出:如果一个反码表示的是一个负数,那么人脑就无法直观的看出它的数值了。当然这不重要,毕竟机器数也不是给你看的,计算机能看懂就行。

在原码的小节中,我们发现直接用原码计算会出错,那么如果直接用反码计算呢?试一试:

使用反码计算
十进制运算反码计算反码计算结果(十进制)
1 +(-1)= 0[0000 0001] + [1111 1110] = [1111 1111] = [1000 0000]-0
1 +(-2)= -1[0000 0001] + [1111 1101] = [1111 1110] = [1000 0001]-1
-1 +(2)= 1[1111 1110] + [0000 0010] = [0000 0000](溢出) = [0000 0000]0(溢出归0

从上表可以看出,如果用反码直接进行计算,虽然有所改善,但仍然有问题,所以计算机中的运算也不能直接用反码。

这样就出现了补码的概念。

补码

>(red!)

补码是在反码的基础上得出的,它是这么规定的:

  1. 正数的补码是其本身,不需要做改变。

  2. 负数的补码等于反码加上1。

举个例子,如果是8位的二进制数:

[+1] = [0000 0001] = [0000 0001] = [0000 0001]

[-1] = [1000 0001] = [1111 1110] = [1111 1111]

[-2] = [1000 0010] = [1111 1101] = [1111 1110]

可以看到,补码已经完全和真值没有什么关系了,但是用补码进行计算,能够解决上述问题。

使用补码计算
十进制运算补码计算补码计算结果(十进制)
1 +(-1)= 0[0000 0001] + [1111 1111] = [0000 0000] = [0000 0000]0(溢出归0
1 +(-2)= -1[0000 0001] + [1111 1110] = [1111 1111] = [1111 1110] = [1000 0001]-1
-1 +(2)= 1[1111 1111] + [0000 0010] = [0000 0001] = [0000 0001]1(溢出归0

从上表可以看出,如果使用补码进行计算,就不会存在反码和原码计算中存在的问题了。

当然,有人可能会觉得我举的例子太少且比较简单,缺少代表性,那么你完全可以自己试一试。不过,你完全可以放心,补码计算一定不会有任何问题。

那么至此,我们要说几个非常重要的结论:

  1. 正数的原码反码补码是一致的,负数的反码是原码除符号位取反,补码是反码+1。

  2. 计算机中机器数的存储方式都是以补码的形式存储的,运算也是以补码的形式计算的。

为什么使用补码存储?

>(green!)

上面我们已经大致解释了这个问题:

因为对于原码、反码和补码三种存储方式而言,使用补码存储计算加法时才不会出错。

并且,补码的出现还使得计算机在运算时,用加法统一了加减法。

试想一下,如果使用补码存储整数,那么加减法都能够转换成加法进行,这样CPU就只需要设计加法器,而不需要专门设计减法器。结构简单不仅可以有利于实现,更能够提升效率。

补码进行运算不会出错,在上面我们已经用朴素的方式验证过了,关于加减法的统一,如果你想知道其设计原理,可以继续往下看。

原理

>(green!)

讨论下原码反码补码的原理,没兴趣的同学可以跳过。不过我觉得从本质上了解补码的机制还是很有好处的。

为什么原码和反码不行?

>(green!)

上面已经讲的很清楚了,使用原码和反码直接进行加法运算,由于错误的溢出和进位,导致最终错误的运算结果。所以我们需要另寻他法,这就是补码。接下来,浅谈一下补码的由来。

补码的原理

>(red!)

要谈补码,首先就要知道补码这个名字的由来。

原码比较好理解,真值加上符号位就是原码,原码基本就还是原先的值。反码是原码取反所以叫反码。

那么补码呢?为什么叫补码?这是因为补码的提出依赖于 "补数"的概念。

[-] 关于补数

在日常生活中,常常会遇到补数的概念:

例如时钟指示为六点,想要它指向三点,可以按顺时针方向将分针转九格,也可以逆时针方向转三格,结果是一致的。

一天是24小时,但时钟上只有12个数,时钟是不会显示超出12时的时间的,比如显示15点,实际上指针指向的位置是3。

即对时钟而言,超出12点的时间就存在”溢出“概念,都必须减去12这个数,才是指针真实指向的位置。

想象一下,在时钟这个问题上,任何时候时针向顺时针转九格和逆时针转三格的效果都是一致的,我们将方向定位正负,即 -3 与 +9 等价。

那么在数学上称12为模,写作(mod 12),而称 +9 是 -3 以 12 为模的补数记为:-3 ≡ +9(mod 12)

以此类,还有:

-4 ≡ +8(mod 12)

-5 ≡ +7(mod 12)

【以上截取自《计算机组成原理·第二版》唐朔飞著p221】

可以将模理解为一个足够大的正数,足够大到比你运算所有数字的绝对值都要大。

>(green!)

看到这里,你可能很疑惑,好好地为什么搞一个"补数"出来呢?

实际上,补数的意义是:在一个计算体系(模)中,补数的出现可以统一加法和减法为一种算法。

-4 ≡ +8(mod 12)也就是说,在时钟的体系中,-4 和 +8 是一个数,减去4就等于加上8,于是就能将加减法统一成加法或者减法。

明白这个道理后,那么把补数套用到计算机的机器数计算中,是不是也能够统一加减法呢?答案是肯定的,这就是补码的由来。

那么,看到这里你就已经明白了,补码是可以统一加减法的,但是具体到底是怎么得出来的呢?

补码的由来

>(green!)

数学意义上的加法和减法是有本质区别的,一个是让数变大,一个是让数变小,所以常规情况下加减法是不可能统一的,想要统一必须就要在特殊的场景中。

这时,我们就需要将眼光重新投到时钟上:

数轴在正常情况下是一条无限的直线,加减法的结果在数轴上的方向就不可能一致,那么:

如果数轴不是直线而是一个圆形,不就完全可能出现加一个数反而变小,减一个数反而变大的情况了吗?

于是,我们假设有一个圆形的数轴,取值范围是0~999,999 + 1 = 0 而不是 1000。

这个1000就是这个数轴计算体系中的mod(模),那么减去200就可以变成加上800,减去300就可以变成加上700,只要减去的数和加上的数的绝对值是模(1000),就存在这种统一加减法的可能。

规定把减去的正整数称之为"原码",与之对应加上的正整数称之为"补码"。计算过程如下:

700 - 327 = 373

327是"原码",计算补码:

1000 - 327 = 673

700 - 327 = 700 + 673 = 1373

因为我们已经规定了数轴是圆形而不是直线了,所以1373已经数值"溢出"了,1373真正表示的数应该是:

1373 - 1000 = 373

所以673是-327的"补码",运算结果也没问题。对上了!

于是我们得出结论:用减法减去一个数可以转换成加上这个数的"补码"。

上面的运算是基于十进制的,那么放在二进制里是否可行呢?

当然是可行的,因为计算机中机器数存储有位数限制,超过位数叫数值"溢出",不计算结果,也就是说固定位数的有符号数是一个天然的"圆形数轴"。

举一个简单例子,对于Java中的byte基本数据类型而言,我们都知道它的取值范围是[-128,127]

那么请问这个取值区间是一个数轴?还是一个圆环呢?

当然是一个圆环,因为byte的最大值加上1,就会成为byte类型的最小值。

>(green!)

假如计算机中存储了一个8位二进制数(先看无符号数),最大取值位1111 1111,加上 1 就会出现数值溢出结果是0。

于是我们取mod为1 0000 0000,然后计算补码,那么整个8位二进制的减法,我们只需要求出补码就可以全部转换为加法了。

我们随便找一个数1011 0010去求补码,仍然按照上面的方法计算:

1 0000 0000 - 1011 0010 = 0100 1110

上述过程看起来没问题,但是不要忘记了我们的初衷:

我们搞出补码是为了舍弃减法,那么如果在求补码的过程中就使用了减法,那岂不是画蛇添足?

所以计算机在算补码时,不能通过减法去算,那怎么算?继续看:

1 0000 0000 可以看成 1111 1111 加 1

所以:

1 0000 0000 - 1011 0010 = 1111 1111 + 1 - 1011 0010 = 1111 1111 - 1011 0010 + 1 = 0100 1101 + 1 = 0100 1110

看到这里,你可能又疑惑了:不是说好了干掉减法,但还是有啊。

不要着急,在计算的步骤我们使用的1111 1111 - 1011 0010 实际上并不是一个普通的减法。

观察一下减数 1011 0010 和结果 0100 1101 有什么关系?是不是恰好每位都取反?

对了,这种特殊的减法就是计算机中的 "取反",得到的结果就是反码

所以在计算补码的过程中,也去掉了减法:用取反运算,取代了减法!

好了,看到这里,你已经知道了教科书里为什么教我们原码取反加1就是补码了吧?

于是我们通过这种手段,统一了计算机中的二进制运算的加减法。

>(red!)

那么一切就万事大吉了吗?当然没有,继续看:

为了简单起见,以十进制为例,二进制是一样的,仍以mod = 1000为例。计算结果如果是负数的话,就有麻烦了:

300 - 700 = -400

-700相对于mod 1000的"补码"是300

于是300 - 700 变成了 300 + 300 = 600

这样看结果似乎不对了,但仔细一想 -400 的"补码" 实际上就是 600,它们是等价的,看来结果是对的,只是结果都是需要用补码等价换算一下罢了。

但一个表达式中的数如果还不能统一形式,实在太难以理解了,如果人都会被难住,何况计算机呢?

所以,既然结果中出现了需要等价换算的补码,那么我们干脆做出规定:

整个表达式中的所有数都是补码的形式,这也就是计算机中存储的机器数都是补码的原因。

做出这种规定后,那么问题就又来了:

在上面这个案例中,我们只把减数变成了补码的形式表示,用于消除减法。被减数仍然是一个“原码”,现在要求表达式全部用“补码”表示,于是就需要做出换算,如下:

300 - 700 = -700 + 300 = -400 = 600

上述计算式就太离谱了啊,出错的原因也很简单:补码本身就是为了消除减法而生的,将负数变成正数。而如果这个数本身就是一个正数,补码应该和原码一样才对。

所以我们又必须规定: 正数的补码是它本身,只有负数的补码才是取反 + 1。

于是我们就有了以下两个消除减法,统一成加法的运算方式:

300 - 700 = 300 + 300 = 600 = -400

我们可以测试一个被减数是负数的例子,比如:

-300 - 300 = 700 + 700 = 1400 = 400 = -600

看到这里,作为这一整套体系的设计者,你洋洋得意,以为解决了问题,是不是已经开始欢呼雀跃了?

其实你高兴得太早了,观察一下上面两个表达式的结果补码(标红色的部位)600 和 400,请问:

你怎么知道补码 600 的原码是 -400 而不是正数600呢?补码400的原码是-600而不是正数400呢?

换句话说,-600的补码一定是400(因为补码就是要将负数变成正数),而400不一定是-600的补码(正数的补码就是它本身),他可能是400本身的原码。

新的问题来了:

一个补码如果是正数的话,它的原码怎么确定是一个正数还是负数呢?

路是自己选的,那么多困难都解决了,这次也要解决问题,于是迎难而上吧。

出现这个问题的根源在于,缺少一种标志告诉我们结果的正负,如果能够像数字前带正负号一样,明确指示出数的正负不就可以了?

结合计算机的二进制表示,我们能不能在计算机存储数时,用1/0来表示数值的正负呢?总要试一试吧,于是就出现了有符号数,我们规定:

数值的最高位表示符号位,并且使用0表示正数,1表示负数。

有了符号位后,我们就可以通过结果的最高位判断:如果最高位是0,就表示正数,原码就是补码本身。如果最高位是1,就表示负数,原码就要做相应运算得到。

理想是很好的,但是能不能这么做还需要做测试,在具体测试之前,我们先梳理一下思路:

我们的目的是想通过加法来表示减法,从而统一加减法。

我们发明了一个叫做补码的东西,还规定以后所有的计算都用补码形式进行,正数的补码是它本身,负数的补码是取反 + 1。

但是现在这个东西产生了岐义,为了区分正负补码,我们决定:

一个机器数的最高位为符号位,它没有任何数值意义,仅用来代表机器码的正负,0表示正数,1表示负数。

现在最后需要做的就是测试了,首先我们要明确的是:

虽然最高位是符号位,但仍然要参与数值运算。并且我们之所以使用符号位,目的是为了区分正负数的补码,所以原码和补码的符号位必须是一致的(不然就白瞎了)。

所以我们必须规定: 负数在取反计算反码的时候,不能将符号位取反!

现在我们要规范一下原码、反码和补码的计算规则:

在固定存储位长得基础上,可以按照以下规则计算:

  1. 原码

    • 其有效数字是该数绝对值的二进制表示。

    • 最高位添加符号位,如果是负数添0,正数添1。

    • 有效数值和最高位之间如果有空隙,加0。

    • 最高位是符号位,其它位都是数值位。

  2. 反码

    • 正数的反码与原码相同。

    • 负数的反码是对其原码逐位取反,但符号位除外。

  3. 补码

    • 正数的补码与原码相同。

    • 负数的补码是在其反码的基础上再加1~

注意:

  1. 只有原码的最高位是符号位,其它位是数值位。反码和补码的最高位能仍然表示正负,但后面的位和数值就没啥关系的。

  2. 计算机内部是用补码存储数值的,只要补码没有超出位数就可以了。

一个比较典型的例子就是:-128

我们都知道一个byte字节型所表示的整数的取值范围是[-128,127],所以-128是可以用8位存储的。

-128的原码是:11000 0000(超出位数,表示不了)

-128的反码是:10111 1111(超出位数,表示不了)

但是这并不妨碍补码:10111 1111 + 1 = 11000 0000 (最高位溢出舍去)= 1000 0000

所以你看,-128的补码是在8位范围内的,最高位1表示负数,后面都是0,但并不意味着它表示"负0",后面不是数值位。

总之,我们做了这么多规定,那么结果还正确吗?需要验证一番,可以分四种情况:

  1. 正数 + 正数 = 正数

  2. 正数 - 正数 = 正数

  3. 正数 - 正数 = 负数

  4. 负数 - 正数 = 负数

限于篇幅(我懒,这里就不放验证了),大家感兴趣可以去试一试。当然你要能试出来不对,这个改进的重任就交给你了啊(笑

总结

>(green!)

最后再总结一下吧:

  1. 我们搞这一套都是为了消除减法,简化CPU结构/指令集,提升计算机运行效率。

  2. 计算机中的所有机器数都以补码的形式存储,最高位不再是数值位,而是符号位,符号位用 0 表示正数,1表示负数。

  3. 正数的补码是它自身,负数的补码为绝对值取反加一(不带符号位取反)

  4. 计算机中只有加法,没有减法。减去一个数可以转换成加上减数的补码。

>(green!)

以上,本节的主体内容就写完了。当然你还可以想一想其中的一些细节问题:

比如为啥符号位不是 0 表示负数,1表示正数呢?等等问题。

我在这里,就不再给出具体的解释了,大家可以自己想一想,查一查(如果你感兴趣的话)

>(red!)

写完这东西还是比较有感慨的,人类的智慧真是无穷的,补码这一套东西的设计实在是太magic了。

当然还有一个映像深刻的道理就是:

如果想要在一套已存在的体系上(生活中的加减法)搞一套新体系(计算机中的加减法),首先是需要思维方式上的创新(考虑计算机中的运算,就要用计算机思维思考),其次在设计之初它一定是漏洞百出的,需要反复修改最终才能合理(迭代)。

就像你撒个谎,弥补漏洞远比撒谎本身要难。

The End

如有发现错误,欢迎联系老师更新!