计算机内的数据存储和操作永远是二进制的。称一个占据 8 字节空间的对象为 Int64,仅仅表明我们以 Int64 的形式去理解和操作那 64 个比特位。从电路层面上,储存一个 Int64 的 8 字节空间与存储一个 Float64 的 8 字节空间没有什么不同,都存储着 64 个 0 或者 1。
当我们操作一个整数对象,比如给它赋值时,是把其 64 个比特当成一个整体操作。有时,特别当我们的程序试图直接跟 CPU 之外的电路打交道时,我们期望能够直接操作一个对象的单个比特位。仓颉提供按位与&、按位或|、按位取反!、按与异或^、左移位<<、右移位>>等操作符,使得我们可以对构成整数对象的各个比特进行操作。
1. 二进制及16进制
假设一个无符号16位整数由2字节共16比特构成,其每个比特的值如图1所示。
在图1中,16个二进制位来源于连续的两字节存储空间,从低到高依次标为第0位 ~ 第15位,每一位分别对应位权20 ~ 215。
为了帮助读者理解,作者特意画了十进制的952710的结构作为参照。十进制数952710的值 = 9 × 103 + 5 × 102 + 2 × 101 + 7 × 100。即,十进制数的值等于其每位的位值乘以对应的位权,再求和。
类似地,无符号二进制数的值也等于其每位的位值乘以对应的位权,再求和。本例中,有:
在编程时,如果直接书写二进制字面量是一件痛苦的工作,程序员要非常小心地反复确认那些0和1的数字有没有错漏。相对于二进制,那些长期与电路打交道的电气工程师更愿意使用十六进制。十六进制的每一位,可以表示0 ~ 15共16种组合,由于24恰好等于16,所以每个十六进制位正好对应4个二进制位,其对应关系如表1所示。
十六进制 | 十进制 | 二进制(4位) | 十六进制 | 十进制 | 二进制(4位) |
---|---|---|---|---|---|
0 | 0 | 0000 | 8 | 8 | 1000 |
1 | 1 | 0001 | 9 | 9 | 1001 |
2 | 2 | 0010 | A | 10 | 1010 |
3 | 3 | 0011 | B | 11 | 1011 |
4 | 4 | 0100 | C | 12 | 1100 |
5 | 5 | 0101 | D | 13 | 1101 |
6 | 6 | 0110 | E | 14 | 1110 |
7 | 7 | 0111 | F | 15 | 1111 |
按照表1,我们很容易把二进制数换算成对应的十六进制。方法就是从低位往高位方向,将二进制位分为4位一组,然后按表1将每一组转成对应的十六进制符号即可。如图2所示,本例中的01001111101100102 = 4FB216,按字面量表示为0x4FB2。
反过来,如果要把十六进制转换成二进制,则需要按表1将每个十六进制位转换成4位二进制,然后再串起来即可。
请注意,本文关于位操作的讨论均基于无符号整数。对于有符号整数,由于补码的存在,问题要稍显复杂。在实践中,对有符号整数进行位操作的情况相对较少。
2. 按位取反
仓颉支持对整数对象进行按位取反操作。
1 | package BitNot |
上述程序的执行结果为:
1 | c = 01011101 |
如执行结果所见,!c对c进行按位取反,简单地说就是0变1,1变0。
3. 按位与
a & b中的&操作符将a对象与b对象的对应二进制位逐一进行按位与(and)运算。当且仅当a与b中的对应二进制位均为1时,结果位为1,否则为0。对于单个比特位的&运算,可以总结其规则如下:
1 | package BitAnd |
上述程序的执行结果为:
1 | a = 10001111111111111111001101111010 |
执行结果的第1 ~ 3行反应了无符号整数a与b进行按位与运算的结果,请读者仔细逐一检查结果中每一个比特位的值与a、b对应位之间的关系。
▶第13行:将a与0xfffffff7进行按位与运算。
请读者注意,0xfffffff7是一个很特别的数,在它的32个比特位中,只有第3位为0,其余位全为1。将UInt32对象a与0xfffffff7进行按位与运算,将导致如下结果:
- 结果的第3位将被置为0,不论其第3位本来是0还是1;
- 其余位不会发生变化(与a相同),因为任何值与1做与运算,值不变。
请读者观察执行结果的第4 ~ 6行,检验上述分析是否正确。通过按位与运算,可以达到将对象的特定位“置0”同时又保持其它位不变的目的。
4. 按位或
a | b中的|操作符将a对象与b对象的对应二进制位逐一进行按位或(or)运算。当且仅当a与b中的对应二进制位中至少有一个1时,结果位为1,否则为0。对于单个比特位的|运算,可以总结其规则如下:
1 | package BitOr |
上述程序的执行结果为:
1 | a = 1111001101111010 |
执行结果的第1 ~ 3行反应了无符号短整数a与b进行按位或运算的结果,请读者仔细逐一检查结果中每一个比特位的值与a、b对应位之间的关系。
▶第13行:请读者注意,0x0800是一个很特别的数,在它的16个比特位中,只有第11位为1,其余位全为0。将UInt16对象a与0x0800进行按位或运算,将导致如下结果:
结果第11位将被置为1,不论其第11位本来是0还是1;
其余位不会发生变化(与a相同),因为任何值与0作或运算,值不变。
请读者观察执行结果的第4 ~ 6行,检验上述分析是否正确。通过按位或运算,可以达到将对象的特定位“置1”同时又保持其它位不变的目的。
5. 按位异或
a ^ b中的^操作符将a对象与b对象的对应二进制位逐一进行按位异或(xor)运算。当且仅当a与b中的对应二进制位不同时,结果位为1,否则为0。
6. 左移位
a << n中的<<称为左移位操作符(left shift operator),它将对象a的二进制位逐次左移n位,超出左端的二进制位丢弃,并用0填充右端空出的位置。下述示例演示了<<操作符的用法。
1 | package LeftShift |
上述程序的执行结果为:
1 | a = 0000000000000101, value = 5 |
第7行代码将a左移3位,执行结果显示,在左移三位后,右方空位全部填充了0。
对于10进制数52310,如果将其左移一位,右方补0,得523010,客观上,左移一位相当于把数字乘以10。同理,在不溢出的前提下,二进制数左移一位,右方补0,客观上相当于把该数乘以2。如果左移n位,则相当于把该数乘以2n。本例中,原值为5的a被左移了3位,相当于乘以23,变为原始的8倍,结果为40。
7. 右移位
a >> n中的>>称为右移位操作符(right shift operator),它将对象a的二进制位逐次右移n位,超出右端的二进制位丢弃。如果a是无符号整数,用0填充左端空位。
1 | package RightShift |
上述程序的执行结果为:
1 | a = 0000100101000000, value = 2368 |
第7行代码将无符号整数a右移5位,执行结果显示,在右移五位后,左方空出位全部填充了0。
与左移操作相反,将无符号整数右移1位,相当于将该数除以2,右移n位,相当于将该数除以2n。本例中,原值为2368的无符号16位整数a右移5位,相当于除以25,即32,结果为74。
8. 置位与复位
将整数的指定位“置为1”称为置位(set bit);将整数的指定位“置为0”称为复位(reset bit)。之前的讨论已知:通过按位与运算,可以达到将对象的特定位“置0”同时又保持其它位不变的目的;通过按位或运算,可以达到将对象的特定位“置1”同时又保持其它位不变的目的。
下述程序中的setBit()及resetBit()函数即是通过按位或及按位与操作实现置位和复位的。
1 | package SetResetBit |
上述程序的执行结果为:
1 | before v = 1111111100000000 |
图3以setBit(v,6)为例,来说明本程序的置位原理。本例中,程序首先使用0x01 << 6来构造一个第6位为1,其它位全为0的UInt16,然后再把这个临时对象与v作按位或运算,将v的第6位置位,其它位则保持不变。
图4以resetBit(v,10)为例,来说明本程序的复位原理。本例中,程序首先使用0x01 << 10来构造一个第10位为1,其它位全为0的UInt16,然后再按位取反,得到一个第10位为0,其它位全为1的UInt16。然后,再把这个临时对象与v作按位与运算,将v的第10位复位,其它位则保持不变。
多数CPU都没有提供直接操作单个比特位的机器指令,所以, 仓颉语言仅提供了基于单个字节、两个字节、四个字节及八个字节的整体位操作语法。单个比特位的操纵,只能通过上述运算来间接完成。