15、重载位运算符:重载&、|、^、~、<<、>>,实现位集合(bitset)类
位运算,说白了就是直接操作内存里的每一个比特位。很多刚入行的朋友觉得这东西离自己很远,其实不然。我在做网络协议栈的时候,一个数据包的状态标志位就靠它来搞定。你想想看,一个 uint32_t 就能存32个开关状态,省内存又高效,多划算。
今天我们就自己动手,写一个简单的位集合类,把 &、|、^、~、<<、>> 这些位运算符全给它重载一遍。嗯,这里要注意:重载不是炫技,是为了让代码读起来像自然语言。
为什么需要位集合?
标准库里其实有 std::bitset,但它的长度是编译期固定的。我当年做嵌入式项目时,需要动态大小的位图来标记内存块的使用情况。标准库帮不上忙,只能自己造轮子。
自己实现的好处很明显:
- 你可以控制底层存储(比如用
std::vector) - 你可以添加自定义操作(比如统计连续0的个数)
- 你可以完全理解位运算的底层原理
核心思想:用一个无符号整数数组来模拟一个连续的比特序列。每个元素存储64个比特位,通过索引计算出它落在哪个元素、哪个偏移量上。
基础框架:存储与构造
我们先搭个架子。底层用 std::vector,每个 uint64_t 存64个位。
#include <vector>
#include <cstdint>
#include <stdexcept>
class BitSet {
public:
// 构造一个能容纳 n 个位的集合,默认所有位为 0
explicit BitSet(size_t n)
: bits_((n + 63) / 64, 0), size_(n) {}
// 获取位数
size_t size() const { return size_; }
// 设置/获取某一位
void set(size_t pos, bool val = true) {
check(pos);
if (val)
bits_[word_index(pos)] |= (1ULL << bit_offset(pos));
else
bits_[word_index(pos)] &= ~(1ULL << bit_offset(pos));
}
bool test(size_t pos) const {
check(pos);
return (bits_[word_index(pos)] >> bit_offset(pos)) & 1;
}
private:
std::vector<uint64_t> bits_;
size_t size_;
size_t word_index(size_t pos) const { return pos / 64; }
size_t bit_offset(size_t pos) const { return pos % 64; }
void check(size_t pos) const {
if (pos >= size_)
throw std::out_of_range("BitSet index out of range");
}
};
这段代码里,word_index 和 bit_offset 是两个辅助函数。我个人习惯把它们写成内联的,因为位运算操作本身就很频繁,函数调用开销能省则省。
小技巧:用 1ULL 而不是 1,因为 1 默认是 int,左移超过31位就未定义行为了。我曾经在这个坑里趴了一下午。
重载位运算符:&、|、^、~
这些运算符的语义很直观:对两个位集合的对应位做逻辑运算。注意,两个集合的长度必须一致,否则没法做。
BitSet operator&(const BitSet& other) const {
check_size_match(other);
BitSet result(size_);
for (size_t i = 0; i < bits_.size(); ++i)
result.bits_[i] = bits_[i] & other.bits_[i];
return result;
}
BitSet operator|(const BitSet& other) const {
check_size_match(other);
BitSet result(size_);
for (size_t i = 0; i < bits_.size(); ++i)
result.bits_[i] = bits_[i] | other.bits_[i];
return result;
}
BitSet operator^(const BitSet& other) const {
check_size_match(other);
BitSet result(size_);
for (size_t i = 0; i < bits_.size(); ++i)
result.bits_[i] = bits_[i] ^ other.bits_[i];
return result;
}
BitSet operator~() const {
BitSet result(size_);
for (size_t i = 0; i < bits_.size(); ++i)
result.bits_[i] = ~bits_[i];
// 注意:取反后,超出 size_ 的高位可能被置1,需要掩码清除
if (size_ % 64 != 0) {
size_t last = bits_.size() - 1;
size_t shift = 64 - (size_ % 64);
result.bits_[last] &= ~((1ULL << shift) - 1);
}
return result;
}
这里有个细节:~ 操作符取反后,最后一个 uint64_t 里超出实际位数的那些高位会被置1。如果不处理,后续做 & 运算时会得到错误结果。我刚开始写的时候漏了这一步,调试了半小时才发现。
注意:重载 ~ 时一定要考虑尾部掩码。否则你的位集合里会多出一些「幽灵位」,排查起来非常痛苦。
重载移位运算符:<< 和 >>
移位操作在位集合里意味着整体左移或右移。比如左移3位,所有位都往高位方向移动3格,低位补0。
实现起来比想象中复杂一点,因为要跨 uint64_t 边界搬运数据。
BitSet operator<<(size_t shift) const {
BitSet result(size_);
if (shift >= size_) return result; // 全部移出
size_t word_shift = shift / 64;
size_t bit_shift = shift % 64;
for (size_t i = 0; i < bits_.size(); ++i) {
uint64_t val = 0;
// 从源位置取数据
if (i >= word_shift)
val |= bits_[i - word_shift] << bit_shift;
if (bit_shift != 0 && i > word_shift)
val |= bits_[i - word_shift - 1] >> (64 - bit_shift);
result.bits_[i] = val;
}
// 清除尾部高位
if (size_ % 64 != 0) {
size_t last = bits_.size() - 1;
size_t shift = 64 - (size_ % 64);
result.bits_[last] &= ~((1ULL << shift) - 1);
}
return result;
}
BitSet operator>>(size_t shift) const {
BitSet result(size_);
if (shift >= size_) return result;
size_t word_shift = shift / 64;
size_t bit_shift = shift % 64;
for (size_t i = 0; i < bits_.size(); ++i) {
uint64_t val = 0;
if (i + word_shift < bits_.size())
val |= bits_[i + word_shift] >> bit_shift;
if (bit_shift != 0 && i + word_shift + 1 < bits_.size())
val |= bits_[i + word_shift + 1] << (64 - bit_shift);
result.bits_[i] = val;
}
return result;
}
这段代码的核心逻辑是:先算跨了几个 uint64_t(word_shift),再算内部偏移(bit_shift)。然后从源数组里「拼」出目标值。说白了就是手工模拟一个超大整数的移位。
个人经验:写移位操作时,最好画个图。把每个 uint64_t 看成一个小格子,箭头标清楚数据从哪流到哪。我每次写这种跨边界的位操作都会画一遍,省得脑子转不过来。
知识体系总览
下面这张图把本章的核心知识点串起来了。你可以看到,位集合的底层是数组,上层通过重载运算符提供类整数的操作体验。
完整示例:把位集合用起来
光说不练假把式。我们写个 main 函数,看看这些运算符到底好不好用。
#include <iostream>
int main() {
BitSet a(16), b(16);
a.set(0); a.set(2); a.set(4); // 二进制:...00010101
b.set(1); b.set(2); b.set(3); // 二进制:...00001110
BitSet c = a & b; // 只有第2位同时为1
BitSet d = a | b; // 第0~4位全为1
BitSet e = a ^ b; // 第0,1,3,4位为1
BitSet f = ~a; // 除了0,2,4位,其余全为1(注意高位掩码)
BitSet g = a << 3; // 左移3位,第3,5,7位为1
BitSet h = a >> 1; // 右移1位,第0,1,3位为1
std::cout << "a & b 的第2位: " << c.test(2) << "\n";
std::cout << "a | b 的第0位: " << d.test(0) << "\n";
std::cout << "a << 3 的第5位: " << g.test(5) << "\n";
return 0;
}
输出结果应该全是 1。如果你跑出来有 0,八成是尾部掩码没处理好。嗯,我当年第一次跑这个测试时,~a 的结果就多出来几个奇怪的1,排查了半天才发现是最后一个 uint64_t 的高位没清掉。
避坑指南
最后总结几个我踩过的坑,你写的时候留个心眼:
- 移位运算符的优先级:
a << 3 & b会被解析成(a << 3) & b还是a << (3 & b)?答案是前者。但为了代码可读性,我建议永远加括号。 - 复合赋值运算符:如果实现了
operator&,别忘了实现operator&=。用户会期望a &= b也能用。 - const 正确性:所有不修改对象的成员函数都要加
const。我见过太多人漏掉这个,导致无法在 const 对象上调用test()。 - 异常安全:如果
operator&里new分配内存失败,要保证不会泄露。用std::vector已经帮你处理好了,但如果你自己管理内存,一定要小心。
一句话总结:重载位运算符的核心是理解底层存储布局,然后让运算符的语义与内置类型保持一致。做到这步,你的 BitSet 用起来就会很顺手。
公众号:蓝海资料掘金营,微信 deep3321