C++位运算与位掩码详解(含代码示例与应用场景)

什么是位运算与位掩码?

在上一课“位运算符”(O.2 – Bitwise operators) 中,我们讨论了各类位运算符如何在操作数的每一位上执行逻辑运算。既然已掌握其工作机制,本节将介绍它们在实际中的常见用法。

位掩码的定义与作用

为了操控单个位(例如将其置 1 或清 0),必须首先指明要操作的具体位。遗憾的是,位运算符无法直接识别“位位置”,它们只能与“位掩码”协同工作。

位掩码是一组预先定义的位模式,用于选取后续运算将要修改的特定位。

举现实生活中的例子:若要给窗框刷漆而不慎,可能连玻璃也会一并刷上。解决之道是购买遮蔽胶带,将其贴在玻璃及其他不希望被漆到之处;随后刷漆时,遮蔽胶带会阻挡油漆,从而只有未遮蔽区域(我们想要刷漆之处)被着色。

位掩码对位的作用与此类似——它阻止位运算符触及我们不想修改的位,同时允许访问需要修改的位。

首先探讨如何定义简单的位掩码,随后演示其用法。

如何在C++14中定义位掩码

最简单的方法是为每个位位置定义一个位掩码。用 0 屏蔽无关位,用 1 标记需要操作的位。

尽管位掩码可以是字面量,但通常定义为符号常量,以便赋予有意义的名字并方便复用。

由于 C++14 支持二进制字面量,定义如下:

#include <cstdint>

constexpr std::uint8_t mask0{ 0b0000'0001 }; // 位 0
constexpr std::uint8_t mask1{ 0b0000'0010 }; // 位 1
constexpr std::uint8_t mask2{ 0b0000'0100 }; // 位 2
constexpr std::uint8_t mask3{ 0b0000'1000 }; // 位 3
constexpr std::uint8_t mask4{ 0b0001'0000 }; // 位 4
constexpr std::uint8_t mask5{ 0b0010'0000 }; // 位 5
constexpr std::uint8_t mask6{ 0b0100'0000 }; // 位 6
constexpr std::uint8_t mask7{ 0b1000'0000 }; // 位 7

如此便获得了一组表示各 bit 位置的符号常量,可用于位操作(稍后演示)。

如何在C++11及更早版本中定义位掩码

C++11 不支持二进制字面量,因此需采用其他方式设定符号常量。常用两种方法:

方法一:使用十六进制字面量

(相关说明见 5.2 课 Literals。)

十六进制与二进制对应关系:

十六进制 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | A | B | C | D | E | F  
二进制   | 0000 | 0001 | 0010 | 0011 | 0100 | 0101 | 0110 | 0111 | 1000 | 1001 | 1010 | 1011 | 1100 | 1101 | 1110 | 1111  

因此可用十六进制定义位掩码:

constexpr std::uint8_t mask0{ 0x01 }; // 对应 0000 0001
constexpr std::uint8_t mask1{ 0x02 }; // 对应 0000 0010
constexpr std::uint8_t mask2{ 0x04 }; // 对应 0000 0100
constexpr std::uint8_t mask3{ 0x08 }; // 对应 0000 1000
constexpr std::uint8_t mask4{ 0x10 }; // 对应 0001 0000
constexpr std::uint8_t mask5{ 0x20 }; // 对应 0010 0000
constexpr std::uint8_t mask6{ 0x40 }; // 对应 0100 0000
constexpr std::uint8_t mask7{ 0x80 }; // 对应 1000 0000

若对十六进制转二进制不熟,可读性稍差。

方法二:使用左移运算符将单个 1 移动到对应位置

constexpr std::uint8_t mask0{ 1 << 0 }; // 0000 0001
constexpr std::uint8_t mask1{ 1 << 1 }; // 0000 0010
constexpr std::uint8_t mask2{ 1 << 2 }; // 0000 0100
constexpr std::uint8_t mask3{ 1 << 3 }; // 0000 1000
constexpr std::uint8_t mask4{ 1 << 4 }; // 0001 0000
constexpr std::uint8_t mask5{ 1 << 5 }; // 0010 0000
constexpr std::uint8_t mask6{ 1 << 6 }; // 0100 0000
constexpr std::uint8_t mask7{ 1 << 7 }; // 1000 0000

检测某位状态(开/关)

拥有位掩码后,可与位标志变量配合操控位标志。
判断某一位是否置位,使用按位与运算符并配合对应掩码:

#include <cstdint>
#include <iostream>

int main()
{
    [[maybe_unused]] constexpr std::uint8_t mask0{ 0b0000'0001 };
    [[maybe_unused]] constexpr std::uint8_t mask1{ 0b0000'0010 };
    [[maybe_unused]] constexpr std::uint8_t mask2{ 0b0000'0100 };
    [[maybe_unused]] constexpr std::uint8_t mask3{ 0b0000'1000 };
    [[maybe_unused]] constexpr std::uint8_t mask4{ 0b0001'0000 };
    [[maybe_unused]] constexpr std::uint8_t mask5{ 0b0010'0000 };
    [[maybe_unused]] constexpr std::uint8_t mask6{ 0b0100'0000 };
    [[maybe_unused]] constexpr std::uint8_t mask7{ 0b1000'0000 };

    std::uint8_t flags{ 0b0000'0101 }; // 8 位,可容纳 8 个标志

    std::cout << "bit 0 is " << (static_cast<bool>(flags & mask0) ? "on\n" : "off\n");
    std::cout << "bit 1 is " << (static_cast<bool>(flags & mask1) ? "on\n" : "off\n");

    return 0;
}

输出:

bit 0 is on
bit 1 is off

flags & mask0 为例:

0000'0101 &
0000'0001
---------
0000'0001

将结果 0000'0001 转换为 bool,由于非零即 true,故为真。
同理 flags & mask1 结果为 0000'0000,转为 bool 为假。

置位(打开某一位)

要将某一位置 1,使用按位或赋值运算符:

flags |= mask1; // 打开 bit 1

亦可同时打开多位:

flags |= (mask4 | mask5); // 同时打开 bit 4 和 bit 5

复位(关闭某一位)

要将某一位清 0,需结合按位与与按位非:

flags &= ~mask2; // 关闭 bit 2

同样可同时关闭多位:

flags &= ~(mask4 | mask5); // 同时关闭 bit 4 和 bit 5

关键提示

某些编译器可能因整型提升而提示符号转换警告:

flags &= ~mask2;

因为 mask2 类型小于 intoperator~ 会将其提升至 int,从而右操作数变为有符号,而左操作数为无符号,导致警告。
解决方式:

flags &= static_cast<std::uint8_t>(~mask2);

此问题在 O.2 课已讨论。

翻转位状态

要翻转位(0↔1),使用按位异或:

flags ^= mask2; // 翻转 bit 2

亦可同时翻转多位:

flags ^= (mask4 | mask5); // 翻转 bit 4 和 bit 5

位掩码与 std::bitset

std::bitset 支持全部位运算符。尽管使用其成员函数(test、set、reset、flip)修改单个位更便利,但若想一次性修改多位,仍可使用位运算符与位掩码。

示例:

#include <bitset>
#include <iostream>

int main()
{
    [[maybe_unused]] constexpr std::bitset<8> mask0{ 0b0000'0001 };
    [[maybe_unused]] constexpr std::bitset<8> mask1{ 0b0000'0010 };
    [[maybe_unused]] constexpr std::bitset<8> mask2{ 0b0000'0100 };
    [[maybe_unused]] constexpr std::bitset<8> mask3{ 0b0000'1000 };
    [[maybe_unused]] constexpr std::bitset<8> mask4{ 0b0001'0000 };
    [[maybe_unused]] constexpr std::bitset<8> mask5{ 0b0010'0000 };
    [[maybe_unused]] constexpr std::bitset<8> mask6{ 0b0100'0000 };
    [[maybe_unused]] constexpr std::bitset<8> mask7{ 0b1000'0000 };

    std::bitset<8> flags{ 0b0000'0101 };
    std::cout << "bit 1 is " << (flags.test(1) ? "on\n" : "off\n");
    std::cout << "bit 2 is " << (flags.test(2) ? "on\n" : "off\n");

    flags ^= (mask1 | mask2); // 翻转 bit 1 和 2
    // ...
}

注意

  1. std::bitset 无直接通过位掩码查询成员的函数,需使用按位与。
  2. 使用 any() 判断结果是否非零。

赋予位掩码语义

将位掩码命名为 mask1mask2 仅能指示所操作位,却无法说明其实际含义。
最佳实践是为位掩码赋予有意义的名字,以文档化位标志用途。以下示例源于某游戏:

#include <cstdint>
#include <iostream>

int main()
{
    [[maybe_unused]] constexpr std::uint8_t isHungry   { 1 << 0 }; // 0000 0001
    [[maybe_unused]] constexpr std::uint8_t isSad      { 1 << 1 }; // 0000 0010
    [[maybe_unused]] constexpr std::uint8_t isMad      { 1 << 2 }; // 0000 0100
    [[maybe_unused]] constexpr std::uint8_t isHappy    { 1 << 3 }; // 0000 1000
    [[maybe_unused]] constexpr std::uint8_t isLaughing { 1 << 4 }; // 0001 0000
    [[maybe_unused]] constexpr std::uint8_t isAsleep   { 1 << 5 }; // 0010 0000
    [[maybe_unused]] constexpr std::uint8_t isDead     { 1 << 6 }; // 0100 0000
    [[maybe_unused]] constexpr std::uint8_t isCrying   { 1 << 7 }; // 1000 0000

    std::uint8_t me{}; // 所有标志初始为关
    me |= (isHappy | isLaughing); // 开心且大笑
    me &= ~isLaughing; // 不再大笑

    std::cout << std::boolalpha;
    std::cout << "I am happy? " << static_cast<bool>(me & isHappy) << '\n';
    std::cout << "I am laughing? " << static_cast<bool>(me & isLaughing) << '\n';
}

使用 std::bitset 的等效实现:

#include <bitset>
#include <iostream>

int main()
{
    [[maybe_unused]] constexpr std::bitset<8> isHungry   { 0b0000'0001 };
    [[maybe_unused]] constexpr std::bitset<8> isSad      { 0b0000'0010 };
    [[maybe_unused]] constexpr std::bitset<8> isMad      { 0b0000'0100 };
    [[maybe_unused]] constexpr std::bitset<8> isHappy    { 0b0000'1000 };
    [[maybe_unused]] constexpr std::bitset<8> isLaughing { 0b0001'0000 };
    [[maybe_unused]] constexpr std::bitset<8> isAsleep   { 0b0010'0000 };
    [[maybe_unused]] constexpr std::bitset<8> isDead     { 0b0100'0000 };
    [[maybe_unused]] constexpr std::bitset<8> isCrying   { 0b1000'0000 };

    std::bitset<8> me{};
    me |= (isHappy | isLaughing);
    me &= ~isLaughing;

    std::cout << std::boolalpha;
    std::cout << "I am happy? " << (me & isHappy).any() << '\n';
    std::cout << "I am laughing? " << (me & isLaughing).any() << '\n';
}

何时使用位标志最有效?

敏锐的读者可能注意到,上述示例并未节省内存:8 个 bool 变量通常占 8 字节,而示例(使用 std::uint8_t)共占 9 字节——8 字节用于位掩码,1 字节用于标志变量!

位标志的真正优势在于存在大量相同的标志变量时。例如,若上例中“我”的数量增至 100 人,使用 8 个 bool 每人需 800 字节;位标志仅需 8 字节掩码 + 100 字节标志变量,共 108 字节,节省约 8 倍。

对大多数程序而言,节省的内存不足以抵消复杂度;但若对象数以万计乃至百万计,位标志能显著减少内存占用,是值得掌握的优化手段。

另一场景是函数需接受 32 种选项的任意组合。若用 32 个 bool 形参:

void someFunction(bool option1, ..., bool option32);

调用时:

someFunction(false, ..., true, ..., true); // 难以阅读

而使用位标志:

void someFunction(std::bitset<32> options);
someFunction(option10 | option32); // 清晰易读

这正是 OpenGL 等知名 3D 图形库采用位标志参数的原因之一:

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

其中:

#define GL_DEPTH_BUFFER_BIT   0x00000100
#define GL_STENCIL_BUFFER_BIT 0x00000400
#define GL_COLOR_BUFFER_BIT   0x00004000

多位的位掩码示例

尽管位掩码常用于选择单个位,也可选择多位。以显示器像素颜色为例:

像素颜色通常由红、绿、蓝三色光强度(各 8 位)及 Alpha 透明度(8 位)构成,共 32 位,格式如下:

RRRRRRRR GGGGGGGG BBBBBBBB AAAAAAAA

以下程序读取 32 位十六进制 RGBA 值,并提取各分量:

#include <cstdint>
#include <iostream>

int main()
{
    constexpr std::uint32_t redBits   { 0xFF000000 };
    constexpr std::uint32_t greenBits { 0x00FF0000 };
    constexpr std::uint32_t blueBits  { 0x0000FF00 };
    constexpr std::uint32_t alphaBits { 0x000000FF };

    std::cout << "Enter a 32-bit RGBA color value in hexadecimal (e.g. FF7F3300): ";
    std::uint32_t pixel{};
    std::cin >> std::hex >> pixel;

    const std::uint8_t red   { static_cast<std::uint8_t>((pixel & redBits)   >> 24) };
    const std::uint8_t green { static_cast<std::uint8_t>((pixel & greenBits) >> 16) };
    const std::uint8_t blue  { static_cast<std::uint8_t>((pixel & blueBits)  >> 8) };
    const std::uint8_t alpha { static_cast<std::uint8_t>(pixel & alphaBits) };

    std::cout << "Your color contains:\n";
    std::cout << std::hex;
    std::cout << static_cast<int>(red)   << " red\n";
    std::cout << static_cast<int>(green) << " green\n";
    std::cout << static_cast<int>(blue)  << " blue\n";
    std::cout << static_cast<int>(alpha) << " alpha\n";
}

运行示例:

Enter a 32-bit RGBA color value in hexadecimal (e.g. FF7F3300): FF7F3300
Your color contains:
ff red
7f green
33 blue
0 alpha

总结:位标志置位、清零、翻转与查询的简明总结

  • 查询:使用按位与
    if (flags & option4) ...
  • 置位:使用按位或赋值
    flags |= option4;
    flags |= (option4 | option5);
  • 清零:使用按位与与按位非
    flags &= ~option4;
    flags &= ~(option4 | option5);
  • 翻转:使用按位异或
    flags ^= option4;
    flags ^= (option4 | option5);

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"