第 12 讲:运算符(四):位运算符
位运算一共有六个:分别是位与运算 &
、位或运算 |
、位取反运算 ~
、位异或运算 ^
、位左移运算 <<
和位右移运算 >>
。
为了引导你学习比特位处理,我们先来学习一下整数在内存里的表达。
实际上,小数(浮点数)也有二进制表达的逻辑。因为它没有比特位的相关处理,因此我们不在这里作过多介绍。如果需要学习的话,请参考 IEEE 754 规范的相关内容。警告:该规范的学习难度较大。
Part 1 整数的内存表达方式
在早期发明计算机的时候,我们拥有一种万能的转换逻辑将人类能理解的十进制数值改成二进制数值。为了帮助理解,我们先讲正整数,然后讲负整数。它们的处理是不太一样的。
1-1 正整数的表达
我们将一个十进制数以二进制(只用 0 和 1 两个数码)来表示。为了保证表达的唯一性,采用的办法是这样的:
将一个十进制数不断向下除以 2,并一直往下写整数除法运算的结果,并在右侧对应写上除法的余数(0 或 1);
然后将表达出来的余数序列,从下往上倒着书写。书写的结果就是二进制数值了。
来看一个例子:47。我们要表达 47 这个数的二进制表达,那么就不断除以 2。
当然,因为对 2 取模运算(就是取余数)的话,只有奇数余数为 1,偶数余数为 0,因此你可以直接这么去记忆规则:当数字是偶数的时候,直接在右边写 0;否则就是奇数,那么右边就写 1。
最后,我们从最下面的 1 开始往上倒着读序列:101111,这个数字就是 47 的二进制结果。
相反地、我们如果需要将二进制转换回十进制数值的话,就把二进制数写出来之后,将每一个位置上的数字乘以权重(Weight),然后加起来。
我们还是拿 47 的二进制来说明。
我们在每一个数位的下方写上 0 到 5(从右往左写)。
然后,把标记了 1 的地方全部记作 2 的 n 次方。最后,把它们全加起来。
这里每一个数位都称为一个比特位(Bit),也称为比特或者位。
1-2 负整数的表达,以及补码的引入
正整数是最基础的表达过程。但是负整数有点不一样。在二进制的处理的过程之中,为了尽量使用较少的工具完成较多的任务,计算机科学家考虑使用加法器来计算减法。举个例子,我们想要完成 5 - 3 的任务,那么只需要改成 5 + (-3) 就可以了。这里的 -3 就是用负整数的表达就可以。然后,直接使用加法的算法,将 5 和 -3 两个数字加起来。
科学家最开始考虑的是用原码表达整数。原码和前面介绍的十进制转换成二进制后的结果基本上一样。就多了一个规则:如果这个数字是负数,那么就将最高位的比特从 0 改成 1。比如说 3 的话,我们就可以将最高位从 0 改成 1。最高位在哪里呢?这里就牵扯到了一个概念:数据类型占内存多大的问题。
一般来说,sbyte
占 1 个字节(8 个比特)、short
占 2 个字节(16 个比特)、int
占 4 个字节(32 个比特),而 long
占 8 个字节(64 个比特)。这四个类型都是带符号的类型,即除了正整数以外,还可以表示负整数。
按照一般的道理来说,假如这个数据类型是 sbyte
的话,那么我们就需要用到 8 个连续的比特来表达一个整数。当这个数字是负数的时候,最高位改成 1,其它比特位则依旧是二进制的普通表达。
这样就可以对应到之前的文章内容。8 个比特的话,一个比特表示符号,那么剩下的自然就只有 7 个比特了。7 个比特通过 0 和 1 的排列组合,一共能表达 128 种不同的数字(7 个数位,每一个数位能表示 0 和 1 两种情况,所以组合起来就是 2 的 7 次方,即 128 种结果);正是因为这个原因,外带一个符号位,所以
sbyte
的范围是 -128 到 127。你可能会问我:“欸,不对啊,这 -128 哪里来的;还有,为什么正整数只到 127,128 哪里去了”。这个问题我们不在这里说明。等我把这一点内容说完了,这个 -128 你自然就知道怎么来的了。
那么,-3 可以表达为 10000011
:最高位的 1 表示这个数是负数,而后面 7 个位置 0000011 刚好是 3 的二进制表达,所以这个数字是 -3。这个 -3 的二进制表示称为原码形式。
问题来了。如果我们直接带入 5 和 -3 的原码计算加法,会得到什么结果呢?
显然,负数的数据要和正数的数据是互补的,才能使得计算过程能够正常进行。因此,科学家发明了反码和补码的概念。科学家笃定了,补码形式一定能让负数变成可带入加法器运算的特殊表达形式。
补码是将原码的非符号位全部取反,然后再这个基础上再自增一个单位,得到的结果。比如 -3,我们要经过如下的一番运算,才能得到补码表达:
我们把补码提取出来,参与刚才的加法运算:
就有这么巧。5 + (-3) 结果恰好等于 2。稍微注意一下最高位的 1 的进位逻辑。由于我们拿 sbyte
0000 0010
。而这个数据的最高位是 0,也就是说它是一共正整数,故直接读取数值信息,就是 2 了。
是的,科学家发明的补码就是为了解决让负数也可以参与加法器的加法运算过程的问题。当然,除了解决这个问题,还有一个问题是,0 的原码里,+0 和 -0 是两个表达。一个是 0000 0000
,而另外一个是 1000 0000
。这样显然不行啊。于是,后者(-0)就使用补码来读取数据:
在变回去后,1000 0000
0000 0000
了,所以 -0 和 0 就是一样的数据了,确实很巧妙。
由于
1000 0000
转反码的时候需要先减去 1,而后面全 0 的关系,只能从符号位去减,因而数据成了0111 1111
。
在计算机里,1000 0000
被特殊处理,由于符号位是 1,因此只能读作负数,故这个数值就是 -128。
好了。我们解释了补码的问题,下面我们可以来看一下位运算了。
Part 2 位与运算
位与运算将两个数字对应的比特位作位与运算处理。它的操作和逻辑且运算是差不多的。我们把 0 当成 false
、1 当成 true
来理解:两个比特位在参与运算的时候,如果都是 1 才是 1,其它的情况都是 0。
我们使用 a & b
来表示把两个数字使用位与运算。它和贪婪逻辑且运算用的是一样的符号,但是贪婪逻辑且运算符的两侧都是 bool
类型的数值,而这里的 a
和 b
则是整数类型。
举个例子。我们将 5 和 -3 进行位与运算。运算过程如下:
我们可以看到 5 & -3
Part 3 位或运算
位或运算和位与运算差不多,也和位与运算的过程是对称的:只有两边都是 0 的时候,结果是 0,否则是 1。
我们依旧拿 5 和 -3 举例子。
它使用符号 |
来表示。
Part 4 位异或运算
位异或运算和逻辑异或运算是一样的。我们依旧把 1 当成 true
、0 当成 false
。当两个比特参与运算的时候,当且仅当两个比特位相同的时候(都是 0 或者都是 1),结果是 0;否则是 1。
它使用符号 ^
来表示。
Part 5 位取反运算
位取反运算和“原码取反”的过程基本一样,但是位取反的逻辑甚至会把每一个比特位取反,包括符号位。不过和逻辑取反运算类似,它只针对于一个数字进行运算,而不是两个数字一起参与运算。
不过稍微不一样的地方是,位取反运算并不是用感叹号,而是 ~
符号:比如说 ~(-3)
的结果就是 2。当然,这个 -3 的括号可以不要,即 ~-3
Part 6 位左移运算
位左移运算写成 <<
,表示将数值的比特位直接往左移动若干位置;右边移动出去的部分补充 0。比如 3 << 4
的话,将 3 写成二进制就是 0000 0011
。<< 4
表示往左边移动 4 个比特,然后右侧补充 0,就变成了 0000 0011 0000
。显然,我们拿 sbyte
类型举例,高 4 个位置的比特位会超出存储范围,因此会被舍弃掉,故结果是 0011 0000
,即十进制的 48,故 3 << 4
的结果就是 48。
这里可以记住一个结论。由于比特位是完整左移的,再加上右侧全部自动补充 0 来填补位置,所以实际上这个数据是被扩大了 2 的次幂这么多倍数。举个例子,
3 << 4
就应该和 的结果是一样的。实际上一看,确实是的:。
Part 7 位右移运算
同理,位右移运算写成 >>
,即将所有比特位往右边移动若干位置;然后左侧多出来的位置补充 0 占位。比如 47 >> 3
这个数值等于多少呢?47 写成二进制是 0010 1111
,往右移动 3 个位置就变成了 0000 0101 111
。最后面的三个位置上的 1 由于超出了表达范围,因此被舍去,因此数据变成了 0000 0101
,这个数值是 5,因此 47 >> 3
的结果是 5。
同样地,我们可以发现位右移运算也有类似的结论。往右移动比特位会将低比特位丢弃,而原始数据被缩小,因此实际上数据是被缩小了 2 的次幂这么多倍数。举个例子,
47 >> 3
就应该和 是一样的。注意,外围的 里,这个符号叫向下取整。因为数值本身是整数,而除法会使得数据变为小数,因此需要取整运算。
Part 8 总结
本文给大家介绍了位运算操作。这些操作可能不容易理解,对于我们以后来说,很少用到。如果我们会用到它们,我们在后面的文章会再次说明。