C++ std::bitset: Flags, Bit Masks, Subset Enumeration, and vector<bool> Compared
Key takeaways
This is a bitset guide that summarizes the basics of bit operations, bitset vs vector<bool>, masking, permutation, and combination patterns, and performance.
What is bitset?
std::bitset is an STL container representing a fixed-size bitset. It allows efficient storage and manipulation of data bit by bit, and is useful for flags, state management, bit masks, etc.
#include <bitset>
// Basic example
std::bitset<8> bits; // 8 bits
bits.set(0); // set bit 0 to 1
bits.set(3); // set bit 3 to 1
bits.reset(0); // set bit 0 back to 0
Why do you need it?:
- Memory efficiency: 1 bit = 1 bool (normal array is 1 byte)
- Bit operations: Intuitive operations such as AND, OR, XOR, NOT, etc.
- Type safety: compile-time size verification
- Convenience: Provides bit manipulation functions
// ❌ Plain array: 8 bytes
bool flags[8] = {false};
// ✅ bitset: 1 byte
std::bitset<8> flags; // 8 bits = 1 byte
A bool[8] array actually costs 8 bytes in practice — each bool element occupies a full byte, even though a single bit would suffice, because C++ has no addressable unit smaller than a byte. std::bitset<8> packs all 8 flags into a single byte by managing individual bits within shared storage internally, which is the whole reason it exists: this is an 8x memory reduction for this specific case, and the gap only widens as N grows — a bitset<64> is 8 bytes versus 64 bytes for an equivalent bool array.
Bit Indexing:
std::bitset<8> bits{"10101010"};
// 76543210 (indices)
std::cout << bits[0] << '\n'; // 0 (rightmost)
std::cout << bits[7] << '\n'; // 1 (leftmost)
// Note: the string reads left-to-right, but indices run right-to-left!
This reversed direction is the single most common source of off-by-one-style bugs with bitset: the string constructor reads left-to-right exactly as written, but operator[] indexes from the right (bit 0 is the least significant bit). Writing "10101010" puts a 1 in the leftmost character position, which ends up at index 7, not index 0 — reading the string as “index 7 down to index 0” rather than “index 0 up to index 7” avoids the confusion.
Constructing and Reading a bitset
#include <bitset>
// Construction
std::bitset<8> b1; // 00000000
std::bitset<8> b2{0b10101010}; // 10101010
std::bitset<8> b3{"10101010"}; // 10101010
// Access
bool bit0 = b2[0];
bool bit7 = b2[7];
// Output
std::cout << b2 << std::endl; // 10101010
All three construction forms — default, binary literal, and string — suit different sources of bit data. 0b10101010 is the natural choice when the value is a compile-time constant you’re writing directly; the string form is what you reach for when parsing bit patterns from external input (a config file, a serialized flag byte represented as text) since a std::string is easy to build at runtime, unlike a binary literal.
Flags, Bitwise Operations, Conversions, and Counting
Flags
#include <bitset>
enum Permission {
Read = 0,
Write = 1,
Execute = 2,
Delete = 3
};
class FilePermissions {
std::bitset<4> perms;
public:
void grant(Permission p) {
perms.set(p);
}
void revoke(Permission p) {
perms.reset(p);
}
bool has(Permission p) const {
return perms[p];
}
void print() const {
std::cout << "Read: " << perms[Read] << std::endl;
std::cout << "Write: " << perms[Write] << std::endl;
std::cout << "Execute: " << perms[Execute] << std::endl;
std::cout << "Delete: " << perms[Delete] << std::endl;
}
};
int main() {
FilePermissions fp;
fp.grant(Read);
fp.grant(Write);
if (fp.has(Read)) {
std::cout << "Read permission granted" << std::endl;
}
fp.print();
}
Using the Permission enum values directly as bitset positions (perms.set(p) where p is Read = 0) is what makes this readable — the alternative, tracking four separate bool members, scales badly past a handful of flags and loses the ability to do bulk operations like “clear all permissions” (perms.reset()) or “check if any permission is granted” (perms.any()) in one call instead of one per flag.
Bitwise operations
#include <bitset>
int main() {
std::bitset<8> b1{"11110000"};
std::bitset<8> b2{"10101010"};
// AND
auto and_result = b1 & b2;
std::cout << "AND: " << and_result << std::endl; // 10100000
// OR
auto or_result = b1 | b2;
std::cout << "OR: " << or_result << std::endl; // 11111010
// XOR
auto xor_result = b1 ^ b2;
std::cout << "XOR: " << xor_result << std::endl; // 01011010
// NOT
auto not_result = ~b1;
std::cout << "NOT: " << not_result << std::endl; // 00001111
}
std::bitset overloading these operators means the whole bit set is operated on in one instruction-level pass rather than a per-bit loop — the compiler lowers b1 & b2 for a bitset<8> (or bitset<64>) down to a single machine-word AND instruction, which is exactly the performance case this container exists for.
Transformation
#include <bitset>
std::bitset<8> bits{"10101010"};
// To integer
unsigned long ul = bits.to_ulong();
std::cout << "As integer: " << ul << std::endl; // 170
// To string
std::string str = bits.to_string();
std::cout << "As string: " << str << std::endl; // 10101010
// From integer
std::bitset<8> bits2{170};
to_ulong() throws std::overflow_error if the bitset’s value doesn’t fit in an unsigned long — a real concern for a bitset<64> on a platform where unsigned long is only 32 bits (common on Windows). to_ullong(), covered in the pitfalls section below, is the safer choice whenever the bitset might exceed 32 bits.
Count
#include <bitset>
std::bitset<8> bits{"10101010"};
// Number of set bits
std::cout << "Set bits: " << bits.count() << std::endl; // 4
// Size
std::cout << "Size: " << bits.size() << std::endl; // 8
// All set?
std::cout << "All set: " << bits.all() << std::endl; // false
// Any set?
std::cout << "Any set: " << bits.any() << std::endl; // true
// None set?
std::cout << "None set: " << bits.none() << std::endl; // false
count() is a thin, readable wrapper over what’s commonly called “population count” or “popcount” — on most modern compilers and CPUs, count() compiles down to a single hardware POPCNT instruction rather than a per-bit loop, which is why it’s the preferred way to count set bits over a hand-written loop calling test(i) for every index.
Bitwise Operators on bitset
std::bitset<8> bits;
// Set
bits.set(); // set all to 1
bits.set(3); // set bit 3 to 1
bits.set(3, 0); // set bit 3 to 0
// Reset
bits.reset(); // reset all to 0
bits.reset(3); // reset bit 3 to 0
// Flip
bits.flip(); // flip all bits
bits.flip(3); // flip bit 3
// Test
bool bit3 = bits.test(3);
test(pos) and operator[] look interchangeable but differ in one important way: test() does bounds checking and throws std::out_of_range for an invalid position, while operator[] (for a non-const bitset) is unchecked and is undefined behavior on an out-of-range index. Prefer test() when the position comes from external input you haven’t already validated; operator[] is fine, and slightly faster, when you control the index and know it’s in range.
What Each Bit Operator Is Used For
A summary of frequently used operations in integers or bitset is as follows.
| operations | meaning | Example Intuition |
|---|---|---|
AND & | Only positions where both are 1 | Extract only specific bits with mask |
OR | | If even one is 1, then 1 | Combine flags |
XOR ^ | If they are different, 1 | Toggle, Compare for equality |
NOT ~ | Everything is reversed | Flip entire mask |
<< / >> | Shift left/right | Movement corresponding to multiplication and division by 2^k |
std::bitset overloads the above operations for the entire bit set, so AND/OR/XOR/NOT can be performed at once without a loop. Unlike integers, operations between bitsets of different sizes must have the same size at compile time — there’s no implicit conversion between bitset<8> and bitset<16>, so mixing sizes is a compile error rather than a silent truncation.
std::bitset<8> a{"11001100"};
std::bitset<8> b{"10101010"};
std::bitset<8> low4 = a & std::bitset<8>{"00001111"}; // keep only the low 4 bits
bitset vs vector<bool>
Both aim for bit-wise compression, but their roles are different.
| Item | std::bitset<N> | std::vector<bool> |
|---|---|---|
| size | Compile-time constant N (template argument) | resize possible at runtime |
| Save location | Fixed size, usually inside a stack or object | dynamic allocation on heap |
| operations | Rich bit operations such as AND·OR·XOR·NOT and shift | Element-specific approach/focus on some algorithms |
| Standard Guarantee | complete bit set type | Due to the specialization of vector, details such as references are difficult |
What to use and when
- If N is fixed in the code (e.g. 64 bits for flags, 32 bits for IPv4 mask),
bitset<N>is type safe and the operation is clear. - If the length changes depending on user input or file size, consider alternatives such as
vector<bool>or boost::dynamic_bitset if the bit length is very large. - Note in the documentation that
vector<bool>is a “collection of bools” and not theoretically equivalent to the fullvector<T>(e.g.std::vector<bool>::referenceis a proxy object, not an actualbool&, which breaks generic code that expectsvector<T>::referenceto be a real reference).
Bit masking pattern
Turn on/off/toggle specific bits
unsigned x = 0b1010;
unsigned mask = 1u << 3; // bit 3
x |= mask; // turn on
x &= ~mask; // turn off
x ^= mask; // toggle
In bitset, you can do the same thing more readably with set(pos), reset(pos), and flip(pos).
Keep only low-order k bits: x & ((1u << k) - 1)
Extract specific section bits: Combine shift and AND.
std::bitset<16> v{"1111000011110000"};
auto low8 = (v & std::bitset<16>{"0000000011111111"}); // conceptually, the low 8 bits
Flag Combinations and Subset Enumeration
Flag combination
The pattern of putting multiple options in one integer or bitset and passing them as bitwise OR is common in API flags.
enum class Opt : unsigned { A = 1u << 0, B = 1u << 1, C = 1u << 2 };
unsigned f = static_cast<unsigned>(Opt::A) | static_cast<unsigned>(Opt::C);
bool hasB = (f & static_cast<unsigned>(Opt::B)) != 0;
Writing option indices directly with bitset makes the binary output clearer when debugging.
Subset enumeration (bit mask)
Any subset of the set with elements 0..n-1 can be traversed with a mask from 0 to 2^n - 1.
#include <bitset>
#include <iostream>
int main() {
const int n = 4;
// Watch for overflow of (1u << n) when n is large (keep it under the unsigned bit width)
for (unsigned mask = 0; mask < (1u << n); ++mask) {
std::bitset<n> bs(mask);
std::cout << bs << '\n';
}
}
When n is large, 2^n explodes, so use backtracking or next subset optimization (e.g. submask = (submask - 1) & mask) as appropriate.
Relationship between combination and permutation
- Combination: It is natural to use the position where the bit is 1 as the “selected element.”
- Permutation: Since the order is important, bits alone are not enough and are often used together with
next_permutation, etc. However, thebitset/uint32_tmask appears frequently in State Compression DP, which sets “use or not” as a bit.
When bitset Is Fast and When It Is Not
- Fixed size, hot loop: The single operation of
bitset<N>is good for the compiler to optimize word by word. If N is small, put it on the stack and avoid repeated allocations. - Count: You can find the number of 1s at once with
count(). When traversing specific bits, index loops andtest(i)are standard methods, and when very fast least significant bit traversal is required, integer masks and compiler built-in functions (e.g. GCC__builtin_ctzll) are reviewed to suit project policy. - I/O:
to_string()iterations can be expensive when printing in bulk. If it is not for debugging, consider handling it with integer bit operations or writing the buffer all at once. vector<bool>vs bitset: If you don’t really need dynamic sizes,bitsetis often simpler in layout and easier to optimize.
Size, Index, Conversion, and String-Order Problems
Size
// Compile-time size — fine
std::bitset<8> bits;
// ❌ Runtime size
// int n = 8;
// std::bitset<n> bits; // error — N must be a compile-time constant
// ✅ Runtime size: use vector<bool>
std::vector<bool> bits(n);
The error here isn’t a quality-of-implementation limitation — bitset<N>’s N is a non-type template parameter, so it has to be resolvable at compile time by definition, the same way an array’s compile-time size does. If the size genuinely isn’t known until runtime, bitset is the wrong tool regardless of how the code is structured; reach for vector<bool> or a dynamic bit-array library instead.
Index
std::bitset<8> bits{"10101010"};
// Index 0 is the rightmost bit
std::cout << bits[0] << std::endl; // 0 (rightmost)
std::cout << bits[7] << std::endl; // 1 (leftmost)
Worth repeating here because it’s the single most common mistake with bitset: the string constructor and operator[] read in opposite directions, as covered above. If you’re serializing a bitset to a string and parsing it back, be consistent about which end you consider “first,” and document it — the standard library itself doesn’t force a convention here.
Conversion
std::bitset<64> bits{0xFFFFFFFFFFFFFFFF};
// ❌ Overflow
// unsigned long ul = bits.to_ulong(); // throws on platforms where unsigned long is 32 bits
// ✅ unsigned long long
unsigned long long ull = bits.to_ullong();
This is a genuine portability trap: unsigned long is 64 bits on Linux/macOS but only 32 bits on 64-bit Windows (the LLP64 data model), so code calling to_ulong() on a bitset<64> can work fine in local testing on Linux and throw std::overflow_error the moment it runs on Windows. to_ullong() avoids the platform dependency entirely, since unsigned long long is guaranteed to be at least 64 bits everywhere.
String
// Constructing from a string
std::bitset<8> bits{"10101010"};
// ❌ Invalid character
// std::bitset<8> bits2{"102"}; // throws
// ✅ Handle invalid input
try {
std::bitset<8> bits3{"10201010"};
} catch (const std::invalid_argument&) {
std::cout << "Invalid character in bit string" << std::endl;
}
The string constructor is strict by design — any character other than '0' or '1' (by default; a custom character pair can be specified) throws std::invalid_argument rather than silently ignoring or truncating the bad input. That’s the right default for a type meant to represent exact bit patterns, but it does mean any code parsing bitset strings from untrusted input needs a try/catch around construction, as shown here.
State Machines, Set Operations, and Bit Field Compression
State Machine
enum class State {
Idle = 0,
Running = 1,
Paused = 2,
Error = 3
};
class StateMachine {
std::bitset<4> activeStates_;
public:
void enterState(State s) {
activeStates_.set(static_cast<int>(s));
}
void exitState(State s) {
activeStates_.reset(static_cast<int>(s));
}
bool isInState(State s) const {
return activeStates_[static_cast<int>(s)];
}
bool isIdle() const {
return activeStates_.none(); // all bits 0
}
};
// Usage
StateMachine sm;
sm.enterState(State::Running);
if (sm.isInState(State::Running)) {
std::cout << "Currently running\n";
}
Modeling state as a bitset rather than a single State enum value is a deliberate choice here — it allows multiple states to be active simultaneously (Running and Paused both set, for a pause-in-progress transition), which a plain enum variable can’t represent without an extra “compound state” value for every combination you care about.
Set operations
class CharSet {
std::bitset<256> chars_; // ASCII character set
public:
void add(char c) {
chars_.set(static_cast<unsigned char>(c));
}
bool contains(char c) const {
return chars_[static_cast<unsigned char>(c)];
}
CharSet operator&(const CharSet& other) const {
CharSet result;
result.chars_ = chars_ & other.chars_;
return result;
}
CharSet operator|(const CharSet& other) const {
CharSet result;
result.chars_ = chars_ | other.chars_;
return result;
}
size_t size() const {
return chars_.count();
}
};
// Usage
CharSet vowels;
vowels.add('a'); vowels.add('e'); vowels.add('i');
CharSet consonants;
consonants.add('b'); consonants.add('c');
auto intersection = vowels & consonants; // set intersection
auto union_set = vowels | consonants; // set union
The static_cast<unsigned char>(c) in add() and contains() isn’t decoration — char is signed on most platforms, so a character with its high bit set (any byte ≥ 0x80) would produce a negative int when used as an index without the cast, and indexing a bitset<256> with a negative value is undefined behavior. Casting through unsigned char first guarantees the index lands in [0, 255] regardless of whether char is signed on the target platform.
Bit field compression
class CompactData {
std::bitset<32> flags_;
public:
// bits 0-7: type (8 bits)
void setType(uint8_t type) {
for (int i = 0; i < 8; ++i) {
flags_[i] = (type >> i) & 1;
}
}
uint8_t getType() const {
uint8_t type = 0;
for (int i = 0; i < 8; ++i) {
if (flags_[i]) {
type |= (1 << i);
}
}
return type;
}
// bits 8-15: status flags
void setFlag(int index, bool value) {
flags_[8 + index] = value;
}
bool getFlag(int index) const {
return flags_[8 + index];
}
};
Packing a “type” byte and separate status flags into distinct bit ranges of one bitset<32> is the same idea C bit fields (struct { unsigned type : 8; unsigned flagA : 1; ...}) express with compiler-managed layout — the bitset version is more portable (bit-field layout is implementation-defined and varies by compiler/platform) at the cost of writing the bit-shifting logic by hand, as setType/getType do here.
FAQ
Q1: What is bitset?
A: An STL container representing a fixed-size set of bits. Data can be efficiently stored and manipulated bit by bit.
Q2: How is the size of the bitset determined?
A: Fixed as a template argument at compile time. The size cannot be changed at runtime.
std::bitset<8> bits; // fixed at 8 bits
Q3: What bit operations are supported?
A: Supports AND(&), OR(|), XOR(^), NOT(~), left shift (<<), and right shift (>>).
std::bitset<8> b1{"11110000"};
std::bitset<8> b2{"10101010"};
auto result = b1 & b2; // AND
Q4: Can I convert it to integer or string?
A: Yes. Use to_ulong(), to_ullong(), and to_string().
std::bitset<8> bits{"10101010"};
unsigned long ul = bits.to_ulong();
std::string str = bits.to_string();
Q5: What is the difference from vector<bool>?
A:
- bitset: fixed size, determined at compile time.
vector<bool>: Dynamic size, changeable at runtime.
std::bitset<8> bits; // fixed
std::vector<bool> vec(8); // dynamic
vec.resize(16); // OK
Q6: What is the memory size of bitset?
A: Number of bits divided by 8 (in bytes). For example, bitset<8> is 1 byte, bitset<32> is 4 bytes.
Q7: What is the bit index direction?
A: Index 0 is the rightmost bit.
std::bitset<8> bits{"10101010"};
// 76543210 (indices)
bits[0]; // 0 (rightmost)
bits[7]; // 1 (leftmost)
Q8: What are bitset learning resources?
A:
- “C++ Primer” by Lippman, Lajoie, Moo
- “Effective STL” by Scott Meyers
- cppreference.com - std::bitset
std::bitset is a fixed-size bit set that is memory efficient and allows for intuitive bitwise operations.
Related Articles
- C++ Algorithm Set | Set Algorithms Guide
- C++ std::atomic
- C++ Custom Allocators for STL Containers: Pool, Stack, Tracking Allocators and PMR
- C++ function objects
- C++ Bit Manipulation