C++ 位运算

在计算机内部,所有的整数最终都是以 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 个位运算符,如下表所示。

C++ 位运算符
运算符 名称 运算规则 示例(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。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号