在计算机内部,所有的整数最终都是以 0 和 1 组成的二进制形式保存在内存中的。其中,每一个 0 或 1 都叫做一个 “二进制位(bit)”。
初学的小伙伴刚看到二进制容易觉得陌生,其实拿平时熟悉的十进制对比一下就很好懂了。十进制是 “逢十进一”,从右往左每一位的权重依次是 1、10、100,每次乘以 10。二进制则是 “逢二进一”,从右往左每一位的权重依次是 1、2、4、8,每次乘以 2。
换算时,只要看哪一位上是 1,就把对应的权重加起来。比如一个 4 位二进制数从左到右对应的权重分别是 8、4、2、1。十进制整数 5 可以拆成 4 加 1,写成二进制就是 0101,而十进制整数 3 可以拆成 2 加 1,写成二进制就是 0011:
5 -> 0101 -> 0*8 + 1*4 + 0*2 + 1*1 = 5
3 -> 0011 -> 0*8 + 0*4 + 1*2 + 1*1 = 3所谓的 “位运算”,说白了就是直接对整数在内存中的每一个二进制位逐位操作。在 C++ 中,共有 6 个位运算符,如下表所示。
| 运算符 | 名称 | 运算规则 | 示例(a = 5, b = 3) |
|---|---|---|---|
| & | 按位与 | 两个对应位都为 1,结果才为 1;否则为 0 | a & b,结果为 1 |
| | | 按位或 | 两个对应位只要有一个为 1,结果就为 1 | a | b,结果为 7 |
| ^ | 按位异或 | 两个对应位不同为 1,相同为 0 | a ^ b,结果为 6 |
| ~ | 按位取反 | 把每一位上的 0 变成 1,1 变成 0 | ~a,逐位翻转 |
| << | 左移 | 将各二进制位向左移动指定位数,右侧补 0 | a << 1,结果为 10 |
| >> | 右移 | 将各二进制位向右移动指定位数 | a >> 1,结果为 2 |
在实际开发中,做普通加减乘除使用常规的算术运算就够了,但在处理权限开关、状态标志或底层硬件数据时,位运算非常实用。
由于负数在内存中的二进制编码比较特殊,为了方便大家直观观察每一位的 0 和 1 变化,下面的示例统一使用非负的 unsigned int 类型。同时从 C++14 标准起,代码里可以直接使用 0b 前缀写出二进制字面量,比如 0b0101 就表示十进制的 5,对照起来一目了然。
按位与(&)、按位或(|)和按位异或(^)
按位与(&)、按位或(|)和按位异或(^)都需要两个整数参与运算:先把两个整数的二进制位按位置对齐,再一位一位地算出结果。为了方便观察,下面推演时只列出最低 4 位,前面省略的高位全为 0:
- 按位与(&):两个对应位都为 1 时,结果位才为 1,否则为 0。在
5 & 3中,只有最右边那一位两个数都是 1,因此结果为0001,即十进制 1。 - 按位或(|):两个对应位只要有一个为 1,结果位就为 1,只有两个都是 0 时结果才为 0。在
5 | 3中,后三位都至少出现了一个 1,合并后的结果为0111,即十进制 7。 - 按位异或(^):两个对应位不同则为 1,相同则为 0,可以简记为 “异 1 同 0”。在
5 ^ 3中,中间两位不同得 1,首尾两位相同得 0,结果为0110,即十进制 6。
0101 & 0011 -> 0001 (1)
0101 | 0011 -> 0111 (7)
0101 ^ 0011 -> 0110 (6)注意 在 C++ 中,^ 是按位异或运算符,小伙伴们不要受数学书写习惯的影响把它当成 “乘方”。比如在代码里写 2 ^ 3 算出来的是异或结果 1,而不是 2 的 3 次方 8。
看完了上面的二进制推演,下面我们结合代码来验证 &、|、^ 这三个位运算符的计算结果。
示例 1:按位与、按位或与按位异或
#include <iostream>
int main() {
unsigned int a = 0b0101; // 十进制 5
unsigned int b = 0b0011; // 十进制 3
std::cout << (a & b) << '\n';
std::cout << (a | b) << '\n';
std::cout << (a ^ b) << '\n';
return 0;
}运行结果如下。
1
7
6分析:
(a & b)、(a | b) 和 (a ^ b) 按十进制打印出的结果分别是 1、7、6,与我们前面的二进制推演完全一致。
输出时之所以把 (a & b) 用小括号括起来,是因为根据前面学过的运算符优先级,输出符号 << 的优先级高于 &、|、^。如果不加小括号直接写 std::cout << a & b;,编译器会先算左边的 std::cout << a 而引发编译报错。
移位运算(<<、>>)与按位取反(~)
除了上面三个双目位运算符之外,C++ 还提供了将二进制位整体左右移动的左移(<<)和右移(>>)运算符,以及对单个整数逐位翻转的按位取反运算符(~)。
1. 左移(<<)和右移(>>)
左移运算符(<<)会把所有二进制位整体向左移动指定的位数,右边空出的低位自动补 0。右移运算符(>>)则会把所有二进制位整体向右移动,最右边移出的低位直接丢弃。仍以十进制 5(0101)为例:
0101 << 1 -> 1010 (10)
0101 >> 1 -> 0010 (2)看到 << 和 >>,大家会联想到输入输出里的 std::cout << 和 std::cin >>。同一个符号在不同位置作用不同:跟在 std::cout 或 std::cin 后面时负责输入输出,放在两个整数中间时就是左移和右移运算符。请看下面例子。
示例 2:左移与右移运算
#include <iostream>
int main() {
unsigned int value = 0b0101; // 十进制 5
std::cout << (value << 1) << '\n';
std::cout << (value << 2) << '\n';
std::cout << (value >> 1) << '\n';
return 0;
}运行结果如下。
10
20
2分析:
value 初始二进制为 0101,即十进制 5。左移 1 位后变成 1010(十进制 10),左移 2 位后变成 10100(十进制 20)。右移 1 位则丢掉最右边的 1,变成 0010(十进制 2)。
可以看到,在非负整数不溢出的前提下,每左移 1 位相当于乘以 2,每右移 1 位相当于除以 2 取整。当然,日常做普通乘除法时直接写 * 2 和 / 2 会更加直观。
2. 按位取反(~)
按位取反(~)写在单个整数前面,作用是把该整数在内存中的每一个二进制位翻转过来:原来是 0 的位变成 1,原来是 1 的位变成 0。如果只看最低 4 位,0101 翻转后会变成 1010(十进制 10)。那实际运行代码会输出什么呢?下面我们来看一个例子。
示例 3:按位取反运算
#include <iostream>
int main() {
unsigned int value = 0b0101; // 十进制 5
std::cout << (~value) << '\n';
std::cout << ((~value) & 0b1111) << '\n';
return 0;
}运行结果如下。
4294967290
10分析:
看到第一行的输出结果,很多小伙伴可能会觉得很奇怪:“0101 翻转过来不是 1010(十进制 10)吗?为什么第一行会跑出 4294967290 这么大的数字呢?”
这是因为在主流电脑上,一个 unsigned int 占 4 个字节,一共包含 32 个二进制位,这一点可以通过 sizeof 运算符查出来。前面省略没写出来的 28 个高位原本全是 0,一旦执行 ~value,那 28 个 0 也会全部翻转成 1,算出来的自然就是一个几十亿的大整数。
回忆一下按位与的规则:任何二进制位和 0 按位与都会变成 0(清零),和 1 按位与则会保持原样不变(保留)。因此第二行写 ((~value) & 0b1111) 时,0b1111 前面省略的 28 个高位全是 0,刚好把高位的 1 全部清零,只保留最低 4 位翻转后的 1010,于是准确输出了 10。
“位运算符” 与 “逻辑运算符” 的区别
前面我们学习过各种基础运算符,其中的逻辑运算符(&&、||、!)和这里的位运算符(&、|、~)外形很像,初学者很容易敲错。它们之间的主要区别如下表所示。
| 对比维度 | 位运算符(&、|、~) | 逻辑运算符(&&、||、!) |
|---|---|---|
| 操作对象 | 针对整数的每一个二进制位逐位计算 | 针对条件表达式的整体真假 |
| 计算结果 | 返回计算后的整数值 | 返回布尔值 true 或 false |
| 短路特性 | 无短路特性,两边都会完整计算 | && 和 || 具有短路特性 |
下面我们通过一个示例,看看把 && 误写成 & 会带来怎样的结果反差。
示例 4:按位与和逻辑与的区别
#include <iostream>
int main() {
unsigned int a = 4;
unsigned int b = 2;
std::cout << (a & b) << '\n';
std::cout << (a && b) << '\n';
return 0;
}运行结果如下。
0
1分析:
同样是 4 和 2,使用 & 算出来是 0,而使用 && 算出来却是 1。计算按位与 a & b 时,4 的二进制是 0100,2 的二进制是 0010,逐位对比没有任何一位同时为 1,因此结果是 0。
而在计算逻辑与 a && b 时,C++ 会把所有非 0 的整数都当成 true。由于 4 和 2 都不是 0,4 && 2 就相当于 true && true,结果为 true,输出为 1。
位运算的应用场景
在实际开发中,位运算有一个非常经典的用途:使用一个整数的不同二进制位来保存多个 “开 / 关” 状态,每一个代表独立状态的二进制位叫做一个 “标志位(Flag)”。
比如我们要管理一个文件的 3 种权限:“可读”、“可写” 和 “可执行”,可以让每个权限各自独占一个二进制位:
readFlag = 0b0001 (1)
writeFlag = 0b0010 (2)
executeFlag = 0b0100 (4)细心的小伙伴会注意到,三个权限对应的十进制数字是 1、2、4,而不是 1、2、3。这是因为 0b0001、0b0010、0b0100 里都只有一个位置是 1,就像一排互不干扰的独立开关。而十进制 3 的二进制是 0b0011,相当于同时拨开了前两个开关。
定义好独立的标志位后,最常用的操作有以下 2 种:
- 使用 | 组合权限:例如
readFlag | writeFlag会将0001与0010按位或,得到0011(十进制 3),表示同时开启可读和可写权限。 - 使用 & 检查权限:例如
(permissions & writeFlag) != 0,拿当前权限值与目标标志做按位与。如果结果不为 0,说明对应位置上的开关处于开启状态。
注意 按位与(&)、按位或(|)、按位异或(^)的优先级比比较运算符(==、!=)还要低。因此把位运算与比较运算写在一起时,必须加上小括号写成 (permissions & writeFlag) != 0,否则程序会先算后面的 != 0。
示例 5:组合与检查权限标志
#include <iostream>
int main() {
const unsigned int readFlag = 0b0001; // 可读(1)
const unsigned int writeFlag = 0b0010; // 可写(2)
const unsigned int executeFlag = 0b0100; // 可执行(4)
// 组合可读与可写权限
unsigned int permissions = readFlag | writeFlag;
// 检查是否拥有对应权限
bool canWrite = (permissions & writeFlag) != 0;
bool canExecute = (permissions & executeFlag) != 0;
std::cout << canWrite << '\n';
std::cout << canExecute << '\n';
return 0;
}运行结果如下。
1
0分析:
在这个例子中,permissions 通过 readFlag | writeFlag 合并了可读与可写权限,对应的最低 4 位二进制为 0011。
接着使用 & 检查可写权限:permissions & writeFlag 相当于计算 0b0011 & 0b0010,结果为 2(不等于 0),说明已开启可写权限,canWrite 输出 1。
而检查可执行权限时,permissions & executeFlag 相当于计算 0b0011 & 0b0100,结果为 0,说明未开启可执行权限,canExecute 输出 0。
