Migrating to xstd-bits¶
A move to xstd-bits serves one or more of four goals, each with its own payoff:
- A class interface where none existed, over raw integers, enumerations and flags, gives type safety and expressiveness: the keys keep their own types, and the shifts and masks become named members.
- A focused interface in place of a hybrid one, where
std::bitsetandboost::dynamic_bitsetoffer every reading at once, gives focus: the type says which reading the code means. - A space-optimized container, in place of the standard's containers of
boolandstd::size_t, spends a bit per position. - A speed-optimized algorithm over those containers works a block at a time, which turns the space optimization into a time optimization too.
Where the code changes, a section shows how as a Tony Table, the before-and-after form WG21 papers use: what the code says today on the left, what it says here on the right. Every right-hand column compiles and runs against the library with GCC 15 and Clang 22, and so does every left-hand one except itsy_bitsy's, whose last release no longer builds with either.
A class interface where none existed¶
Code that keeps bits in an integer spells them as positions, enumerators or flags, and works them with shifts and masks; the class takes the keys the code already has, and the shifts and masks become its members:
- positions in a word:
bit_fixed_set<N>for a word of its own, orbit_array<N>where the word is a row ofbool;bit_set_viewfor one kept where it is. - an enumeration's keys:
bit_enum_set<Enum>. - a bitmask type's flags:
bit_flag_set<Mask, N>, for the standard library's [bitmask.types] and for other libraries' flags, such as Qt'sQFlagsand POSIX'ssa_flags.
A bitboard or a flag word¶
A word whose bits are positions, as a chess engine's bitboard or a flag word, becomes a bit_fixed_set<64>. The
shift-and-mask primitives become insert, erase and contains, std::popcount becomes size, and the
w &= w - 1 loop over std::countr_zero becomes a range-for, lowest position first. &, |, ^ and the shifts
stay as the word's: the same precondition, n < 64 for a shift, and the same instructions, so code that already
works on the word keeps working and keeps its speed; a & ~b is also a - b. A word read as a row of bool,
indexed, rotated or reversed, is a bit_array<64> instead.
std::bitset<64> was the standard's own answer, and bit-twiddling code did not take it, for five reasons this set
answers one by one:
constexpr. Nothing instd::bitsetwas until C++23's P2417R2; every member of the set is.- Iteration.
std::bitsethas no iterators, so a loop over the set bits needs libstdc++'s_Find_firstand_Find_next, which no other library has. The set is a bidirectional range, withfront()andback(). - Shifts.
std::bitsetdefines a shift byNor more as empty, a compare on every shift. The set's shifts are the word's, withn < Nas their precondition, andxstd::shlandxstd::shrare the total forms to opt into. - Single bits.
std::bitset'sset,reset,flipandtestwith a position throwout_of_range. The set's|=,^=,-=,&=and[]with a key are unchecked, as the word's shift-and-mask is, and a hardened build checks their precondition;insertchecks, andcontainsanderaseare total, for code that wants either. - Initialization.
std::bitset's default constructor zeroes every bit, and nothing skips it, though a word can be left unwritten until it is assigned. The set is empty by default, as a set must be, andbit_fixed_set<64>(xstd::indeterminate)writes nothing, for a field that is assigned before anything reads it;xstd::indeterminate_arraybuilds an array of them, as a per-ply state of bitboards holds.
| Before | After |
|---|---|
|
|
Kept where it is, in a struct or an ABI, the word takes the set's interface in place through a view.
| Before | After |
|---|---|
|
|
An enumeration's keys¶
bit_enum_set<Enum> is the set of an enumeration's enumerators, one bit each in the smallest unsigned type that holds
them, as Java's EnumSet is. An enumeration that ends in a count enumerator needs nothing more; another lists its
values in a specialization of xstd::enum_traits. The shifts by std::to_underlying go, and iteration yields the
keys in the enumeration's own type.
| Before | After |
|---|---|
|
|
Enumeration flags: std::filesystem::perms and other [bitmask.types]¶
bit_flag_set<Mask, N> is the set of the mask's one-bit values. It converts implicitly from and to the mask, so
the constants and the functions that take the mask stay as they are; what changes is the type of the variable.
Iteration yields the flags from the highest down.
| Before | After |
|---|---|
|
|
Qt's QFlags¶
QFlags<Enum> wraps an enumeration of one-bit values; bit_flag_set over the same enumeration replaces it, keyed on
the flags themselves. It takes the enumeration although Qt::AlignLeft | Qt::AlignTop is a QFlags rather than an
Qt::AlignmentFlag: a flag type accepts any enumeration whose | compiles, whatever it returns. Qt::AlignmentFlag
has no fixed underlying type, so its values are only those of the nine-bit field its enumerators need, and the width
is written out: with a wider one, ~a would form a value the enumeration cannot hold.
| Before | After |
|---|---|
|
|
Member by member:
QFlags |
Flag type |
|---|---|
testFlag(f), f one bit |
contains(f) |
testFlags(m) |
xstd::ranges::bit_includes(a, m), except that testFlags(0) asks whether a is empty, and bit_includes(a, 0) is always true |
testAnyFlags(m) |
xstd::ranges::bit_intersects(a, m) |
setFlag(f, on), f one bit |
on ? a.insert(f) : a.erase(f) |
setFlag(m, on), m several bits |
on ? a \|= m : a -= m |
toInt() |
the conversion to the mask, Qt::AlignmentFlag(a), or xstd::bit_convert<unsigned>(a) for the word |
An unscoped enumeration converts to bool, so if (a & Qt::AlignLeft) still reads as any-of, through the
conversion to the mask. A scoped enumeration does not, and its flag type then asks xstd::ranges::bit_intersects or
contains instead.
A signed word of unsigned flags: POSIX sa_flags¶
A flag type keeps a signed word's sign bit out of its universe, so a flag that sits there needs the word's unsigned
type. glibc's SA_RESETHAND is 0x80000000, an unsigned constant, stored in struct sigaction's int sa_flags.
The word is int, the flags are unsigned: key on unsigned, and cast once on each side of the field. Iteration
is then std::set<unsigned, std::greater<unsigned>>'s, SA_RESETHAND first.
| Before | After |
|---|---|
|
|
xstd::bit_flag_set<int>{SA_RESETHAND} does not compile on glibc: the constant narrows into int. A run-time value
with the sign bit set fails the flag type's assertion on the way in.
A focused interface in place of a hybrid one¶
std::bitset and boost::dynamic_bitset each offer three readings at once; here the type says which, at
std::bitset<N>'s fixed width or boost::dynamic_bitset's run-time one:
- a set of positions: membership, flags, iterating over what is set. That is
bit_fixed_set<N>orbit_set. - a sequence of
bool: a value at each index, rotating, a row of cells. That isbit_array<N>orbit_vector. - a row combined whole:
&,|,^,~and shifts over every element at once. That isstd::valarray<bool>'s reading,bit_fixed_valarray<N>orbit_valarray.
itsy_bitsy offers the sequence reading alone, and takes the same destinations.
std::bitset<N> as a set of positions¶
The positions become keys, and std::bitset's verbs become std::set's. The shifts are an integer's rather than
std::bitset's: b << n empties a bitset once n >= N, while s << n takes n < N as its precondition, as a shift
of a std::uint64_t does, and a hardened build checks it. Where the count can reach the width, xstd::shl(s, n) and
xstd::shr(s, n) are the total forms, which empty the set there and shift the other way for a negative count.
| Before | After |
|---|---|
|
|
|
|
|
|
std::bitset<N> as a row of bool¶
A row combined whole is a bit_fixed_valarray<N>, std::valarray<bool> packed: operator[] stays, and every
operator works element by element over two rows of one size, or a row and a bool. ~ is logical not, as ! is,
and the comparisons answer a row of bools, so b == c becomes (v == w).min(). The count and the queries are
xstd::ranges::bit_count, bit_all_of, bit_any_of and bit_none_of, or valarray's own sum, min and max; a
rotation is cshift. std::bitset's shifts stay, in its sense: v << n moves position i to i + n, which is
valarray's v.shift(-n). A row that is indexed and permuted rather than combined is a bit_array<N>, with
xstd::ranges::bit_rotate and xstd::ranges::bit_reverse.
| Before | After |
|---|---|
|
|
|
|
std::bitset<N> as a word¶
to_ullong and the unsigned long long constructor are the one-word case of xstd::bit_convert and the
from_blocks tag. A fixed width converts into any at least as wide, zero-extended, so N need not be the word's
width, and into a narrower one it does not compile.
| Before | After |
|---|---|
|
|
|
|
|
|
boost::dynamic_bitset¶
As a set, the run-time width grows to hold a key, where dynamic_bitset asks for a resize first and set(i) past
the size is out of range. As a sequence it is std::vector<bool>'s interface, and its bitwise operators are
bit_valarray's, one xstd::bit_convert away. The opt-in header converts between the two libraries, so a boundary
can stay Boost's while the code behind it moves.
| Before | After |
|---|---|
|
|
|
|
|
|
itsy_bitsy¶
bitsy::bit_sequence<Container> is the sequence reading over a container of words; here the storage is in the type's
name rather than its argument. bitsy::bit_view<Range> is bit_span, and the set reading has no itsy_bitsy
counterpart. The left column follows itsy_bitsy's README: its last release, of August 2022, no longer builds with
GCC 15 or Clang 22.
| Before | After |
|---|---|
|
|
|
|
|
|
A space-optimized container¶
std::array<bool, N>, std::inplace_vector<bool, N> and std::valarray<bool> spend a byte per position and
std::set<std::size_t> a node per key; bit_array<N>, bit_inplace_vector<N>, bit_valarray and bit_set spend a
bit. std::vector<bool> is packed already, and bit_vector is that packing under a vector's name. None of these
needs a section: the type changes and the code around it stays, as each answers its counterpart's synopsis
(README), with these exceptions:
[vector.bool]'sflip()and deprecated staticswapare left out, and so isarray's andinplace_vector'sdata(), which a packed bit has no address for.bit_valarrayhas no arithmetic, so+,*and the rest stop compiling. Two operators compile and change meaning:~is logical not, wherevalarray<bool>'s promotes and answers alltrue, and<<and>>move positions asstd::bitset's do, wherevalarray<bool>'s shift each element's value.
A speed-optimized algorithm¶
Packing pays in time only where the algorithms are adapted to it. Howard Hinnant measured as much in
On vector<bool> (2012): over 100,000 positions, a
word-at-a-time find, count, fill, copy, swap_ranges, rotate and equal ran from 1.7 times (rotate) to
about 75 times (find) as fast as over an array of bool, and the same algorithms a bit at a time up to 38 times as slow
(fill). xstd::ranges adapts each of them.
On a packed sequence a std::ranges algorithm still compiles, and visits one proxy at a time. Its counterpart in
xstd::ranges carries a bit_ prefix and works a block at a time, in O(N/D) for D bits per block, over any
sequence of the library's, a bit_span and its windows included. Over bool a predicate is the identity, so
bit_all_of, bit_any_of and bit_none_of drop it, and bit_count and bit_find take the value they look for.
| Before | After |
|---|---|
|
|