Design notes¶
Why the code is shaped the way it is. The headers carry one line each; the reasoning lives here, and a
one-line comment ending in [design.md#anchor] points at the section that explains it.
Decisions still in flight live on the open issues, and #80 is the closed design plan the current shape came out of. This file holds what has landed, and, under Future directions, what is designed and waits on something.
Storage, readings, refinements¶
The library has two layers and one extension point.
Storage is blocks of unsigned integers, owned by bit_block_container. It knows widths, blocks and the
primitives every reading is built from — first_difference, any_above, any_block_set and the like —
and it compares only memberwise: it names no ordering, because the readings order the same bits
differently.
A reading is what the bits mean: a set of positions, or a sequence of bool. Each
is a tag type (set_reading_tag, sequence_reading_tag) and an adaptor over the
storage that decides what ==, <=>, iterators and operator[] mean, with the orderings themselves in
detail/comparisons.hpp as free functions over the storage's primitives.
A refinement extends a reading without touching it: a future string_reading_tag
(the-string-reading) derives from sequence_reading_tag, so every view and algorithm that accepts a sequence accepts a string, and
sequence_three_way serves both. The views check a reading with std::derived_from, which is what keeps
the set of readings open.
Each reading is held to the specification of the container it imitates, paragraph by paragraph: the
inventories in doc/audit/ and the check that runs them are that denominator.
Storage and containers¶
owned-bit-storage¶
The exposition-only owned_bit_blocks asks whether a type is bit storage a container can own: bit_blocks
(is-and-has) that is also regular, and read-only through a const object. For a range that is a
regular, sized, contiguous, subscriptable range of unsigned integers whose const subscript does not write.
Regular is what lets bit_block_container default its == over the width and the blocks, in that member
order, so two run-time widths part on the width before a block is read. std::array and std::vector both
qualify, and so does std::inplace_vector — a runtime width over static capacity, for free. A built-in array of
blocks, std::uint64_t[4], is bit storage and not owned bit storage: it is not regular, having neither copy
assignment nor an == over its blocks, so no owner holds one and no bit_block_container has it as Blocks. The tag
constructors, xstd::bit_convert and the views read it as they read the std::array of the same blocks.
bit_blocks is two cases joined. One block is the exposition-only bit_block: an xstd::unsigned_integer,
const or not, but never volatile (cv-transparency). The exposition-only bit_block_range is a
sized contiguous range of such blocks that subscripts, asking bit_block of the range's reference type, since
range_value_t drops the cv a std::span<std::uint32_t volatile> carries; xstd::bit_blocks is either, and is the
one of the three a user names. Code that treats the scalar and the range differently branches on
xstd::unsigned_integer, and bit_block_container asks bit_block_range of its Blocks, which is how a bare
block is refused at the constraint while contiguity is named rather than implied.
It is exposition-only because no storage joins the library from outside. The storage under every owner is
a bit_block_container over owned_bit_blocks Blocks, while the views take any bit_blocks. The split is
the one between owning and borrowing: std::span<B> is bit storage a view borrows, and it is neither regular --
two spans comparing equal would mean the same blocks, not the same bits -- nor read-only through const. Each
owner fixes its storage by its name, and none takes one as an argument: the grid's four columns, the array, the
inplace vector, the vector and the small vector, are the whole of what an owner holds. A user's own container of
blocks is read by a view over it, which asks only bit_blocks. So owned_bit_blocks and resizable_bit_blocks
state what the library's own storages provide, as the standard's exposition-only concepts state what its own
templates ask, and a user never meets them in a signature. Meyers' Effective STL, Item 2, "Beware the illusion of
container-independent code", is the reason not to open it: the storages already part on what growth past capacity
throws, on whether they are constant-evaluable (only std::inplace_vector), on whether a copy is noexcept (not
boost::container::static_vector's) and on what a capacity of nought costs, and an owner generic over them all could
promise only what they share.
A run-time width asks one thing more. resizable_bit_blocks refines owned_bit_blocks with what
the vehicle calls to change its block count: resize(count, value), push_back, insert at the end, clear
and max_size. The storage requires it only at N == std::dynamic_extent, so std::array<B, K> still serves a
fixed width while bit_block_container<std::array<B, K>, std::dynamic_extent> is refused at the constraint
rather than inside resize. The test that the contract is the whole of what a storage has to provide
is test::minimal_blocks: a storage written outside the library with exactly those members and no others, which
runs the set reading's full primitive suite through the set adaptor over it. boost::container::static_vector
satisfies both concepts as it comes, and it holds the inplace column's blocks wherever std::inplace_vector is
missing, so it runs under coverage. Boost.Container force-inlines an asserting operator[], which at -O0 would
put a branch no passing test takes on every subscript in the vehicle; the test tree defines
BOOST_CONTAINER_DISABLE_FORCEINLINE, which leaves the call, and its branch, in Boost's header.
Whether growth throws is the storage's own answer. std::vector::resize can throw std::bad_alloc, and a
storage with an inline capacity throws past it: std::inplace_vector a std::bad_alloc, and
boost::container::static_vector a boost::container::bad_alloc, which derives from std::exception and not from
std::bad_alloc unless BOOST_CONTAINER_USE_STD_EXCEPTIONS is defined. The owners ask neither past its capacity:
they refuse first with std::bad_alloc, as std::inplace_vector specifies, so a caller catches one type over
both. The tests build without the macro, so a Boost exception that escapes an owner fails the test that expects the
standard one; a leg that defined it would turn that failure into a pass. Growth keeps whatever guarantee the
storage's own resize gives, because the width moves only after the storage has grown: over any of the standard or
Boost vectors, whose resize on unsigned blocks either completes or changes nothing, a failed growth leaves the
owner as it was. A storage whose resize is noexcept never takes that path.
The element clause is xstd::unsigned_integer, and not the wider bit_mask, which would be the concept
if the operators were all a block is asked for. They are not. Beyond them the body wants the <bit> intrinsics
— popcount, countr_zero and countl_zero, each constrained on xstd::unsigned_integer in
detail/intrinsic.hpp and reached at some thirty sites — a numeric_limits<block_type>::digits for
bits_per_block, and block arithmetic: shl(unit, count) - unit, and the block & (block - 1) step the set
reading's block walk takes, where bit_mask omits - deliberately, subtraction and set difference
being indistinguishable to a concept.
The two agree on every block the library ships, and both refuse bool, the character types and every signed
type, so std::vector<int> stays out either way. They part on the class types that are fields of bits without
being numbers, std::bitset among them: bit_mask admits those and this concept does not, which is
the point. std::array<std::bitset<64>, 4> is asserted refused, a ready-made negative case
(concepts-are-tested-on-ready-made-types) for exactly the gap
between the two spellings. Widening the clause would move that refusal from an unsatisfied constraint to a hard
error inside the template — the failure mode the subscript clause below exists to prevent, and there is no
reason to accept it on the element clause when the narrower concept states the truth.
The asymmetry it records is the layering, not an accident. unsigned_integer goes in and the container
gives the common vocabulary (the-common-vocabulary) — and, once the non-assigning operators land, bit_mask — out: it
asks more of a block than it offers its own user, consuming numbers and yielding a field of bits, shedding the
arithmetic on the way up. That is also why nesting cannot work: a bit_block_container will have every
operator and still no popcount, no digits and no - 1.
The concept has a header of its own under <xstd/bits/bit_concepts/>, beside bit_blocks, which it refines: the
one says what a Blocks is, the container is the vehicle built over it, and a reader asking the first question
need not open the 1500 lines answering the second. Between them those headers include <concepts>, <ranges>, the
one xstd-ints concept and the alias of the-const-reference. borrowed_block_span, the span a view writes through, is
left in detail/borrowed_block_span.hpp. num_blocks_v stays with the vehicle, being a block-count computation
rather than a statement about what a Blocks is.
Subscript is spelled out rather than left to std::ranges::contiguous_range, which does not imply it.
contiguous_range gives data() and a contiguous_iterator, and a contiguous_iterator is a
random_access_iterator, so i[n] is required — of the iterator. The range itself is under no such
obligation: a plain buffer wrapper can satisfy the other four requirements and have an iterator that subscripts
happily while c[n] does not compile. No ready-made type isolates that gap — every std candidate that fails
this concept fails for some other clause first (span and subrange are not regular, string_view's and
vector<int>'s elements are not unsigned integers, vector<bool> is not contiguous) — and the gap is not worth
a class written only to be
asked about (concepts-are-tested-on-ready-made-types), so it is
stated here from [range.refinements] rather than pinned by a test. bit_block_container reaches for the
range's subscript in 140 places, block_mask among them, so without this the concept admits storages the class
cannot be instantiated over — the shortfall surfacing as a hard error inside the template rather than as an
unsatisfied constraint, which is the failure mode
a-requires-clause-names-its-arguments exists to prevent.
The mutable and the const subscript are two requires-expressions with a parameter list each, not one expression
over C&, C const& and an index together. They are separate requirements — a storage can offer one and not
the other — and each list then names exactly what its own requirement uses, c and n, rather than a shared
r/c pair in which half the names are dead in half the expressions. The index is n like every position in
this library, and its type is C::size_type rather than a bare std::size_t: a contiguous block container
is a container, a[n] is how [sequence.reqmts] spells the operation, and size_type is the name a container
gives the index. std::array, std::vector and std::inplace_vector each name it, so the clause costs the
three storages nothing and asks anything else for the typedef a container has. It is written without a
typename, P0634 making one implicit in a requirement-parameter-list and readability-redundant-typename
reporting it where it is written — which is how the first CI run of #143 failed. The quoted GCC diagnostic
below echoes a typename all the same: that is GCC's printing of the parameter, not what the header says.
Diagnostics were measured, not assumed, and they are almost the same either way: given a storage with the
mutable subscript alone, both forms point at the failing requirement, and Clang's note is identical. GCC's
differs only in the parameter list it echoes, remeasured at the current spelling: in requirements with 'C& r',
'const C& c', 'typename C::size_type n' joined, against in requirements with 'const C& c', 'typename
C::size_type n' split. A narrower echo, not a better error. The split is for the reader.
The requirement is written against the iterator's own reference type, range_reference_t<C>, rather than
against range_value_t<C>&, because the point is not that subscript yields a reference but that it yields
the same one iteration does. The rest of that is semantic and no concept can check it, so it is stated
here as the standard states it for random_access_iterator's i[n]:
c[i]is*(std::ranges::begin(c) + i)
and asserted in the tests, by address rather than by value, for every storage shipped. An unchecked semantic requirement that no test pins is a comment.
Naming it a block container follows from the same fact. A contiguous container of blocks generalizes the C array
of blocks, and its operator[], the C array's defining operation, yields a block; a concept claiming a range is blocks while unable to index one would be
describing something else. The word is deliberately close to the standard's contiguous container
([container.reqmts]/68) without claiming it: that term drags in the whole Container table — empty(),
max_size(), cbegin/cend, member swap, seven nested typedefs — and bit_block_container needs
almost none of it. At a static width it needs none; at a run-time width it needs max_size, resize,
push_back and clear, which are sequence container operations, not Container ones. Neither path draws the
line where [container.reqmts]/68 draws it, so the concept states its own five requirements and borrows nothing.
The name says block container, not storage: what an adaptor sits on is a different question, asked by
bit_block_container_type and answered by name rather than by members
(one-storage).
cv-transparency¶
Every public concept and type trait answers for T const, T volatile and T const volatile as it does for T,
as xstd-ints' do (integer types),
with one exception, below, for storage a block algorithm reads or writes.
- Concepts.
bit_index_mapping,sized_bit_index_mappingandbit_mask_mappingaskfrom_indexandfrom_blockfor the cv-stripped key, so aconstkey maps as the bare one.bit_convertible_to<From, To>holds for a cvToexactly when it does forTo. - Type transformations.
bit_rebind,bit_resize,bit_least,bit_fast,bit_alignandbit_underlyingtransform a cv owner as the bare one and qualify the result alike, asxstd::make_unsigned_t<int const>isunsigned const. The block argument is held by value, so its cv is dropped:bit_rebind<std::uint8_t const, O>isbit_rebind<std::uint8_t, O>. - Values.
bit_blocks_extent_vnames the bare storage's width, so astd::span<std::uint32_t, 2> constis 64 bits wide, as the span is. - Functions.
bit_convert<To>returnsstd::remove_cv_t<To>, as a cast toint constyields anintprvalue; aconstclass return would only stop the result from being moved.
Volatile is the exception, where the order of accesses is the point. A volatile object promises that each read and write the source spells is one access in the program, in order. A memory-mapped status or control register needs exactly that, and every block algorithm here breaks it: they copy whole arrays, merge adjacent reads into one wider load, vectorize, and read a block twice where once would do. So:
bit_blocksadmits a const block or range and never a volatile one:std::uint32_t volatile,std::span<std::uint32_t volatile>andstd::uint32_t volatile[2]are no bit storage, and no view deduces over them.- An owner or view that is itself volatile is no owner or view. Every member taking an explicit object parameter
constrains it against volatile, so
std::ranges::range<xstd::bit_array<64> volatile&>is false, as it is for a volatilestd::vector, rather than a hard error insidebegin. - An owner's block is cv-unqualified, as a standard container's element type must be:
basic_bit_array<std::uint32_t const, 32>is refused at its own constraint, asstd::vector<int const>is ill-formed. - A volatile scalar is one access, so it is a source:
xstd::bit_convert<xstd::bit_array<32>>(status)reads the register once and converts the value it held, andstatus = xstd::bit_convert<std::uint32_t>(flags)writes it back in one store. A volatile array, owner orstd::bitsetwould be read piece by piece, and is refused.
the-const-reference¶
The const subscript is checked against range_const_reference_t: the standard's where the library has it, and
where it does not, ours — P2278R4's two aliases transcribed from the standard, in a header of their own:
template<std::indirectly_readable It>
using iter_const_reference_t = std::common_reference_t<std::iter_value_t<It> const&&, std::iter_reference_t<It>>;
template<std::ranges::range R>
using range_const_reference_t = iter_const_reference_t<std::ranges::iterator_t<R>>;
That is [const.iterators.alias] and [ranges.syn] verbatim, down to the constraints, and it is ten lines because the paper defines the alias by a formula rather than by magic.
One header, and the fallback namespace is what makes the conditional safe. The transcription above sits
there unconditionally, and below it detail/range_const_reference.hpp selects
std::ranges::range_const_reference_t where __cpp_lib_ranges_as_const says the library has the paper and
fallback::range_const_reference_t where it does not. Nothing shadows anything: on a library that ships P2278
both spellings exist and are separately reachable, which is exactly what lets the tests below check one against
the other. What the check needs is that the fallback be separately nameable, which is the namespace; a second
file bought nothing and is not there.
The reason not to use std::ranges::range_const_reference_t is that a third of the matrix does not have it.
libc++ has implemented P2278R4 on no branch, trunk included: __cpp_lib_ranges_as_const is still a
commented-out line in its <version>, beside __cpp_lib_ranges_chunk and unlike the chunk_by defined next to
it, so the commenting tracks implementation rather than being a stale block. The headers are simply absent —
range_const_reference_t occurs nowhere in __ranges/concepts.h, and __ranges/as_const_view.h and
__ranges/const_access.h do not exist. Apple's toolchain is downstream of libc++, so no Xcode will have it
first, and this is not a wait-for-the-next-rung situation: naming the standard alias directly fails on Xcode
16.4.
A conditional is only as good as its other arm, and that is the whole argument for the transcription. The
obvious arms, std::ranges::range_const_reference_t and range_reference_t<C const>, ask two different
questions: P2278 asks what C's own iterator yields once const-ified, the second what a
C const iterates. Measured, they part on exactly one thing:
| storage | range_const_reference_t<C> |
range_reference_t<C const> |
|---|---|---|
vector<Block>, array<Block, N>, inplace_vector<Block, N> |
Block const& |
Block const& |
| a regular, span-like handle | Block const& |
Block& |
Within contiguous_range that makes P2278 strictly the tighter arm, and the one thing it catches is a
shallow-const blocks type: one whose c[n] hands back a writable Block& through a C const&, which would
let a bit_block_container const be written through. That conditional would therefore have admitted such a
storage on libc++ and refused it everywhere else — a concept meaning two things on two thirds of the matrix.
Transcribing does not remove the conditional; it makes the other arm the same type rather than a second
contract, which is the only reason a conditional is the right shape here at all.
Two spellings were considered and not taken. range_value_t<C> const& is what the alias collapses to for a
contiguous range, needs nothing, and was measured equal on every storage here — but it states a coincidence of
this concept's other clauses rather than the requirement, and would be wrong the moment the alias were reused
anywhere that is not contiguous. A conditional_t over is_const/add_const reconstructs "const-ify the
reference" by hand, and gets proxies wrong: measured on std::vector<bool>, the dance says bool const& where
the paper says bool, because common_reference_t consults basic_common_reference and add_const_t cannot.
Both are lookalikes; the formula is the thing.
What pins it is TheConstReferenceIsP2278s. Where the library does have the paper — the gcc, msvc and
clang-with-libstdc++ rungs — the fallback must equal std::ranges::range_const_reference_t, asserted on both
storages and on vector<bool>, so the transcription is checked against three vendors rather than against my
reading of the wording. That check is the reason the fallback is namespaced apart instead of written inline in
the #else: were it only the unselected arm, the rungs that could verify it would never name it, and the rung
that names it could not verify it. The rest holds everywhere, whichever arm was taken: the reference is const for
each storage shipped, and the fallback gives bool for vector<bool>, which is the assertion that fails first
if anyone ever "simplifies" the definition into the dance.
the-one-vehicle¶
bit_block_container lives in detail/bit_block_container.hpp, with the concept, num_blocks_v and
the detector that names it. It holds primitives only: each reading's equality and orderings are free functions
over them in detail/comparisons.hpp (the-ordering-primitive). It has no per-storage aliases: each owner spells its storage once, in its base clause --
basic_bit_array<Block, N> derives from the sequence adaptor over
bit_block_container<std::array<Block, num_blocks_v<Block, N>>, N>.
The name is in xstd::bits::detail with the rest of detail/, so nothing outside the library names it; it is the
device that turns two readings over three storages into two plus three, and a factoring device is machinery
rather than vocabulary. A user reaches every width through bit_fixed_set<N> or basic_bit_array<Block, N>, and a
test that needs the container itself spells its storage, as bit_block_container<std::vector<std::uint8_t>>.
Each public header includes only the storage it names, so bit_array does not see std::vector, and the
choice between std::inplace_vector and boost::container::static_vector sits in detail/inplace_blocks.hpp,
which only the two inplace headers include.
The name says what it does to its argument. It takes a owned_bit_blocks — a range that is
blocks (owned-bit-storage) — and adds the bit interface. In goes
storage that answers about blocks, out comes something that answers about bits. Concept and class differ by one
word, and it is the word that changes.
That is also what earns it the name the adaptors are constrained on. It is the one storage they admit
(one-storage), the only one there is: std::bitset and boost::dynamic_bitset are comparison
targets rather than adapted storages (owning-is-ours). It lives under detail/ because nobody
outside spells it (the-interface-line). What the three have in common as members is
the-common-vocabulary. A name such as block_sequence would hide that: a sequence of
blocks reads as storage that happens to have been adapted, where a bit container reads as the thing the
readings are built on.
bit_block_container<Blocks, N> is the single storage vehicle. N is the width when that is a constant
and std::dynamic_extent when the width is carried at run time.
It owns the unused-tail invariant — every bit at or above size() reads zero — which is what makes
whole-block comparison, popcount and the block-at-a-time scans mean anything at all.
It has no iterators and no proxies. Those are readings, and a reading here would be picking one; it would
also make std::ranges see a sequence of blocks rather than of bits.
The hand-unrolled one- and two-block cases are where the static performance lives, so they stay
compile-time branches. The general path they fall through to is written over m_blocks as a range, and
therefore serves the dynamic width unchanged.
concepts-are-tested-on-ready-made-types¶
Concept tests name std::array, std::vector and std::inplace_vector and nothing else. A class written only
to be asked about proves what its author put in it: it is the concept restated in class syntax, it passes by
construction, and it goes stale silently when the concept moves. The three std containers are the storages this
library is actually instantiated over, so an assertion about them is an assertion about the library.
What that gives up is the negative cases a shim isolates one clause at a time, and here three of the four
survive on ready-made types alone: std::vector<bool> is not contiguous, std::vector<int> is signed, and
std::array<std::bitset<64>, 4> is the field of bits that is not a number — the one that separates
unsigned_integer from bit_mask (owned-bit-storage). Only the
range-subscript clause has no ready-made counterexample, and it is argued in prose there instead.
counting_blocks in test/src/bits/detail/bit_block_container.cpp is not an exception to this. It is a
swap-and-move fixture with instrumented operations (swap-goes-through-adl), which no
std container can be, and it carries no concept assertion of its own: it is instantiated as a
bit_block_container's Blocks, and that instantiation is the only check it needs to satisfy.
the-common-vocabulary¶
test::bitset::vocabulary, in test/include/test/bitset/vocabulary.hpp, is what the three bit containers answer
in their own names, with no trait in
between: the intersection of std::bitset's vocabulary, boost::dynamic_bitset's and
bit_block_container's. Measured against all three rather than guessed, and larger than it first looks —
nineteen requirements:
std::regular |
copy, move, == |
| observers | size, count, test(n), all, any, none |
| mutators | set(), set(n), reset(), reset(n), flip(), flip(n) |
| bitwise | &=, \|=, ^=, <<=, >>= |
What falls outside is what makes it an intersection. Each of these is absent from at least one of the three, so asking for it would drop a model:
| absent from | |
|---|---|
bit_block_container |
a subscript to a bit, its operator[] being a block's (test-not-subscript), unary ~, set(n, value) |
std::bitset |
-=, is_subset_of, find_first, member swap |
boost::dynamic_bitset |
to_string |
The asymmetry is the point, and it runs in two directions at once. bit_block_container provides the
union of what the two readings ask of it — a set reading needs find_first and find_next, a sequence
reading needs positional writes — while the concept asks only what the three containers have in
common. Union below, intersection across.
Nothing is constrained on it, and that is deliberate. The adaptors admit their storage nominally, by
bit_block_container_type (one-storage), and the structural question is a
different question: std::bitset and boost::dynamic_bitset both model the common vocabulary and neither
is a storage this library wraps. Constraining an adaptor on the concept would turn this is not one of ours
into your type lacks count(), which is the wrong diagnosis about the wrong type.
The concept is therefore a description, pinned by the tests, not a gate. It says what the three have in common and fails loudly if one of them drifts.
the-primitive-basis¶
Two layers, and the lower one is the whole design. bit_block_container provides the primitives — the
operations a reading needs to be efficient rather than merely correct — and each adaptor assembles them into
the standard-like API its reading presents. No third layer sits between them, and one-storage
says why.
The primitives are the point, and the reason is measurable: they are exactly what the counterparts do not have. What each provides natively, on libstdc++:
| primitive | bit_block_container |
std::bitset<N> |
boost::dynamic_bitset |
|---|---|---|---|
| block read | yes | only N <= 64, through to_ullong |
no |
find_first |
yes | _Find_first, where the library has it |
yes |
find_next |
yes | _Find_next, where the library has it |
yes |
find_prev |
yes | no | no |
count, all, any, none |
yes | yes | yes |
std::bitset and boost::dynamic_bitset are an incomplete basis: enough to implement the full views API
correctly, never enough to implement it efficiently. Neither walks backwards, and boost hands over no blocks at
all, so a view over one would have to walk in reverse a position at a time — quadratic over a full
traversal, where ours reads a block at a time (one-function-per-tier). The two
reserved names are not portable either: _Find_first and _Find_next are libstdc++ and MSVC extensions,
absent on libc++ (the-two-reserved-names).
The shortfall is not an oversight. std::bitset was designed to extend the bitwise operators to an
arbitrary fixed width: it is a wide unsigned integer with per-bit accessors, not a container.
[template.bitset]'s synopsis is that and nothing besides — &=, |=, ^=, <<=, >>=, ~, set, reset,
flip, test, count, size, all, any, none, operator[], and the conversions to and from strings and
integers. No begin, no end, no search. So find_first, find_next and find_prev were never missing
from std::bitset; they were never in scope. That also explains why _Find_first and _Find_next are
libstdc++ and MSVC extensions rather than standard (the-two-reserved-names): the
implementations added what the standard had no reason to ask for.
The set and sequence readings ask a wide integer questions it was never meant to answer, and answering them efficiently is what the storage below is for.
So the conclusion the table argues for is not a shim over three spellings of one thing. It is one storage
that is a complete basis, and no counterpart is adapted as a storage. Adapting one would mean hand-writing
find_first and find_next over whatever it offers, num_blocks and block behind a
requires (N <= ullong_digits) guard, and no find_prev at all, since neither counterpart has one to forward
to. The counterparts are comparison targets instead (owning-is-ours), and with no second basis
to reconcile, no layer reconciles one (one-storage).
the-bytes-they-agree-on¶
A comparison target can become a value the set reading converts to and from, and at a static width it is an
exact one. A field of N bits has the same N, and under this reading that width is a capacity, so position
n here is bit n there with nothing left to choose: nothing truncates, nothing grows, nothing throws, and the
round trip is the identity both ways. bit_fixed_set<N> therefore reads blocks that are bit storage through
the tag constructor bit_fixed_set<N>(xstd::from_blocks, b), and hands a field back, as it crosses with
anything else that has bit storage, through the free function xstd::bit_convert (is-and-has). Named in both directions, and not because either could fail — a set of positions
and a field of bits are two readings of the same bits, and this library makes a reader pick one rather than
letting a conversion pick for them.
An explicit constructor and an explicit conversion operator are the wrong tool for the door. explicit
guards against a conversion nobody asked for; the failure available here is a reader misreading one that was
asked for. Admitting a sequence of blocks makes that concrete, because a contiguous range of unsigned integers
already means something at this reading:
auto const blocks = std::array<std::uint64_t, 4>{ 5, 0, 0, 0 };
bit_fixed_set<256>(std::from_range, blocks) // {0, 5} — the values are keys
bit_fixed_set<256>(blocks) // {0, 2} — the values are blocks
Behind an explicit constructor both compile, both succeed, and they disagree, a tag being the whole of what
separates them. A name is what tells them apart at the call site, where the reader is. std::bitset spells its own
exit to_ullong for the same reason and offers no conversion operator at all, and Boost.dynamic_bitset spells this
pair from_block_range and to_block_range. Here the way in is the tag, which bit_block_container spells
assign_bits one layer down, and the way out is xstd::bit_convert, one free function for every pair of widths, so
no reading carries a member for it.
Neither is spelled with a type. std::bitset appears nowhere in the set adaptor: the two are templates
constrained on bits::detail::bit_layout<B, N>, which admits two families: one whose layout the language states, and
one read on the terms std::bit_cast reads any object.
| family | admitted because | what it costs |
|---|---|---|
fixed blocks (fixed_blocks_source) |
bit n of a block is 2^n, by the language |
nothing: no assumption |
field of bits (container_source) |
trivially copyable, little-endian, room for N bits |
the caller's word that its bytes are its bits |
unsigned long long is therefore not a special case in the header but an instance of the first family, which is
what to_ullong and the constructor taking one reduce to. std::bitset<N> is an instance of the second, and
crosses to bit_fixed_set<N> with neither side named in the constraint. Its width is the N of its type, and no
member of it is called to find one.
The stated family is one concept over a block or a fixed number of them, and it is what makes raw blocks
convertible. An unsigned integer states its own layout; a contiguous sequence of unsigned integer blocks states
the rest of it, block j holding the positions [j·digits, (j+1)·digits). A scalar is the sequence of length one,
which is why fixed_blocks_source is one concept: fixed_bit_blocks whose width covers N and that a value owns.
A scalar crosses as the range of one block it is, so one copy serves both.
That copy assigns every block, so the container starts from unwritten blocks and zeroes none of them first. It is
a fold over the K block indices, not std::ranges::copy and not a loop. GCC sizes a function for inlining early,
before calls and loops are folded away: a copy through a span takes the source's address, which puts it on the stack,
and a loop indexes it at run time. Either made Bitboard(from_blocks, x) look like a call where it is one move, and
pushed a chess engine's attack generator past GCC's inlining limits. The fold's constant indices give hand-written
code's instructions at one, two and six blocks, the widths of chess, Othello and shogi, and Go. Everything in it is read by shifts on values, never by std::bit_cast on an
object, so no padding is reachable and endianness never enters: b[j] >> k is the same number on either byte
order. Only a foreign field of bits, whose internals this library cannot name, is read as an object.
So std::array<uint64_t, 4> crosses, and so does std::array<uint32_t, 8> over the same two hundred and
fifty-six positions, because the byte is the common ground where the block is not. The width must be named by the
type — xstd::bit_blocks_extent_v answers it for a block, a std::array and a built-in array, and
std::dynamic_extent for a vector — because "nothing truncates" is a promise made at compile time, and a run-time
size cannot keep it. It must cover N but need not equal it, at one block as at many. A type with no
bit_blocks_extent_v is not in this family; it crosses as a field of bits or not at all.
A built-in array of blocks is in the family as a source only. It is read and never owned, and its blocks are the
bits of the std::array of the same blocks, so it is read through the same shifts and the same copy. No function
returns one, so it is never a target: bit_convert writes into a std::array and not into a std::uint64_t[4].
Asking fixed_bit_blocks before owned_bit_blocks is load-bearing rather than tidy. That concept asks
std::regular, which asks constructible_from, which re-enters the very constructor whose constraint this
is — a concept depending on itself. fixed_bit_blocks asks bit_blocks first, and an adaptor's iterator is a proxy
and so never contiguous, so the cheap question answers false for every reading here before the recursive one is put.
One spelling repays reading twice, and it reads differently at each reading — which is the point, and the trap.
bit_fixed_set<32>(xstd::from_blocks, 5u) is the set of positions the value five has, {0, 2}, not the set {5}.
bit_array<32>(xstd::from_blocks, 5u) is a packed array of bool, so it is true, false, true and twenty-nine more
false. Same bits, two vocabularies. The name is what carries the distinction: each is asked for by a
spelling that says which reading of the argument is meant, which is exactly what explicit could not do.
The currency is bytes, not blocks. Byte j holds the positions [8j, 8j + 8) least significant bit first,
which is what every contiguous bit container lays them out as whatever its block width, so two widths over the
same positions agree byte for byte. That is what makes this a copy rather than a walk over positions, and one
function is the primitive: copy_bits, blocks to blocks at any two block widths, a run of bytes being blocks of
unsigned char. The storage's assign_bytes and to_bytes, the two families' bit_bytes and bytes_bits,
bit_convert's copy between owners and boost::dynamic_bitset's gather all go through it or its two byte
accessors, and the hash reads the same bytes through the same accessor.
It is said twice, and the reason is measured rather than tasteful. Said in shifts, they name where a
position goes instead of assuming a byte order, so they are right on either endianness and they are a constant
expression — neither of which a memcpy is. They are also a byte at a time, and over the eight kilobytes of
2^16 positions that costs 9.70µs against 0.07µs, a factor of about a hundred and forty-five. So the shifts
keep the two cases a copy cannot take, a constant expression and a big-endian target, and the copy takes the
case that is neither, which is every rung this ladder runs. if consteval picks the first,
if constexpr (endian::native == endian::little) the second; neither is a branch a coverage slot can miss,
because neither is a branch at run time.
That pair of cases is worth taking one branch at a time, because three of them are not the same question.
A scalar is the range of one block and takes whatever a range takes, and that is measured rather than
assumed: a value is at most a handful of bytes, the loop unrolls, and memcpy timed identically at every width
— 0.31ns either way on uint8, uint32 and uint64. A branch of its own would buy nothing.
A sequence of blocks takes the copy only where the bytes of a value are the bytes of the field, which is a
little-endian target whose block type has no padding: the shifts count by digits where a copy counts by
sizeof, and those agree only when every bit of the object is a value bit. No standard type has such padding,
and the test is there so that one could not quietly turn a copy into the wrong answer. The block must also be a
built-in: a class such as absl::uint128 or boost::int128::uint128 lays out its value as its author chose,
which no rule of the language makes its bytes, so it crosses by shifts. That is one predicate,
block_copies_as_bytes, asked by every copy in the library, the hash's included, so no two of them can disagree
on when a copy is the answer. Its shape is load-bearing
too: written as if !consteval with a return, the shifts below it become unreachable code in a run-time
instantiation, which MSVC reports as C4702 and this build treats as an error. Two alternatives, so neither arm
is dead.
The blocks are value-initialised first, which is what clears the tail above N and so makes
set → blocks → set the identity at a width the sequence is wider than. The other direction is not the identity
and is not meant to be, exactly as it already is not for an integer too wide for the width.
A zero width exchanges no byte, and so reads none. std::bitset<0> occupies a byte that represents no
position, so a std::bit_cast of it reads an uninitialised one and is no constant expression; the way back casts an
object of zero bytes, which reads nothing of the source either. The guard is byte_count<N> rather than N,
those being zero together and byte_count being what the two conversions actually range over.
That factor is what decides a question this design keeps inviting: whether a foreign bitset should be read
block-wise in place rather than converted. In place is not portably possible — std::bit_cast yields a copy, so
reading someone else's words needs a pointer into them, which is _M_p, __seg_ or _Myptr by turns. It also
turns out not to be worth wanting. Iterating the set positions of a std::bitset<2^16> at 2.7% density costs
41.1µs asking every position, against libstdc++'s own in-place _Find_first/_Find_next and
converting once and then iterating, which come out level: five runs put their ratio at 1.06, 0.94, 0.95,
1.06 and 1.01, so on par to within about six percent. The absolute microseconds are not quotable — this box is
bimodal, and both of those two move between roughly 7.5µs and 18.5µs together, which is what makes the ratio
the only honest figure and a single run of either a trap. So there is no bit_readable to add: converting
costs what reading in place would, the conversion is the door, and the readings' own block-wise algorithms are
what waits behind it.
The second family is trusted on std::bit_cast's terms, and asked for exactly what those terms need.
container_source<B, N> holds where B is trivially copyable, which is what std::bit_cast asks before it reads an
object; where the target is little-endian, so that byte j of a word holds the word's bits [8j, 8j + 8); and where
the object has room for the width, N <= sizeof(B) * 8. Nothing of B is called and nothing is constructed. Its
first ⌈N/8⌉ bytes are its bits, read by one copy at run time and through std::bit_cast in a constant expression;
writing one copies those bytes into sizeof(B) zero bytes and casts them back, so the bytes past the width are clear
without a default constructor to clear them.
Where its width comes from is the same choice made the same way. A foreign type names a width only as
std::bitset<N> does, in its type, and bit_width_v takes that N from the template argument rather than from a
size(). Any other foreign type has std::dynamic_extent, so xstd::bit_convert matches no fixed width against it,
and it is a source only where a target supplies N, as bit_block_container's tag constructor does.
The checks are for Murphy, not Machiavelli. Each guards against what a real implementation or a real caller gets
wrong by accident: a big-endian target, where bit n of a word lands at the far end of it, and a type too small for
the width, whose last positions would be read from past the object. Both are refused at compile time. What is not
guarded is a type built to deceive — one that keeps its bits reversed, or in a word it does not keep clean — because
telling that apart from the real thing means running the type's own members, which asks of every candidate a set,
a count and a size that a constant evaluation can reach, and a width bounded by the evaluation's step budget.
Like std::bit_cast, the caller vouches that the type is plain bits. The consequence is stated rather than hidden:
a pointer or a std::span passed where a target supplies N is trivially copyable and has room for as many bits as
its object, so it is admitted, and what is read is the bytes of the address and not what it points at.
What is not asked is has_unique_object_representations_v, and that is measured rather than preferred. GCC
13 through 16 answer false for any class with an empty non-static data member, even one [[no_unique_address]]
makes free, where clang answers true at identical layout — sizeof 8 and offsetof 0 on both. Every container
this library defines has such a member, so that trait would make this concept false on every GCC rung and true
on every clang rung. An empty base keeps the trait on GCC where an empty member does not, which is the remedy
if it is ever wanted; trivially copyable is what std::bit_cast asks, and the concept asks no more.
A run-time width takes no field of N bits through the tag, and that is the policy and not an omission: a field of
N bits names one N at compile time and a growing set has no single one to mean. It crosses through
xstd::bit_convert instead, which carries the width along (interop-not-a-bitset).
Both readings carry the exchange, and it is said once, in the container. The width is
bit_block_container's own property rather than any reading's, so that is where bit_extent, the two
exchanges_bits predicates, assign_bits and to_bytes live; a reading forwards the tag constructor to them in
one line and adds only what its own vocabulary requires, and xstd::bit_convert reads to_bytes through
storage_access, the one door both adaptors open to the library's free functions. Said in each adaptor instead,
the dynamic_extent-to-zero guard — needed because dynamic_extent is SIZE_MAX and byte_count of that is two
exabytes — would be a copy per adaptor of one idea. The differences between the readings are each forced by
something the reading already is.
The sequence reading takes it whole, on bit_layout exactly as the set reading does — with one constraint
the set reading has no need of. A bit_subspan is a window: a bit offset and a size of its own into storage it
does not span, so its position zero is not the storage's and its bytes are not the storage's bytes. Asking
has_static_width alone would wave it through, because a window over a static container reports that
container's extent rather than its own size, and to_bytes would then hand back the wrong bits. So
bit_convert reads a window as a run-time width of its own (bit_width_of asks a view for is_windowed),
a block at a time from its offset through block_at, so that its first position is position zero of the copy. A
bit_span, which is not a window, spans the whole container and converts like an owner.
Two block widths over the same N are two spellings of one field of bits, so they cross on this rule with
neither side named: basic_bit_array<uint8_t, 64> converts to and from basic_bit_array<uint64_t, 64> because the
byte is the common ground where the block is not.
The set reading and the sequence reading cross directly, both ways: xstd::bit_convert<xstd::bit_array<64>>(set)
is one copy of the blocks, because each has bit storage at a layout the library knows (is-and-has).
Neither is read as a container_source: each has bit storage at a layout this library lays out itself, and crosses
through its blocks rather than through its object.
the tag that deduces a width¶
The way in is a tagged constructor, not a static member, because a static member names its own type and nothing
can be deduced through it, and because a constructor is what emplace_back and the other in-place constructions
reach. The tag is std::from_range's twin -- xstd::from_blocks_t,
an explicitly defaulted constructor, and the object xstd::from_blocks -- because the job is the same one: saying at
the call site which reading of the argument is meant. itsy_bitsy's bit_sequence spells the same door std::in_place,
which every standard type taking it reads as constructing the contained value: bit_fixed_set<32>(std::in_place, 5u)
would read as the set {5} where the storage reading gives {0, 2}, and a tag that forwards any arguments to the
storage's constructor gives the guides nothing to deduce a width from. basic_bit_array(xstd::from_blocks, board) is
basic_bit_array<std::uint64_t, 64>, and basic_bit_fixed_set(xstd::from_blocks, blocks) over a
std::array<std::uint8_t, 3> is basic_bit_fixed_set<std::size_t, std::uint8_t, 24>: the guides deduce the block type and the
width, with std::size_t as the key, from an unsigned integer, a std::array of them or a built-in array of them, the
storage whose width the type carries. The guide for a built-in array takes it by reference, Block const (&)[K], so
it keeps its bound rather than decaying to a pointer, and deduces what the std::array<Block, K> guide does.
bit_block_container takes the same tag. Its adopting constructor moves in owned blocks whose type holds exactly
the width, as well as the growable blocks a run-time width adopts, and stores a width only where the storage has
one; so bit_block_container(xstd::from_blocks, std::array<std::uint8_t, 3>{}) deduces
bit_block_container<std::array<std::uint8_t, 3>> through that constructor's own guide. A templated constructor
reads any other bits of a layout its width exchanges. Two explicit guides remain, both for what is not a range of
owned blocks: one block deduces std::array<Block, 1>, and a built-in array Block[K] deduces std::array<Block, K>.
None of this reaches a run-time width, which has no width in its type for a guide to find.
is-and-has¶
Two things cross this door, and the library keeps them apart. A type is bit storage when its own blocks are the
bits: an unsigned block, or a sized contiguous range of them that subscripts, which is xstd::bit_blocks. A type
has bit storage when its internal storage is such blocks at a layout known or trusted: every owner and every
view over a whole width here, std::bitset<N>, boost::dynamic_bitset through its extension header, and bit
storage itself, which trivially has itself.
Each relation gets one operation and one concept. Blocks that are bit storage for X are taken as they are by
the tag constructor, X(xstd::from_blocks, b), where the tag says the blocks are bits rather than keys or
elements; xstd::bit_constructible_from<X, B> names it. Anything that has bit storage converts to anything else
that does through xstd::bit_convert<To>(from), which maps positions and may copy, shift or throw;
xstd::bit_convertible_to<From, To> names it. Between two fixed widths the target is at least as wide as the
source, the blocks are copied through the byte currency with the positions past the source's width clear, and a
view is read from and never written into. So bit_convert<xstd::bit_array<64>>(set),
bit_convert<std::bitset<64>>(set) and bit_convert<std::uint64_t>(set) are one rule, and a std::bitset never
goes through the tag, which would blur a type that has storage with one that is it.
the same job flat_set gives extract and replace¶
std::flat_set has a door of its own onto its representation: extract() && hands the underlying
KeyContainer out and leaves the set empty, and replace(container_type&&) takes a sorted-unique one back.
Together they are how a flat_set is built cheaply from a vector you already have, and how you get the sorted
vector back out afterwards. At a static width, from_blocks and xstd::bit_convert<B> are this library's answer
to the same need, and the two pairs differ in four ways that all follow from what the representation is.
flat_set::extract/replace |
from_blocks/bit_convert<B> |
|
|---|---|---|
| what crosses | one fixed type, container_type |
any B a concept admits |
| cost | a move of the container | a copy of N bits |
what extract leaves behind |
an empty set -- it is &&-qualified and destructive |
nothing; a fixed width is copied out and the set is untouched |
what replace promises |
a precondition: sorted, unique, no duplicates. Violating it is UB | nothing to promise; every field of N bits is a valid set |
The first row is the substantive one. container_type is a single type fixed by the class template, so
extract/replace cross with vector<Key> and nothing else -- another flat_set over a deque<Key> is a
different type with a different door. What crosses is named by concepts instead: the tag takes any block or array of
blocks that is bit storage, and xstd::bit_convert carries an
unsigned long long, a std::array of blocks and a std::bitset<N> of the right width on
one rule, the last of them read on the terms std::bit_cast reads any object (the field of bits earlier in this
section).
The fourth row is why the naming could be lighter here than the standard's. replace is void and cannot check
its precondition without doing the linear work the call exists to avoid, so the standard makes it UB and the name
carries the warning. from_blocks has no precondition at all: the domain of an N-bit field and the domain of a
set over [0, N) are the same set of values, and the map between them is a bijection. So the name is not there
to warn about validity -- it is there to say which reading of the bits you meant, which is
(from_blocks, 5u) reading {0, 2} and not {5}, and that is a different job from replace's.
The third row is the one to watch when porting. extract() is destructive and &&-qualified because moving the
container out is the whole point; bit_convert<B> copies a fixed width, because at N bits there is nothing to
move that is cheaper than the copy. A loop that calls extract once and bit_convert once is not the same loop, and
the flat_set one is the one that has to be written carefully.
The run-time widths take from_blocks too, with a different argument: not a block or an array to copy, since
std::bitset names one N and a growing set has no single value for it, but the block container itself, which they
adopt. The constructor is flat_set's container constructor under the tag -- block_container_type by value, moved
in, with an allocator-extended form beside it -- and flat_set's pair completes it: extract() && hands out
block_container_type -- the std::vector, inplace_blocks or small_vector of blocks -- and leaves the owner
at width zero, and replace(block_container_type&&) takes one back. All three are a move and nothing else, on the
sequence and set readings' run-time-width owners and on no view, since a view has nothing of its own to hand; the
valarrays take the adopting constructor alone. The static widths' tag
constructor requires a width in the type and the adopting one a width in the object, so the two never meet.
Each owner deduces through guides of its own. basic_bit_vector(from_blocks, std::move(blocks)) deduces
basic_bit_vector<Blocks, N> from one guide over every bit_storage, N the storage's constant capacity where it
has one and dynamic_extent where it has none, so K inline blocks are adopted at N = K * digits, which is
basic_bit_inplace_vector<Block, K * digits>, and a std::vector at no cap. The guide is the class's: the inplace
and small names are aliases that name the storage through inplace_blocks_for and num_blocks_v, which no
deduction sees through, so blocks adopted whole are deduced through basic_bit_vector and basic_bit_set.
replace has no precondition to state, which is where it parts from flat_set's: the width becomes the blocks'
whole width, every bit a position, so there is no tail for it to find dirty and no order for it to find broken. The
price is that a width is not carried through the round trip -- extract hands out whole blocks, the unused tail
clear, and replace reads them as whole blocks -- so a sequence of 70 comes back as one of 128 with the last 58
false. The set reading loses nothing, its width being only capacity; the sequence reading resizes
afterwards when the exact width matters. Adoption reads its blocks the same way. None is untagged, for the reason blocks take a tag: a
range of unsigned integers already means something to each reading's constructors, and the name is what says blocks.
The conversion operator reaches its storage through the storage() accessor and never through m_bits. The
member is a Bits* wherever a reading refers rather than owns, so naming it directly compiled for an owner and
was a hard error for a view — while the constraint answered yes either way. That is the worst shape a concept
can have: the question says the conversion exists and the call then fails to compile. The constructor is the
owner's alone, since writing through a view would write bits it does not own.
padding¶
static_used_bits is the mask of the last block that is not padding. num_bits is align_up(N), so
num_bits - N lies in [0, bits_per_block) and the shift is always in range.
Width zero needs no case of its own: it holds no block, so it has no padding, and the mask of all ones that
num_bits - N == 0 gives it names nothing, since every arm that applies the mask asks first whether there is
padding to clear. used_bits() is the same mask at a run-time width, asked only where a block exists.
The width member is a std::size_t, widened to the blocks' alignment where they out-align one:
template<class Blocks>
using stored_width_t = std::conditional_t<
(alignof(std::size_t) >= alignof(Blocks)), std::size_t,
std::conditional_t<(alignof(std::uint64_t) >= alignof(Blocks)), std::uint64_t, std::ranges::range_value_t<Blocks>>>;
A storage out-aligns a size_t in two ways, and each arm answers one. A storage holding its blocks inline does so
by the blocks' own alignment, and past a std::uint64_t's that is a 128-bit block, whose block_type is both wide
enough to hold any width and exactly the size of the gap it fills:
bit_block_container<std::inplace_vector<xstd::uint128, K>, N> is that cell. A 32-bit target whose 64-bit integers
keep their 8-byte alignment, as wasm32's do, reaches the std::uint64_t arm through any storage holding one: a
std::vector whose allocator carries a std::int64_t is 8-aligned against a 4-byte size_t whatever its blocks
are, so its block_type may be an unsigned char that cannot hold the width, where a std::uint64_t both holds it
and fills the gap. Either way bit_block_container is then its two members and nothing else, at the same size the
padding cost. std::array reaches none of this, a static width carrying no member at all, and neither does a
std::vector over a stateless allocator, aligned as a pointer whatever it holds. The static_assert in
bit_block_container holds the two facts that make the chosen type the right carrier, at least a size_t's size
and at least the blocks' alignment, so a storage over-aligned for some other reason fails loudly rather than
truncating a width. Every reader goes through size(), which converts once, so the arithmetic stays a size_t's.
default-construction¶
A defaulted default constructor, rather than two constructors constrained on the extent:
std::vector default-constructs empty, and so does a run-time width here -- width zero, no blocks, no
allocation, as std::vector<bool> has it. The two members live in a base, bit_members, whose specialization for a
capacity of nought declares them with no initializer and [[no_unique_address]], since a default member initializer
makes a defaulted default constructor nontrivial (the-inplace-column); everywhere else the
blocks stay a plain member, whose tail padding an overlappable one would expose to -Wpadded.
A static width holds exactly the blocks its width fills, which is none at width zero: num_blocks_v and blocks_for
give the same count, and the static arms that index a block directly are the ones selected at one block or two.
Default construction zeroes an array's blocks at every width, bit_array included, though std::array<bool, N> a;
leaves its elements indeterminate. The two must admit the same code, and array<bool, N> a; a[0] = true; is valid:
a[0] is an object of its own, and writing it reads nothing else. A packed a[0] = true reads the block that a[1]
shares, which would be undefined over a default-initialized block, so the blocks start clear. Reading a position
before writing it is undefined for std::array, which permits the false it answers here. What is given up is
is_trivially_default_constructible, which no packed array can have while one-element writes stay valid; masking the
read instead would not help, as an indeterminate value is the whole block's and block & mask already reads it.
The zeroing is the public types' alone, and as late as the hierarchy allows. Every layer under them -- the layouts,
bit_block_container and the adaptors -- defaults its default constructor and declares the blocks with no
initializer, which a constructor could not skip, so each leaves them unwritten when default-initialized, as
std::array does. The three public types over an array, basic_bit_fixed_set, basic_bit_array and
basic_bit_fixed_valarray, reset their blocks in a user-provided default constructor; a run-time width starts empty
and has nothing to zero. Inside the layers, a constructor that writes position by position, from keys or bools,
resets the blocks first, and one that assigns every block, as the exchange through from_blocks does, leaves them
unwritten. At a static width every layer is trivially default-constructible, as std::array is, so T()
zero-initializes and T t; writes nothing. A default member initializer anywhere in the chain, even on the empty width
tag, would make the constructor nontrivial, and GCC's constant evaluator drops the zero-initialization that precedes a
nontrivial defaulted one. The public types and the position-by-position constructors assign their zeroes with
reset() in any case, so they depend on neither.
The three also take xstd::indeterminate_t, for a two-step initialization: the constructor leaves the base
default-initialized and writes nothing at any width, the padding of a width that does not fill its blocks included.
Only a whole write may follow it; inserting one key after it is undefined, as a[0] = true would be. A set that is
about to be overwritten, as a chess engine's per-ply state is, asks for the tag where it is declared.
structural-at-aligned-widths¶
[array.overview]/4 makes std::array<bool, N> a structural type, usable as a non-type template argument. A
structural class has every base and every non-static data member public and non-mutable ([temp.param]/7), and two
values name the same specialization exactly when their members do, word for word. That is only sound where every word
pattern is a value. At a width that is not a multiple of the block's digits, the last block has unused high bits,
and ==, <=> and hashing compare whole blocks on the promise that those bits stay clear. Public blocks would let a
caller set them, so those widths keep their blocks non-public and are not structural.
At an aligned width, where N fills its blocks, there is no invariant to protect: every bit is a position, and the
layout is exactly the std::array<Block, K> plus an empty width tag. Access control cannot depend on a template
argument, but the choice of base can. bit_block_container takes structural_bit_members, a struct whose m_size and
m_blocks are public, when its blocks are a std::array whose xstd::bit_blocks_extent_v is exactly N bits (structural_blocks_v,
answered as bit_block_container::is_structural), and bit_members, whose members are protected, otherwise. Each adaptor
keeps its m_bits in an adapted_bits base on the same terms, public only for an owner over structural storage. The
names are the same in either base and the adaptors reach them through the same using-declarations, so nothing else
in the library notices which one it got; the public members are not an interface, only what makes the owner
structural.
That gives bit_align<bit_array<N>> and bit_align<bit_fixed_set<N>> at any width, and the plain owners
at a multiple of the block's digits. Width zero is among them: its blocks are a std::array<Block, 0>, structural by
[array.overview]/4 like any other extent, with no bit in it to keep clear. The inplace owners cannot follow at any width,
since neither std::inplace_vector nor boost::container::static_vector is structural, and the views hold pointers
kept non-public as before.
the-moved-from-state¶
A run-time width's move leaves the source at width zero with no blocks: the same object the default constructor
makes, and one on which every member without a precondition works, push_back and insert included. The implicit
moves cannot say that: they move the blocks and copy the width, so a moved-from bit_vector would answer
size() == 100 over no blocks and a push_back would write past the end. The moves are written out for the run-time
widths alone, constrained on not has_static_size, and defaulted for the static ones, which keeps those trivial.
A capacity of nought is defaulted too: its width is zero without being stored, so there is nothing to reset.
Zero blocks is what makes the state reachable without allocating. Restoring one block in the source would have
asked std::vector for memory inside a move, which is what noexcept on every cell's move rules out. The source's
blocks are clear()ed after the move, because std::inplace_vector and boost::container::small_vector keep
their moved-from elements where std::vector does not. Move assignment takes the source's blocks into a local
before writing anything, so a self-move puts back exactly what it took, with no branch on this == &other for a
test to have to take.
the-strong-assignments¶
Both assignments of a run-time width give the strong guarantee, where the standard asks a container only for the
basic one. Blocks are trivially copyable, so the one step that can throw is an allocation, and it comes first: blocks
that fit the capacity already held are copied over the old ones, which allocates nothing, and otherwise the copy is
built in storage of its own and moved in whole; the width is written last. A defaulted copy assignment would write
the width first, it being the first member, so a refused allocation would leave a width over blocks that do not hold
it, and reads past the old blocks would go out of bounds. The move assignment copies only between unequal allocators that
stay, and anywhere else moves the blocks without throwing. An allocator that propagates on copy assignment comes
over with a copy built under it, moved in where the allocator propagates on move assignment and swapped in where it
propagates on swap; one that does neither leaves that copy only the basic guarantee, which is all std::vector's
gives. The copy is moved in rather than swapped wherever both would do: Boost 1.83 swaps a small_vector that
holds its blocks inline with one that does not element by element, growing the inline one, which allocates. A
capacity in the type keeps the defaulted copy assignment, which cannot allocate and so stays trivial where its
blocks are.
growth¶
Growth is the run-time width's alone, and every member of it leaves the unused tail clear. resize(n, value)
resizes the blocks to what n needs, filled with value, moves the width, and masks the new last block;
growing with ones also sets the old last block's tail, clear by the invariant, since those are the first new
positions -- and sets it only after the blocks have grown, so that a growth std::inplace_vector refuses with
bad_alloc leaves the value as it was. push_back and pop_back are resize by one, clear is resize(0) -- the object a default
constructor makes -- and append(block) is boost's: the block's bits become the next bits_per_block
positions, split across two blocks where the width is not aligned, and pushed as the first block at width
zero. It keeps boost's strong guarantee: the split pushes the new block before it writes the old last
block's tail, as boost's does, so a refused push leaves that tail clear. reserve, capacity and shrink_to_fit are in bits and exist where the blocks have them:
std::vector and std::inplace_vector, not std::array.
clear() here is the sequence reading's, width to zero, which is what std::vector<bool> and
boost::dynamic_bitset mean by it; the set reading's clear() is fill(false) and never reaches this
member, so the landmine #80 recorded -- probing clear() on boost and emptying the width -- cannot recur.
inplace_blocks<Block, num_blocks_v<Block, N>> is the third storage: a run-time width under a compile-time capacity
of exactly N bits, held in a std::inplace_vector where __cpp_lib_inplace_vector says the library has one and in
a boost::container::static_vector everywhere else. Either satisfies owned_bit_blocks as it is, but each counts
blocks, so it refuses growth only a whole block at a time; a capacity that stops inside the last block is the
container's to hold. check_capacity does that on every growth path -- resize, push_back, append, reserve
and the sized constructor -- and throws std::bad_alloc, as std::inplace_vector specifies, before anything is
written, so the storage is never asked for a block past the capacity.
The adaptors take growth by detection on the storage: growth is a container's business and no view's, so it exists on an owner and on nothing else. sequence_adaptor is
std::vector<bool> where its storage grows -- the count and count-value constructors, the range and
initializer_list constructors and assignments, assign, resize, clear, push_back, pop_back,
emplace_back, with reserve, capacity and shrink_to_fit where the blocks have them -- and
std::array<bool, N> where it does not, each member requiring the storage member it forwards to. set_adaptor
takes none by name: a set grows by insert, and the storage's growing_insert grows a run-time width to hold
the key (asking-is-total).
Scans¶
inclusive-is-the-primitive¶
Inclusive and exclusive name the only thing separating the two forward scans: whether n itself is a
candidate. That is a property of the scan rather than of a reading, which is why this is not called
lower_bound — that is the set reading's word, and this layer serves the sequence reading equally.
bit_fixed_set::lower_bound is where the set name belongs, and it maps here one for one.
The inclusive form is the primitive and the exclusive one is derived, because the reverse does not close:
exclusive_find_next(n) == inclusive_find_next(n + 1) for every n
find_first() == inclusive_find_next(0)
Exclusive-first would need find_first() == exclusive_find_next(-1), and size_t has no such value —
which is exactly what would force a branch on x == 0. Both derivations are + 1 and never
− 1, so nothing wraps at zero. Boost's find_next and libstdc++'s _M_do_find_next are both the
exclusive form, correctly: their iteration idiom is find_first() then find_next(i), which never needs
the inclusive one.
n == size() rather than n >= size(): the precondition is n <= size(), so size() is the only value
the scan cannot start from. is_valid does not say this — it is n < size() — and size() is a
legitimate argument, meaning "no such position".
offset-guards¶
Neither forward scan guards on offset != 0. >> 0 is the identity, so the masked test is correct at a
block boundary too, and advancing past that block afterwards is right either way. Neither reference
implementation guards it — boost shifts by ind then falls to m_do_find_from(blk + 1), libstdc++ masks
by ~0 << whichbit then does __i++ — and the guard skips no work: at offset 0 it merely moves the same
test into the loop's first iteration. #88 measured it
as pure cost.
The reverse scan's reverse_offset != 0 guard is a different question, and is not about the shift:
left_bit - offset is in [0, left_bit], so shifting is always in range and by zero is the identity. It
is about which block the range scan must start at — at index when the whole block is in range, below it
when the masked block came up empty. Naming the fallback block outright, as the two-block case does, makes
that distinction disappear.
two-block-case¶
At two blocks the tail is one named block rather than a range: the general walk costs more than the whole
scan is worth at this width. index is a run-time value — n is — but the block count is not, so the
second half is a test and not a loop.
Indexed rather than branched: m_blocks[index], not an if on index, so the common path is one
computed load. Only when the starting block comes up empty does it matter which block we started in, and
then only to decide whether block 1 is still ahead. Writing this as a branch instead cost 10 instructions
at -O3 -march=native.
index-walks¶
A plain index walk rather than drop + find_if, and a descending one rather than reverse + drop +
find_if. The iterator forms would have to recover the block's position with distance(); the index is that
position, so it never needs recovering. The reverse form would compose two adaptors only to have distance()
undo them.
the-blit¶
bit_block_container::block_at(pos) is a block's worth of bits at any position:
the bits [pos, pos + digits), assembled from block(pos / digits) >> r and the next block << (digits - r)
where r = pos % digits. Two things make it total where it is called. A shift by digits is undefined, so
an aligned read is the block itself with no second term; and the last block has nothing above it, so the
read stops there rather than asking for a block past the end. What the block reaches beyond the width is the
clear tail, and it is the caller's to trim. It is the one primitive under every unaligned read: the sequence
adaptor's append_range from a sequence read by block (the-range-members), and the bulk
operations on a window (windows).
bit_block_container::block_at(pos, value, mask) is its write side, on our storage alone as set_block
is: the bits of value under mask land at [pos, pos + digits), split over two blocks where pos is not
aligned, the tail kept clear. Every masked write goes through it: the storage's ranged set,
xstd::ranges::bit_fill over a window, the copy algorithms at any two offsets, and a window's bulk operators, each walking the blocks a range spans with the mask of what each
holds, whole blocks and a partial one at the end.
the-funnel-shift¶
Under block_at and both shift operators is one operation: two adjacent blocks spliced into a double-width block
and shifted down. bit_block_container::straddled_block(index, L_shift, R_shift) is that splice, and the
three sites read as three uses of it rather than three spellings.
It takes the shift and its complement, named as both operators already name them, because both already hold
the pair as loop invariants: passing only one would have the other derived back inside from what it was derived
from outside. That round trip is one hoisted instruction, but it is also the only thing that makes the codegen
differ -- with the pair passed, GCC emits the same instruction mix as the splice written out at each site (954
lines, 60 subtractions, six $64 immediates, against 1016, 67 and twelve with one shift passed), differing only
in register assignment. The two
adding to the block width is the whole contract, and the assert says so. There is no one-shift overload:
block_at holds only the offset and spells the complement at its single call site.
The three do not look alike, which is why the one operation under them is easy to miss. block_at(n) takes
(index, offset) from n.
operator>>= reads (i + n_blocks, R_shift) — literally block_at(i * digits + n), reached without
recomputing the division. operator<<= reads one block below its destination, (i - n_blocks - 1,
R_shift), because it walks down while writing up; its loop names L_shift and the complement is the
offset, which is what hid the third one.
It asserts rather than guards, and that is the design content. Every caller already handles its own edge,
and they are different edges: block_at's is a full-width shift or a missing block above, each operator's is
the destination block with nothing beyond it, and — the one that matters — both operators branch on the
aligned case outside their loop, into std::shift_left or std::shift_right. A guarded primitive
called once per block would drag that test into the loop and cost the memmove fast path, which is the one
thing those operators get for free. Keeping the check where it is preserves it, and the splice still exists
once instead of three times.
Contracts¶
total-versus-precondition¶
bit_block_container::exclusive_find_prev is deliberately not total. Where inclusive_find_next
answers size() for "nothing at or above", this one has a precondition instead, and that is the whole of why it
is three instructions cheaper at every width: it never materializes a not-found value.
It is safe because reverse iteration supplies the guard the function does not. rend() is
make_reverse_iterator(begin()), and std::reverse_iterator stops there, so operator-- is never applied
at begin(). A hand-written loop gets no such help: the forward idiom terminates itself against size(),
the reverse one runs off the bottom.
Totality is the container's contract to keep, not this layer's to absorb — #86 settled the same division one layer down.
the-wraparound-assert¶
exclusive_find_prev asserts is_valid(n - 1), mirroring exclusive_find_next's is_valid(n): each says
the position actually scanned from is one this container has.
n - 1 is that position here, and the wraparound does the work at the bottom — 0 - 1 is SIZE_MAX,
which no width admits — so this states 1 <= n <= size() without a second predicate. is_valid alone
would be wrong: size() is a legitimate argument, meaning "from the end".
the-cheapest-contract¶
bit_block_container states each operation in its cheapest form and lets the caller that needs more pay
for more. exclusive_find_next requires is_valid(n); exclusive_find_prev requires a set position strictly
below n — which any() does not establish, a container whose set positions all lie above n having none
below it.
A caller that needs a total answer restores totality where the width is already in hand. Reverse iteration
does it with rend() (total-versus-precondition), and the set reading's upper_bound
guards the forward step with the width it already holds. Neither guard is in the storage, because every other
caller of either step already knows it does not need them.
This is the direction that matters. A caller may widen a contract; the storage may not narrow one. Putting the guard in the storage would cost the three instructions at every call site to serve the one that asked.
One storage¶
one-storage¶
There is no storage trait. The adaptors take a bit_block_container and ask it directly, so a question about
a storage is answered by the storage, in its own name. The alternative is a bit_traits<Bits>, specialized beside
each storage and carried as a fourth template parameter, set_adaptor<Bits, Own, Traits>, inside a layer of
generic scans that synthesizes whatever a specialization does not declare, choosing a block tier or an element
tier by whether the entry is there.
Such a layer serves a plurality of storages, and there is none. std::bitset and boost::dynamic_bitset are
comparison targets rather than adapted storages (owning-is-ours), and the adaptors are
constrained to bit_block_container nominally, so a storage is ours because the constraint names it and not
because its members answer. Over the one storage a trait would name exactly one thing, the adaptors would name it
twice, and bit_traits<bit_block_container<...>> would be twenty entries forwarding to members its only caller
already has.
What that is worth is not the line count. The element tier is the half of such a layer that only a foreign
storage could ever reach, and it is reachable code carrying reachable branches — a reverse walk one position at a
time, quadratic over a traversal, behind a probe that our storage always fails. Synthesized scans also have to be
total, because a fallback cannot know what its caller checked; the storage's members state preconditions instead
(the-cheapest-contract). And every question has one answer at one address, so a reader
chasing find_first reaches the loop rather than a dispatcher over a probe.
What it costs is the extension point. Specializing a trait for MyStorage is how an outside storage would
join, and nothing stands in for it: a storage joins by being a bit_block_container, which lives under detail/
and is spelled by the library alone. That is a real narrowing and it is deliberate — a trait would pay for a
generality with no second instance (the-interface-line).
The detector is local. The obvious is_specialization_of names a template through a template<class...>
parameter, and this storage is a type then a value, so it does not apply. The adaptors are constrained instead on
bit_block_container_type, a concept over a variable template specialized on bit_block_container<Blocks, N>,
which strips the const a view over a const owner names:
template<class T>
inline constexpr bool is_bit_block_container_v = false;
template<class Blocks, std::size_t N>
inline constexpr bool is_bit_block_container_v<bit_block_container<Blocks, N>> = true;
template<class T>
concept bit_block_container_type = is_bit_block_container_v<std::remove_const_t<T>>;
template<bit_block_container_type Bits, storage Store = storage::owned, class Derived = void, class Key = std::size_t, bit_index_mapping<Key> KeyMapping = bit_key_mapping<Key>, class Compare = std::less<Key>>
class set_adaptor;
A generic concept for the shape would have to be spelt <class U, U...> rather than <class, auto...>, which
would also take a value typed by the type, and would still need the const stripped; two specializations and a
concept state the one fact directly. A constrained parameter is no obstacle: owned_bit_blocks Blocks binds it as
a bare class would.
what-the-readings-share¶
Almost everything the readings ask of a storage is the same operation under another name, which is why the vocabulary is smaller than the APIs suggest:
| what a reading calls | what the storage calls it | |
|---|---|---|
set size() (cardinality) |
count() |
|
set max_size() (width) |
size() |
|
contains(n) |
test(n) |
the same operation |
set erase(n) |
reset(n) |
the same operation |
set clear() |
fill(false) |
|
sequence size() / operator[] |
size() / assign(n, value) |
|
| a view's static extent | extent |
Three members are left over, and they are the three no reading can spell for itself:
growing_insert(n)is the only operation that can grow. The partialinsert(n)assertsis_valid(n)and answers whether the position was new; the total one resizes first where the storage can. They are deliberately two names: a silent substitution of one for the other is a precondition quietly dropped.assign(n, value)is the positional write, spelled apart fromseton purpose.set(bool)andset(std::size_t)are ambiguous for a literal0, andset(n, value)is one of the five absences that make the common vocabulary the intersection of the three vocabularies rather than the union (the-common-vocabulary).fill(value)is bulk, andclearisfill(false). Not an overload ofsetfor the same reason.
why-nested¶
The free functions live in xstd::bits::detail rather than in xstd, and the nesting is load-bearing.
Since C++20 ([temp.names]/3, P0846) an unqualified call with explicit template arguments — shl<Block>(b, n),
or the scan_first<Traits>(c) this rule was written for — parses its < as a template argument list and then
performs ADL with those arguments included, so std and boost, the associated namespaces of the types
in play, would join the overload set. [namespace.std] bars users from adding to std but not
implementations, and boost is under no such constraint.
Down here nothing is visible unqualified from xstd, so the qualification is enforced by scoping rather
than by remembering a prefix at every call site, and no class is needed to stand in for the namespace.
the-two-reserved-names¶
std::bitset's two implementation hooks are complementary, not paired — which is the opposite of what
it looks like, and worth stating because the wrong guess silently costs a tier:
_Getword |
_Find_first / _Find_next |
|
|---|---|---|
| libstdc++ | no — it is _M_getword, on an inaccessible base |
yes |
| MSVC | yes | no |
| libc++ | no | no |
So neither implies the other, and each is worth a constrained entry on its own. Where _Getword is
reachable the walks run block-wise, including a find_prev neither library supplies; where _Find_first is
reachable the forward scans are native. Both return N when nothing is set, which is what a set reading's
end position is here, so no npos mapping is needed — unlike boost, whose find_first answers npos.
The third row is not the end of block access on libc++. A width that fits one unsigned long long reads
its single block through to_ullong(): portable, constexpr, no reserved name, and the constraint
N <= numeric_limits<unsigned long long>::digits puts the overflow_error it could throw out of reach, so
std::bitset<64> runs the block walks on every library. Above one word it is _Getword or nothing:
shifting and calling to_ullong per block is O(N) each, worse than the element walk it would replace.
proxies-compare-themselves¶
Neither ordering needs a comparator. A proxy reference converts implicitly to its value_type, and that
conversion is what satisfies three_way_comparable — checked for both this library's sequence_reference
and std::vector<bool>::reference, on which lexicographical_compare_three_way works with no comparator
at all. An explicit [](bool a, bool b) { return int(a) <=> int(b); } says nothing the conversion has not
already said.
block-writes¶
set_block is the write side of block(), and was deliberately never one of the questions a reading asks:
block writes are only ever needed on storage we control, and the unused tail is bit_block_container's
to keep.
Orderings¶
two-readings-disagree¶
"Lexicographic" is underspecified below the reading layer. Both readings are lexicographic; they order over different sequences, and they disagree, as two pairs show:
| pair | set reading, ascending positions | sequence reading, bools from index 0 |
|---|---|---|
{0} against {1} |
[0] vs [1]: less |
[1,0] vs [0,1]: greater |
{0,1} against {1} |
[0,1] vs [1]: less |
[1,1] vs [0,1]: greater |
A storage serving two readings cannot hold one of their orderings under a neutral name without choosing for
its callers, so it holds none. The orderings sit beside it, both separately named:
detail/comparisons.hpp has a set_three_way and a sequence_three_way over the storage's
primitives, never one three_way, so a caller says which reading it means rather than being handed whichever
the storage happened to pick. Each is the reading's own blockwise answer to std::lexicographical_compare_three_way,
which is also what pins it: whatever it answers has to agree with that algorithm over that reading's own
iterators.
Each is named for the reading it orders, the same names set_reading_tag and sequence_reading_tag
give. Positions, and bools from index 0.
A name is a reading's order, not an adaptor's. Neither takes an adaptor type, only the storage, so
the order is shared by every reading that means it. The one in view is the sequence order: a future bit_string,
whose string_reading_tag derives from sequence_reading_tag, orders its bools from index 0 exactly as a
bit_vector does, and so reuses sequence_three_way as it stands rather than naming a fourth ordering. The bit
string std::bitset::to_string() prints is a different order, most significant first, and is not that reading.
Recorded against a long-held hypothesis: that with the right bit order the set reading's <=> could be a
plain lexicographical_compare_three_way over the blocks. It cannot, in any bit order. The hypothesis is the
whole case for a mirrored layout -- the set order "equivalent to doing the integer comparison wL > wR on the
underlying words" -- and stating the layout without its refutation would leave it free to be mirrored on that
reasoning, so the refutation lives here.
Take the two cases where x holds the lowest differing position p:
| pair | p | does y hold anything above p? | set order |
|---|---|---|---|
{0} against {1} |
0 | yes, 1 |
x is less |
{0,1} against {0} |
1 | no | x is greater |
Same local observation, opposite answers, and set_three_way takes exactly those two
branches: any_above answers true in the first and false in the second. A lexicographic scan cannot tell them
apart, because it has already returned by the time it reaches p.
The reason is structural rather than a shortfall of any particular walk. A blockwise lexicographic compare
orders two vectors of the same length N, position by position, in some fixed order of positions. The set
order orders two sorted element lists of different lengths, where a shorter list that is a prefix is less.
Those lists are the support of the bit vectors and their lengths are popcounts, so the set order asks a
question about what lies above p that no fixed positional scan answers. Relabelling positions moves both
cases together, so the pair above can be built in whatever bit order is chosen; any_above is irreducible.
A bit string, the order std::bitset and boost::dynamic_bitset mean by <, is the one that does get to be
the standard algorithm over the blocks reversed: it is a fixed-length vector, so it has no prefix case to answer.
It is colexicographic on the elements, comparing from the largest position down; the set reading is
lexicographic, from the smallest up. Two different orders on sets, not two spellings of one.
the-ordering-primitive¶
Both orderings are free functions over the storage rather than members of it, in
detail/comparisons.hpp: set_three_way(x, y) and not x.set_three_way(y). An ordering is a question about
two values with neither as its subject, and the member spelling put one of them in a place the operation does
not have -- the same asymmetry a member operator<=> would carry. Each is a template constrained on
bit_block_container_type, in the storage's own namespace, so the adaptors reach it by ordinary lookup and a caller
holding two storages by ADL. Both adaptors are constrained on the same concept, so every storage they adapt has
these primitives, and set_adaptor's <=> calls them with no fallback, as xstd::ranges::bit_includes,
xstd::ranges::bit_intersects and xstd::ranges::bit_disjoint walk the storage's blocks.
They are not the storage's because they are readings and it is not. What they are built from is: first_difference,
any_above, any_block_set, padded_block, padded_first_difference, padded_any_above, block, test and
the widths, all public, and the orderings and set_equal say everything else in those terms. any_above is a
member because it IS asked of one value: whether this storage holds anything above a position.
first_difference is symmetric -- its answer is an xor, which commutes -- and is a member because it is a
primitive over the blocks rather than a reading's vocabulary; the same is true of padded_first_difference.
padded_set_three_way and padded_sequence_three_way, the width-crossing arms of the two ascending orderings,
sit with them. set_equal takes the orderings' form, and for the same reason: equality is as much a question
about two values with neither as its subject as an ordering is, and x.set_equal(y) would spell a symmetry the
operation has and the call does not. It sits beside them, while the defaulted operator==, width first, stays the
storage's hidden friend.
intersects takes the same form, and the standard library says why: intersects is to set_intersection what
contains is to find — the predicate form of an algorithm. find is a member of std::set, asked of one
set with a key, and so contains is a member too. set_intersection is a free algorithm over two ranges,
so the question is free.
Which reading keeps a member is then decided by the counterpart, not by taste. std::set
has nothing of the kind, so set_adaptor keeps neither a member nor a friend, and a reading asks the question
through the algorithm xstd::ranges::bit_intersects, named for the set_intersection it predicts
(algorithms-not-members) — which is also the spelling the analogy above asks for.
xstd::ranges::bit_disjoint asks the negation, so neither question is written with a not.
Neither inclusion nor intersection is a storage member. xstd::ranges::bit_includes and xstd::ranges::bit_intersects walk the two
storages' blocks themselves, keeping the storage's hand-unrolled one- and two-block arms through its public
static_num_blocks, and ask each pair of blocks through block_includes and block_intersects: the one-block
predicates, named for their unit as the public algorithms are, bit_ for the whole and block_ for one block.
block_intersects sits in detail/, since the containers' single-bit tests ask it too; block_includes sits in
bit_algorithm/detail/, since only bit_includes does. block_includes(a, b) takes std::ranges::includes's order
of arguments: every position of b is one of a. A proper subset and either superset are no primitive at all:
xstd::ranges::bit_includes(a, b) is the superset, and each proper form is one inclusion without the other, both answered
across two widths.
Both orderings are answered a block at a time, from two pieces:
first_differencereturns the lowest block at which two values differ, together with that block'sxor.countr_zeroof thatxoris then the lowest position at which they differ. Equal values answer the last block and a zeroxor, from the unrolled arms and the loop alike: the loop runs to the block before the last and returns that one'sxorunconditionally, as the two-block arm does.any_aboveasks whether one value holds anything strictly above a given position. The value's bit at that position is clear -- it is the one that lacked the differing bit -- soblock >> offsetleaves exactly what it holds above, and the shift is always defined becauseoffset < digits. No two-step shift, and no guard against shifting by the width.
From there the two readings differ by one clause and nothing else:
| reading | at the lowest differing position |
|---|---|
| set | whoever HOLDS it is greater, unless the other holds nothing above it |
| sequence | whoever HOLDS it is greater, full stop -- position 0 is the first element, so nothing above it is consulted |
Both are total across two widths. sequence_adaptor::operator<=> calls its ordering for any two lengths, so
an ordering that asserted x.size() == y.size() would abort every bit_vector comparison of two lengths in a
debug build -- and, with the assert compiled out, answer wrongly rather than not at all. Measured against
lexicographical_compare_three_way over std::vector<bool> across 148,225 pairs (every length 0 to 70 plus the
block boundaries 127/128/129/191/192/193, five patterns each), that form gives 27,312 wrong answers and the
total one none.
The total form is the set reading's, one clause lighter. padded_sequence_three_way pads the shorter operand's
missing blocks with zero exactly as padded_set_three_way does -- the invariant keeps everything above
size() clear, so a block that is not there reads the same as a block that is
(width-is-capacity) -- and where the set version asks padded_any_above at the deciding
position, the sequence version asks nothing: position 0 is the sequence's first element, so holding the
lowest differing position settles it outright. What the sequence reading needs instead is the case the set
reading cannot have: agreeing at every position the two share leaves only length, and the shorter is then a
proper prefix of the longer and so less, which is size() <=> size().
An assert where an answer belongs is the pattern in the cheapest contract read the wrong way round. A precondition is free to be narrower than the naive form only where the caller can honour it; this caller cannot, having two lengths whenever its user does.
Why this beats iterating. The lexicographical_compare_three_way form walks set bits; this walks
blocks, and a block step is an xor and a test rather than a load, a shift, a countr_zero and a branch.
Equal values are the clearest case: iteration confirms every element, so a full 1024-bit set costs 1024
bit-scans against 16 block xors. Near-equal and dense values are the same story. Only an early difference
makes the two comparable, both exiting at once.
It is one sweep, not two passes. first_difference covers blocks [0, i]; any_above then covers
[i, n) on one operand, the one that lacked the bit. Block i is the only one touched twice, so the
pair costs n + 1 block reads. And when the values are equal any_above is never reached at all, since
first_difference already settles it.
The prefix clause is not removable. Set order is not plain lexicographic over blocks under any
comparator. At digits = 4, A = {1} and B = {5} differ in block 0, where A₀ = {1} and B₀ = {}; a
prefix rule over blocks says B < A, but the sets truly compare A < B. any_above is exactly the repair,
and is the whole of what separates the two readings.
The standard algorithm is the specification. Whatever the block-at-a-time form answers has to agree with
lexicographical_compare_three_way over the reading's own iterators, which is
the invariant itself, and the test is that the two paths agree. The efficient form
is then free to state preconditions the naive one does not (the cheapest contract);
what it may not do is answer differently.
the-set-queries¶
A set answers one question of a key, as a member, and three of another set, as algorithms, each named after what the standard library already spells:
| query | true where | spelled | named after |
|---|---|---|---|
contains(k) |
k is an element |
member | std::set::contains, the predicate form of find |
xstd::ranges::bit_includes(x, y) |
every element of y is one of x's |
algorithm | std::ranges::includes(x, y, x.key_comp()) |
xstd::ranges::bit_intersects(x, y) |
some element is in both | algorithm | std::ranges::set_intersection, its result nonempty |
xstd::ranges::bit_disjoint(x, y) |
no element is in both | algorithm | std::ranges::set_intersection, its result empty |
contains is total, as std::set's is: a key past the width, and a value that is no key of the mapping at all, are
no element. The three over sets are all-of, any-of and none-of, and every other relation between two sets is one of
them, its operands swapped, or a conjunction of two:
| relation | spelled |
|---|---|
x ⊇ y, superset |
xstd::ranges::bit_includes(x, y) |
x ⊆ y, subset |
xstd::ranges::bit_includes(y, x) |
x ⊋ y, proper superset |
xstd::ranges::bit_includes(x, y) and not xstd::ranges::bit_includes(y, x) |
x ⊊ y, proper subset |
xstd::ranges::bit_includes(y, x) and not xstd::ranges::bit_includes(x, y) |
| some element is in both | xstd::ranges::bit_intersects(x, y) |
| no element is in both | xstd::ranges::bit_disjoint(x, y) |
All three are free functions because std::ranges spells each as one, an algorithm over two ranges rather than a
member of either, and they take its order of arguments: includes(x, y) asks whether y lies within x. Each reads the
blocks a step at a time, at any two run-time widths, and is noexcept. The second argument is
std::type_identity_t<S> const&, outside deduction, so it converts to the first's type, and a flag set takes a mask
there (flag-types). P0125R0 proposes the four containments for
std::bitset as members, and boost::dynamic_bitset has is_subset_of, is_proper_subset_of and intersects;
here each relation has the one spelling above, built from the algorithms std::ranges already names
(algorithms-not-members).
Other libraries spell the six differently, and a dash marks a question a library has no name for:
| subset | proper subset | superset | proper superset | overlap | no overlap | |
|---|---|---|---|---|---|---|
| xstd-bits | bit_includes(b, a) |
bit_includes(b, a) and not bit_includes(a, b) |
bit_includes(a, b) |
bit_includes(a, b) and not bit_includes(b, a) |
bit_intersects(a, b) |
bit_disjoint(a, b) |
boost::dynamic_bitset |
a.is_subset_of(b) |
a.is_proper_subset_of(b) |
— | — | a.intersects(b) |
— |
std::bitset |
— | — | — | — | — | — |
std::ranges |
— | — | includes(a, b) |
— | — | — |
Python set |
a <= b, a.issubset(b) |
a < b |
a >= b, a.issuperset(b) |
a > b |
— | a.isdisjoint(b) |
Rust HashSet |
a.is_subset(&b) |
— | a.is_superset(&b) |
— | — | a.is_disjoint(&b) |
Swift Set |
a.isSubset(of: b) |
a.isStrictSubset(of: b) |
a.isSuperset(of: b) |
a.isStrictSuperset(of: b) |
— | a.isDisjoint(with: b) |
Rust bitflags |
— | — | a.contains(b) |
— | a.intersects(b) |
— |
Checked against Boost's
dynamic_bitset.hpp
in Boost 1.92.0, [template.bitset] and
[includes] in the working draft, Python 3.14's
set types, Rust 1.99.0's
HashSet, Swift 6.4.0's
Set.swift and
bitflags 2.13.2's Flags.
Boost names the subset direction alone, and std::ranges::includes and bitflags' contains the superset direction
alone, each with its operands swapped for the other. Here the superset direction is the one named, in
std::ranges::includes's order, and the subset is that call with its operands swapped. Boost and bitflags name only
the positive overlap and Python, Rust and Swift only the negative one; this library names both, a nonempty and an
empty set_intersection, so neither is spelled as the other's not.
degenerate-widths¶
Two widths get their own if constexpr arm in the orderings, for the reason in
per-instantiation-slots -- an arm that no input of this instantiation can take
is an uncovered branch, and gcovr scores instantiations separately:
- Width zero never differs, so both orderings answer
equaloutright. Without the arm, thediff == 0test would have an untakeable false branch, andany_abovewould be instantiated with no caller able to reach it. - Width one has a single position, so the value lacking it is empty and
any_aboveis constantly false. The set ordering answerstest(0) <=> other.test(0)instead, which also skips instantiatingany_above. The two readings happen to coincide here, the empty set being both the prefix and the smaller bool.
Everything wider shares the general arms. A single block still needs the block-loop specialisations -- a
one-block instantiation cannot take a loop's exit branch -- which is why first_difference and any_above
each spell out the one- and two-block cases the way find_front and xstd::ranges::bit_intersects do.
Each caller of a scan carries the width-zero arm ahead of the call, and is_zero_width is the one
predicate they all ask: at width zero every answer is zero -- the total answer, size() -- and the storage's
step, which asserts, is never instantiated for it. The set iterator's
equality takes an arm there too: a zero width has one position, so every iterator over it is the same one,
and operator== says so outright. The point is the loops an optimizer sees into. Three spellings of the
step failed: one that returned size() left every while (it != last) ++it provably unable to advance,
which crashes MSVC's optimizer (range-v3's symmetric difference over a zero-extent set inside a zero-trip
loop was the reproducer) and which gcc 15 diagnoses, through the counter overflow it implies in
ranges::distance, as undefined behaviour; one marked std::unreachable() cured both and made MSVC report
range-v3's code after it as unreachable (C4702), which /external does not silence; and one that simply
moved the position brought MSVC's crash back. With equality constant instead, every such loop's condition
is false before its first step, and the step itself stays as it is for every width.
the-ordering-invariant¶
Every ordering in the library satisfies
std::lexicographical_compare_three_way(a.begin(), a.end(), b.begin(), b.end()) == (a <=> b)
which is stated on the reading, never on the storage: bit_block_container has no begin()/end(),
and boost::dynamic_bitset has no public iterators, so it cannot be written against a backend at all.
boost::dynamic_bitset::operator< is that algorithm over reverse iterators, a third order: a's highest bit
paired with b's highest, second with second, then a tie broken on width. It is not magnitude ordering,
though it coincides with one at equal width: "1" and "01" are both the number 1, and boost orders them
strictly, the shorter first. No reading here orders that way.
Where a specialization offers nothing faster, the default is that standard algorithm over the reading's own iterators — so the default cannot disagree with the specification, and only an optimization can.
the-hashing-invariant¶
Every value the library compares, it hashes, and
a == b implies hash(a) == hash(b)
under every reading. The cross-cutting protocols -- equality, ordering, formatting, ranges, hashing
-- follow the reading: the standard's own coverage, std::bitset, std::vector<bool> and
std::string hashing while std::array, std::set and std::pair do not, is history rather than design.
The engine is Boost.Hash2, and there are two hashers over it, which differ in what they hash.
xstd::hash<T, H, Flavor> hashes the value, as its standard model does. It is xstd-misc's, and runs Hash2's
hash_append, which finds each adaptor's tag_invoke hook. A hook appends exactly the message Hash2 1.92
builds for the model, so xstd::hash<T, H, Flavor>()(x) == xstd::hash<Model, H, Flavor>()(model(x)) under every algorithm and every
flavor, and a bit container hashes inside a user's Hash2-hashed aggregate as the standard container would:
| xstd type | model | message |
|---|---|---|
bit_array<N> |
std::array<bool, N> |
N bytes 0x00 or 0x01, no size; one '\x00' when N = 0, Hash2 having every append update |
bit_vector, bit_small_vector |
std::vector<bool> |
N bytes, then hash_append_size(N); an unspecialized boost::container::vector<bool> writes the same |
bit_inplace_vector<N> |
std::inplace_vector<bool, N> |
n bytes, then hash_append_size(n), as boost::container::static_vector<bool, N> |
| every set and set view | std::set<Key, Compare> |
each key in iteration order, descending under std::greater, then hash_append_size(count) |
A sequence's bools are not read through its proxies. detail/hash.hpp expands each 64-bit word of the bits
into 64 bytes, through detail/intrinsic.hpp: one _mm512_movm_epi8 where AVX-512BW is enabled, an
xstd::bit_expand per byte where xstd-ints finds PDEP fast, which is BMI2 on x86-64 short of the AMD processors that
microcode it and MSVC's /arch:AVX2, and otherwise, and in a constant expression, a 256-entry table of each byte's
eight bools. Eight words go to an update, and the last stops at the width: no padding bit is ever a bool. Chunking
does not change a Hash2 digest, so the expansion's grain is free. A set walks its keys, costing what std::set
costs, and appends each as Key, so its message follows the platform's std::size_t as the model's does.
bit_block_container, which is no reading, keeps its own hook over its blocks.
xstd::bit_hash<T, H, Flavor> hashes the bits, the representation rather than the model's value. Its message is
the canonical byte string of the bits: ceil(N / 8) bytes, bit i at bit i % 8 of byte i / 8, unused high
bits zero. A static width appends nothing after it, as std::array appends no size: the suffix would carry
nothing, and equality never crosses types. A run-time width appends hash_append_size of the width in bits
for a sequence. A set of run-time width cannot: equal sets need not share a width
(width-is-capacity), so its string stops after the byte holding its last key, and its
suffix is that byte count. An empty static width appends the one '\x00', by Hash2's rule. Wherever
block_copies_as_bytes holds -- a little-endian target, a built-in block, and value bits that fill its
object -- the first ceil(N / 8) bytes of the storage are that string, whatever the block type,
since the unused bits are clear; this is the identity bit_convert rests on. There the string goes in as one
update over the storage, from std::uint8_t blocks to unsigned __int128 ones. Elsewhere -- a big-endian
target, a block with padding bits, a class-type 128-bit block whose layout no rule proves, a constant
expression -- the same bytes are assembled by shifts, up to 256 at a time. Equal values thus hash equal across
storages and block types, which is why bit_hash declares is_transparent; xstd::hash does not, since equal
values of different types need not append the same bytes, as 42 and std::int64_t{42} do not.
The same message is public as xstd::bit_hash_append(h, f, x), for a user's own hook. It also takes a
contiguous range of one static-width container -- a built-in array, a std::array or a std::span, nested
arrays flattened -- and appends each element's string in turn, then the count where the range's type fixes
none. Where each element's object is its string, an owner whose blocks hold no byte past ceil(N / 8), as a
bit_fixed_set<64> in std::uint64_t blocks, and a nested std::array measures as wide as its elements, the
whole range is one update. A board game's position holding six bit_fixed_set<64> planes a side hashes each
side in one update of 48 bytes, and planes[2][6] in one of 96. A bit_fixed_set<50>, eight bytes for a
seven-byte string, goes in a plane at a time, to the same digest. The containers are not made trivially
equality comparable to get this: that would have Hash2 hash their objects and bypass the model hooks.
std::hash<T> is bit_hash<T, boost::hash2::xxhash_64>, which is also bit_hash's default, and it is a
default rather than a fact. It is xxHash at every length, with no switch to FNV-1a for a short string,
because FNV-1a does not avalanche: each byte is xored into the low bits and multiplied up, so the high bits of
the last byte never reach the low bits of the result, and the low bits are what a hash table indexes by. Two
one-word sets differing only in their top key would land in the same bucket. xxHash mixes every input bit into
every output bit at the end, at a cost a short string barely notices. It is spelled boost::hash2::xxhash_64
on every target, std::size_t taking Hash2's fold of its result where it is narrower. bit_hash declares
is_avalanching from xstd::hash_algorithm_is_avalanching, as xstd::hash does, so Boost.Unordered's
open-addressing tables skip their post-mix under xxHash or SipHash and keep it under FNV-1a.
std::hash cannot take another algorithm: its operator() has one argument and its type no parameter for
one. The door to another algorithm is bit_hash<T, H> for the bits and xstd::hash<T, H> for the value, each
seeded where H takes a seed. std::hash<T> is bit_hash at the default, so the two agree digit for digit.
At any other algorithm the digits differ and the invariant still holds, since it rests on what is appended and
not on who hashes it.
A digest beside the hash. The hash answers a std::size_t, Hash2's fold of the algorithm's result, which is
what a table indexes by. bit_digest<T, H, Flavor> answers the result itself, H::result_type, over the same
message, for a digest that stands in for its key: stored, compared or sent where a table's index would collide. Its
defaults differ for that reason. bit_hash runs xxHash-64 over native bytes, as std::hash does; bit_digest
runs XXH3-128 over little-endian bytes, so two keys stay apart and a digest is the same on every target. Both share
one seeded prototype as a private base, and equal bits digest equal across storages and block types as they hash
equal. The shape follows xstd-misc's value-level xstd::hash and xstd::digest.
So a caller chooses as follows. The default, through std::hash, is for keys the program makes: it is
unseeded, and fast at every length. A caller who knows the keys better names another Hash2 algorithm.
Keys an adversary can choose -- read from
the network, a file or a user -- need SipHash, seeded per container, which is Hash2's own advice: an unseeded
algorithm lets an attacker who knows it build a table's worth of colliding keys in advance, and SipHash is a
keyed function built to make that infeasible without the seed, at a speed a hash table can afford.
#include <xstd/bits/bit_functional/bit_hash.hpp> // xstd::bit_hash
#include <xstd/bits/bit_set/bit_set.hpp>
#include <boost/hash2/siphash.hpp> // siphash_64
#include <cstdint>
#include <unordered_set>
using hash = xstd::bit_hash<xstd::bit_set, boost::hash2::siphash_64>;
auto const seed = std::uint64_t{/* drawn at random, per container */};
auto table = std::unordered_set<xstd::bit_set, hash>(0, hash(seed));
Who hashes follows views-follow-their-precedent: the set adaptor owned or
viewed, as std::string_view hashes; the sequence adaptor as an owner alone, as std::span does not, so its
hook is constrained on ownership and a bit_span hashes no more than it compares. Both range adaptors tell ContainerHash they are not ranges: Hash2 chooses between its
range overload and a hook by enable_if, a range with a hook is ambiguous, and the range overload could not
hash the proxy the iterators return anyway. The harness checks the invariant beside ==, wherever a
std::hash exists, and each model's message under three algorithms and both fixed byte orders.
Coverage¶
per-instantiation-slots¶
gcovr counts branch slots per template instantiation and never merges them. A loop that a one-block instantiation can never enter is a branch never taken, whatever every other instantiation does.
So a degenerate width does not get an unreachable loop; it gets different code, via if constexpr on
static_num_blocks == 1 and == 2 in bit_block_container and in the set queries that read it, and on static_block_count and extent == 0
in the storage's own scans. The block count is derived from the width rather than declared for exactly this
reason: nothing new has to be supplied to know it, the block layout being fixed already.
The same gate is why a cursor lives inside the walk it belongs to rather than beside the block index: at one
block the walk is discarded, and a cursor declared outside would never be written — which
misc-const-correctness reads, correctly, as a variable that should have been const.
A test that only makes a call fail instantiates the path it never runs, and the same rule scores it. Three
cases here each handed append_range a views::iota | views::transform whose sum saturates, to check that the
growth ahead of the loop refuses it. Each wrote its own lambda, so each was a distinct closure type and a distinct
instantiation of the packing tier — three of them, and in all three that growth threw before the loop was entered, so every
branch in that tier was one no test took: five short of the gate, on the two lines that shape the loop. One
functor at namespace scope makes the three views one type, and a length the sequence can hold runs that
instantiation's loop for real. The general form: when a range or a storage enters a template only through the
path that refuses it, it arrives with the whole body's branches and none of them taken.
an-assert-begins-its-line¶
The gate excludes an assert from both counts, by --exclude-lines-by-pattern and
--exclude-branches-by-pattern over ^\s*(assert|[A-Z][A-Z0-9_]*_ASSERT)\( -- anchored at the start of a line,
and covering XSTD_BITS_ASSERT (hardened-preconditions) as it covers assert. An
assert written anywhere else on its line is not excluded, and assert(cond) expands to a conditional whose false
arm no test takes, so it is an uncovered branch per instantiation (per-instantiation-slots).
front() and back() on the sequence reading are four lines apiece for this, and no other reason. Their two
preconditions, each written on the line of the call it guards, cost seven branches across the deducing-this
instantiations and take the gate from 100% to 99.2%, where the same asserts in erase and index_of -- each on a
line of its own -- cost nothing at all. The rule is worth stating because the failure is silent at the point of
writing: the code is right, the assert fires as intended, and only the gate says otherwise.
while-not-for¶
Two descending walks are shaped as a while rather than a for with a fall-through, deliberately. The
precondition guarantees a set bit at or below, so a loop that could run out would carry an exit branch no
test can take — exactly what the coverage gate catches, and what find_if hid by keeping that branch inside
the standard library. Written as a while, both branches of the condition are exercised: empty blocks are
skipped, a non-empty one ends it.
one-function-per-tier¶
Each tier of each scan is its own function. readability-function-cognitive-complexity counts if constexpr
like any other branch, and the guards that satisfy the coverage gate put both scans over the threshold when
the tier choice shares the body. The tier was the seam anyway.
Platform workarounds¶
libcxx-views-take¶
all_but_last_are_ones uses an iterator pair rather than views::take.
libc++ 18 — Xcode 16.4 on the matrix — writes the return type of views::take's iota_view fast path as
decltype(iota_view(*begin(rng), ...)), so forming the adaptor's operator| over a range of 128-bit blocks
instantiates an iota_view over that block type, however unlike an iota_view a std::vector is. That
trips libc++'s bigger than integer-like type static_assert, which is a hard error rather than a
substitution failure.
views::drop, views::reverse, views::transform and views::zip are all clear. Only take carries it,
and only until Xcode 16.4 leaves the matrix.
msvc-completes-the-accessor¶
sequence_adaptor::operator<=> is a non-template friend, so its constraint is checked where it is
declared: inside the class, while the class is still incomplete. Written over the accessor —
requires is_owner and requires { x.storage().sequence_lexicographical_compare_three_way(y.storage()); }
— the member access on x.storage() makes MSVC deduce storage()'s return type there, which means
instantiating a body that reads self.m_bits, which needs the enclosing class complete. MSVC 18 answers
C2027, use of undefined type sequence_adaptor<...> at each of storage()'s three returns, then
C7683 and C3313 as the cascade, and C2102 wherever &self.storage() appears. GCC and Clang defer all of it.
The constraint is about the storage, not about the accessor, so it says so:
requires is_owner and requires (bits_type const& b) { sequence_three_way(b, b); }
bits_type is complete and already in hand, and for an owner it is exactly what storage() returns, so
satisfaction is unchanged on every compiler. The call is spelled as a free function because the orderings
are free functions over the storage rather than its members (the-ordering-primitive),
each declaring its return type so that checking the constraint instantiates no body. What makes MSVC complete
the class is naming a member of what the accessor returns, which is the shape the rule below is about; whether
a free-function call through the accessor would trip it too is unmeasured, the constraint not going through the
accessor at all.
Two neighbours look like the same shape and are not. The bulk operators take this auto&& self, so they are
templates and their constraints wait for a call, by which time the class is complete. And
set_adaptor::operator==, also a non-template friend, constrains on x.storage() == y.storage() — an
operator expression, which MSVC does not resolve eagerly the way it does a member access. The rule to carry
forward is narrow: a non-template friend's constraint may not name a member of whatever the accessor
returns.
gcc-array-bounds¶
The shift operators assert n_blocks <= last_block(), which is implied by the is_valid(n) above it. It is
stated again because GCC does not carry the range through xstd::div's aggregate return: without it,
-Warray-bounds reports the memmove inside shift_right at -O1 and -O2 whenever asserts are live.
No CMake build type is that combination — Debug is -O0, and the optimized ones carry NDEBUG — but
-O2 -g is one command away, and the bound is worth saying in any case.
gcc-nttp-implicit-zeros¶
GCC 15 mangles a class-type template argument without the zeros it was never given
(GCC PR 121231, #396). A value
whose leading or interior blocks were zero-initialized and never written takes the name of another value: {0, top}
is mangled as {top}, which reads back as {top, 0}, so two different values share one specialization, within one
translation unit as well as across them. GCC 16 mangles correctly, as an ABI change (-fabi-version=21) that is not
backported, and by default also emits the GCC 15 name as a compatibility alias; that alias is the correct name of the
other value, so across translation units the linker can merge f<{0, top}> with f<{top, 0}> unless
-fabi-compat-version=0 drops it. Clang 22 is correct in both.
The library meets it through its structural owners (structural-at-aligned-widths).
A default-constructed owner filled by insert, set or operator[] leaves a block implicit whenever its keys skip
one, so a value built by insertion can name a different specialization from the equal value built from its blocks.
Over 64-bit blocks that takes a key at or past position 64 with an empty block below it; over 32-bit blocks the keys
{0, 127} of a 128-bit owner already leave two. EqualValuesNameTheSameSpecialization in
test/src/bits/structural.cpp builds {0, last} both ways and asserts one specialization, and on GCC before 16 it
skips that assertion for an owner of more than two blocks, whose interior blocks are the implicit ones. GCC mangles
correctly once every element has been stored, so writing every block explicitly during constant evaluation, under
if consteval, would keep every value the owners produce clear of the defect; that is untried, and needs a check
that it changes nothing at run time or under MSVC.
uint128-printing¶
Never BOOST_CHECK_EQUAL on a block-typed value. It prints its operands on failure, and no standard library
defines operator<< for __int128, so a graded_extents sweep that includes xstd::uint128 fails to
compile on libc++ where it happens to compile on libstdc++. BOOST_CHECK compares without printing.
Naming¶
test-not-subscript¶
bit_block_container::test reads a bit, and cannot be written through. std::bitset's operator[] returns an
assignable proxy and test returns bool, so a subscript to a bit would promise an assignment that does not
compile. bit_block_container's operator[] is the subscript of the blocks it wraps -- a std::array, a
std::vector, a std::span -- and yields a block, read through a const storage and written through a mutable
one, after which the caller restores the unused bits with erase_unused. Only the sequence containers lift the
subscript to a bit: bit_array's operator[] is std::array<bool, N>'s, and returns the proxy.
The writable proxy belongs to the containers above, which is also where the checked reading lives —
std::bitset::test throws where this asserts, a difference the containers state as a guard rather than
one this name should try to carry.
qualifier-prefixes¶
Contract qualifiers are prefixes, not suffixes — inclusive_find_next, exclusive_find_prev,
growing_insert — so that the contract reads before the operation and the cheaper form cannot be called by
mistake. growing_insert is the one that earns the rule twice over: it sits beside an insert that asserts
is_valid(n), and a silent substitution of one for the other is a precondition quietly dropped.
Test harness¶
scratch-objects¶
checker holds two scratch objects, reset before each mutating check, rather than taking a fresh copy per
check. Same-width assignment reuses the storage, so this is two allocations for the whole checker instead of
one per operation — of which positions() alone did five per bit.
It is faster, and it leaves the optimizer one object to follow rather than thousands of construct/destroy
pairs. Three GCC versions have each mis-analysed the copying shape in a different way —
-Wfree-nonheap-object on 15, -Wrestrict on 17-SVN — and moving the copies around only moved the
diagnostic. The whole-container mutators get one method apiece for the same reason: at -O3 GCC 15 inlines
a combined sweep into a single function and then reports -Wfree-nonheap-object on the vector copies, which
ASan, LSan and UBSan all say is not there.
The scratch objects are held by reference, owned by check_ops: by value they would be the only members
narrower than a pointer, and -Wpadded reports the tail padding that leaves.
counted-not-asserted¶
Disagreements are counted rather than asserted per bit. A passing assertion per bit says no more than one,
and a failing one drowns the log. Two helpers turn the comparison into a count, so the cast
readability-implicit-bool-conversion asks for has two sites rather than thirty.
seven-patterns¶
The sweep uses seven patterns, chosen so every pair lands on both sides of each branch: all clear and all set for the whole-container shortcuts, the strided ones for partial blocks, and the endpoint ones for the first and last block specifically.
The last pattern fills every block but the last, which is the only way to reach the right operand of all()'s
short circuit with a false: every other pattern either fails a block below the last, or fills the last one
too. It compares block indices rather than a precomputed bit count, so that a single-block width — for which
it selects nothing — does not fold into an unsigned comparison against zero.
width-zero-comparisons¶
An unsigned comparison against zero is a diagnostic on both major compilers: -Wtype-limits on GCC, and C4296
on MSVC, which is what is_valid() carries its own note about. At a width of zero, i < n with n folding to
a constant is exactly that, so the sweeps use views::iota instead.
The same folding is why lambdas capture by reference throughout rather than naming what they use: under a
static width the compiler folds those to constants, and naming something usable in a constant expression is
what -Wunused-lambda-capture reports.
the-audit¶
The clauses under test/src/spec/ cite the paragraphs they check, which is a numerator. doc/audit/ holds the
denominator: one file per audited specification, pinned to the draft commit it was read from, with a row for
every numbered paragraph of every leaf clause, saying which element it states and what the tests do with it:
answered where a test cites it, declined or forced where a note says why not or what instead,
no-requirement for text with nothing to test, and gap for what is testable and not yet cited.
python3 tools/audit.py reads every table there and every citation under test/, lists and ranges expanded,
and fails on an answered row nothing cites, a cited paragraph with no row, an unexplained declined, forced
or no-requirement, one that a test cites all the same, and any gap unless given --allow-gaps. It prints
the numerator against the denominator per clause, and the Audit workflow runs it on every pull request. When the
draft moves, re-pin the file, renumber the rows, and let the checker name the citations left behind.
Views and containers¶
the-two-adaptors¶
Two class templates carry the two readings: set_adaptor and sequence_adaptor. Each is
written against bit_block_container and against nothing else, so one adaptor serves
bit_block_container over std::array, std::vector and the inplace blocks alike, at both widths and
in both ownerships (one-storage). Each takes the parameters its own reading needs and no
others: set_adaptor<Bits, Store, Derived, Key, KeyMapping, Compare> and sequence_adaptor<Bits, Store, W, Derived>, the set reading
never windowed and the only one with a key.
The two are internal: the owners and the three views are the public surface, and no public template
argument list, deduction guide or specialization spells an adaptor. Every public name is a class deriving from
one of them, passing itself as an argument so that the adaptor names it back: basic_bit_fixed_set<Key, B, N, KeyMapping, Compare> derives from
set_adaptor<bit_block_container<std::array<B, K>, N>, storage::owned, basic_bit_fixed_set<Key, B, N, KeyMapping, Compare>, Key, KeyMapping, Compare>. The short layer stays
an alias fixing the block, and for a set the key: bit_fixed_set<N> and bit_array<N> are those at std::size_t.
Deriving is what keeps a value-returning operation -- & | ^ -, operator~, the shifts, xstd::bit_convert --
handing back the container the caller named rather than the vehicle under it
(the-views-are-the-adaptors).
the-grid¶
The containers are a grid, and nothing else. Two readings over three storages, every cell occupied, every
one an owner and none windowed. Each is a class deriving from its reading's detail adaptor
over its storage, basic_bit_array<B, N> from the sequence adaptor over
bit_block_container<std::array<B, num_blocks_v<B, N>>, N>, and each writes out the constructors of the
standard container it packs (the-container-adaptor). A reading over a storage is
reached through its owner's name and no other, so the grid is a fact about the public surface rather than a
construction imposed on it.
The readings are flat: neither nests inside the other. A set of positions is no sequence of bools and a sequence no set, and the relation views need is a predicate on the owner rather than a base class (the-readings-do-not-mix).
The grid is not written out as a template. One basic_bits<R, C, Block, N, Alloc> taking a reading tag and a
container tag and naming one cell, with the basic_ names aliases over it and the readings aliases over a general
adaptor<R, ...> keyed on the same tags, would pay twice for the uniformity. An alias template cannot declare a
deduction guide, so no owner could have one while the views, being classes, do. And a partial specialization
cannot be befriended: a friend declaration naming the template is a redeclaration, which cannot add a
requires-clause to single one reading out, so the only form left would make every reading a friend of every
other. Without the tags the friendships follow the constraint -- set_adaptor befriends set_adaptor and
sequence_adaptor befriends sequence_adaptor -- and a diagnostic says basic_bit_array<unsigned long, 100>
rather than a five-argument cell name.
What a tag would key on, each container names outright: its base clause says
std::array<Block, num_blocks_v<Block, N>> where a tag map would take array_container_tag to a storage's name.
Each storage is answered where it is defined, one header apiece, so a container includes only the vehicle it
uses: measured, a central switchboard has bit_array.hpp pulling <vector>, which is exactly what the vehicle
split prevents.
The axis is open to users. What a run-time width's storage has to satisfy is the public bit_storage, which
publishes the exposition-only owned_bit_blocks (owned-bit-storage) with growth and the
all-or-nothing resize the no-effects guarantees rest on, and says nothing about where the blocks live.
basic_bit_vector, basic_bit_set and basic_bit_valarray take one as std::flat_set takes its KeyContainer,
and a cap in bits below a constant capacity: the inplace and small columns are aliases naming two storages, and
ext/boost.hpp joins on that and nothing else. An arena's std::pmr::vector, Abseil's or LLVM's small vector, or a
file-backed slice is an argument, not a new owner, and two choices coexist in one program with no setting to agree
on. Rebinding goes through bit_storage_rebind, which a storage of another shape specializes beside itself. The fixed
widths keep <Block, N>: a width the type fixes is not something a storage holds.
the-container-adaptor¶
The grid has two container adaptors, one per reading -- set_adaptor and sequence_adaptor
in xstd::bits::detail, in the manner of std::stack<T, Container> and std::flat_set<Key, Compare, KeyContainer>
-- each over a bit_block_container open to any owned_bit_blocks without a new class for it. They are
internal. The public surface is the owners and the three views, and an adaptor over a storage no owner
names is reached from inside the library and its tests alone.
The owners are classes deriving from the adaptors, not aliases over them. Each passes itself as its adaptor's
last argument over the bit_block_container its base clause spells, which owned_bits_t names; a second
class over the same storage is a distinct type all the same. Each writes out its constructors in the order and
notation of the clause it packs: [set.cons] for the sets, less the allocator forms where the storage has
none; [vector.bool.pspc] for basic_bit_vector, each allocator form beside the form without, and
offered only where the storage has an allocator, which covers [inplace.vector.overview]'s for its inline storages; and for basic_bit_array, std::array being an aggregate, the default and initializer-list
constructors its aggregate initialization stands for. What the clause lacks and the adaptor offers -- the
from_blocks doors -- follows the standard ones, each marked as not in the clause. The copy and move constructors, both assignments and the
destructor are left to the compiler, which declares each as the adaptor underneath answers it, noexcept and
triviality included: a static width stays trivially copyable, and a polymorphic allocator's move assignment stays
potentially throwing, with nothing spelled on the owner to fall out of step. Each declares its own deduction guides and hidden swap, and
specializes std::hash, Boost's is_range and is_tuple_like where it is a range, and for basic_bit_array
std::tuple_size and std::tuple_element: a partial specialization on the adaptor never matches a class derived
from it.
The empty allocator base is keyed on its adaptor, which is what keeps two classes over one storage apart. It
carries the defaulted operator== the defaulted comparisons above it need; shared between two adaptors over one
storage, both would convert to the one empty base and compare equal whatever their bits. Keyed, the comparison
does not compile, as std::set<int> against std::flat_set<int> does not.
Classes deduce without CTAD for alias templates. Owners declared as aliases over the adaptor would reach every
guide, the from_blocks tag's among them, through the alias (P1814). A probe on every
leg (#228, stand-in types, one static_assert per case) finds three rules for that arrangement:
- A guide spells the storage as the alias does. The return type computes the block count from the width,
adaptor<std::array<B, blocks_for<B>(W)>, W>, and never names it,adaptor<std::array<B, K>, …>:Kmeetsblocks_for<B>(N), which [temp.deduct.type]/5.3 makes a non-deduced context. - The one-block guide carries a defaulted count. MSVC 17 (14.44) drops a guide whose only template parameter
is the block type when it builds the alias's guides (
C2641,C2976);template<std::unsigned_integral B, std::size_t K = 1>, spelled as the array guide is, is kept. - Nothing deduces through a name that pins the block type.
bit_array<N> = basic_bit_array<std::size_t, N>as an alias of an alias deduces on neither MSVC 17 nor MSVC 18 (C2641), so a pinned name that something deduces through has to be written over the adaptor directly.
MSVC also refuses alias deduction through an alias basic_bit_fixed_set itself, with C7602, and that is what
makes the owners classes. A class takes its guides as its own and needs none of the three rules: the guides name
basic_bit_array<Block, N> rather than computing a block count, and bit_fixed_set<N> is an alias of
basic_bit_fixed_set<std::size_t, std::size_t, N>, one alias over a class, the depth every compiler deduces
through. The one-block guides need no defaulted count either. The views are a separate question from the owners
(the-views-are-the-adaptors).
owning-is-ours¶
Owning is ours and viewing is interop. An owning adaptor sits over blocks of ours or a bit_storage the user
names, bit_block_container over the blocks themselves, and never over a foreign bitset: the owning
set_adaptor and sequence_adaptor take their ordering from the storage's own
three-way members, with no synthesized fallback for a storage without them. A view reads blocks, and a foreign
bitset reaches either reading by conversion.
The split is what a foreign owner would cost against what it would buy. Every owner-only member -- construction
through the storage's constructors, growth, the saturating shifts, the checked element access, the ordering
-- would need an entry in each foreign trait and harness arms over each foreign storage, and the blit and the
range insertions of #80's 7d more again. It would buy one object where a copy gives the
same reading: xstd::bit_convert<xstd::bit_fixed_set<N>>(my_std_bitset) is the set reading of a std::bitset
someone else owns, and bit_fixed_set holds it in storage of ours. What is given up is the wrapped-equals-raw proof
that the wrapper adds and drops nothing; the views keep it for reads, writes, iteration, searches and hashing,
and construction, growth and the ownership protocol are proven over our storages alone.
A view over a foreign type would need a trait as its contract: extent, size, at, count,
unchecked_assign, insert, fill, and the searches and block reads where the type has them, with a checked_*
family and checked shifts that only a wrapper asks, and an operator-= and operator- on std::bitset that
[namespace.std] does not let a program declare. None of it exists: bit_set_view has -=, std::bitset has no
set difference of its own, and with no foreign storage to adapt, a trait would have nothing to abstract over
(one-storage).
ownership-is-not-an-axis¶
Owning versus viewing is storage lifetime, not a third axis of the model, and it collapses to one template
parameter: storage::owned stores Bits, storage::borrowed stores Bits* or, over span-backed storage, a
copy of it (borrowed-blocks). Always present and only its
type changes, so a plain conditional_t rather than conditional_data_member_t. One accessor, via deducing
this, gives deep const to the owner — self.m_bits propagates self's const — and shallow const to the
view — *self.m_bits does not — for free.
Every mutator is then gated on the storage and nothing else: requires requires { self.storage().op(…) }
reads "the storage lets this handle write". A const owner's accessor hands back a Bits const&, which has no
assign to reach; a const view's hands back Bits&, which is what a view is for; a view over Bits const
hands back Bits const& again. Const and ownership are the same question, asked once, and answered by the type
the accessor returns. The exceptions are the constructors and, once storage grows, the growth members, which need an explicit
requires (owns(Store)): the requires-expression tests what the storage can do, not what this handle may do to
it, and a view over a bit_block_container over std::vector must not be able to resize what it does not own.
views-follow-their-precedent¶
bit_set_view follows std::string_view: a value that happens not to own its bytes, so it has == and
<=>, and its ordering is exactly std::set's. As [string.view.comparison] asks, it compares with whatever
converts to it: its owner, and a view of the same bits that are not const, since both views convert from
mutable to const bits as std::span does. Those are the only mixed comparisons; two owners compare only within
their own type, as std::array and std::vector do. bit_span follows std::span, which P1085 stripped of both
because "same referent" and "same contents" are both defensible readings of a handle. So set_adaptor
compares and hashes whatever it owns or views, and sequence_adaptor compares and hashes only as an owner. The non-member copies
— ~, &, |, ^, -, <<, >> — are the owner's alone in both readings: a copied view would write
through to what it views.
A view's empty base is xstd::empty_base_type<>, which carries nothing — no ==, no <=> — so
bit_span's incomparability is simply absent, with nothing to delete. That is the whole reason xstd-misc has
two empty types rather than one. empty_member_type keeps a defaulted <=> so an enclosing class can default
its comparisons over the member, and that is safe there: a member's associated classes are not the enclosing
class's, so the hidden friend is invisible to it. A base's ARE the derived class's, so the same defaulted
comparison would be found by ADL for every bit_span and would answer equal for any two of them, having only
the empty base to compare.
The alternative is an incomparable_base deriving from the one empty type and deleting the two comparisons it
brings. That works — the deleted pair takes sequence_adaptor exactly where the base's takes the empty type by a
derived-to-base conversion, so it wins overload resolution by [over.ics.rank]/4.4 and the answer is ill-formed
rather than wrong — but it needs BOTH deletions, and neither implies the other: <=> rewrites the four
relationals and never ==; != rewrites from == and never from <=>; and a defaulted <=> implicitly declares
a defaulted == beside it ([class.compare.default]). Deleting == alone leaves <, >, <= and >=; deleting
<=> alone leaves == and !=. Nor can it be written where it belongs: MSVC rejects a trailing requires clause
on a deleted friend (C7599) where it accepts one on a defaulted friend, so the pair cannot be constrained on
not is_owner in the adaptor and has to sit in a base of its own. Two empty types upstream need none of that.
What stays is the assertion. not equality_comparable and not three_way_comparable in the harness are
joined by the seven spellings, and the owner is asserted to answer all seven beside the view answering none, so
they are the view's shape rather than a concept nobody satisfies. Those seven go through named concepts rather
than bare requires (View a, View b) { a == b; }, because an absent or deleted overload is not assertable the
direct way: it is a hard error where the requires-expression names a concrete type — measured identically
on GCC and Clang — and a soft false only when the check reaches the type through a template parameter.
Both referring adaptors opt into std::ranges::enable_view and enable_borrowed_range, the two
specializations [range.view] and [range.range] invite for a program-defined type. The first makes
bit_set_view(x) | views::take_while(…) take the view as it is rather than wrapping it in an owning_view;
the second says what span says, that the iterators point at the storage and outlive the handle that made
them, which is what lets a caller return bit_set_view(c).begin() from a temporary.
the-comparison-is-a-hidden-friend¶
Both adaptors spell operator== as a hidden friend. A defaulted comparison need not be a member:
[class.compare.default]/1 admits a non-static member or a friend, and sequence_adaptor defaults a
constrained friend. The set reading writes both of its overloads out instead, for a reason that is about coverage rather than about comparison and is the
last subsection here.
The choice is unobservable since C++20, which is what lets it rest on other grounds. Where a converting
constructor is not explicit, as
std::bitset's from unsigned long long is not, a mixed comparison compiles; which spellings compile depends on
which parameter can take that conversion. Measured over a class template with an implicit width-carrying
constructor, per standard:
C++17 x == u |
C++17 u == x |
C++20 x == u |
C++20 u == x |
|
|---|---|---|---|---|
member, as std::bitset has it |
yes | no | yes | yes |
| hidden friend | yes | yes | yes | yes |
| namespace-scope function template | no | no | no | no |
The member's implicit object argument never converts, which is the C++17 asymmetry. P1185's reversed
candidates ended it: u == x now also considers x == u, reaching the member with the integer as the
argument that converts, so member and friend became indistinguishable. Defaulted == arrived in the same
standard, so there is no era in which the friend could be defaulted but behaved differently.
The third row is why "non-member" is not the useful distinction. A hidden friend of a class template is a
non-template function, one per instantiation, so both parameters take conversions normally. A
namespace-scope template deduces instead, and deduction never considers user-defined conversions -- it
rejects x == u and u == x, in every standard, the reversed candidate failing to deduce just as the
first one did. The hidden friend only ever accepts more. A namespace-scope template would also need the
forward-declaration and friend operator==<> dance for private access: three declarations in dependency order
for one operator.
the set reading writes its equality out¶
set_adaptor carries two operator== and neither is defaulted. The general one answers through set_equal,
because width is capacity for this reading and two sets holding the same positions are equal at any two widths
(width-is-capacity). The other, the static-width owner's, could be = default and is
return x.storage() == y.storage(); instead.
The two spellings mean the same thing and do not cost the same. A defaulted comparison compares base classes
before members, and an owner here derives from bits::detail::allocator_base_type -- an empty class carrying
the storage's allocator_type where there is one and nothing at all where there is not. Its own defaulted
operator== can only answer true, so the defaulted form emits a call and a branch that no input can send the
other way. Probed with equal operands, with operands differing in the first block, with operands differing in
the second, and with an object against itself: that edge stayed at zero every time.
Which is a curiosity until a gate counts it. gcovr is told to drop branches on a line matching
= default;, which is there to excuse exactly this synthesized branch -- and the pattern matches a LINE, not
a declaration. sequence_adaptor's comparison and bit_block_container's each fit
on the one line that carries = default;, so the exclusion finds them. set_adaptor's static-width overload,
defaulted, wraps over three, its trailing requires-clause being longer than everything else about it, and gcov
anchors a function's branches at the first line of its declaration -- where there is no = default; to match.
One construct, three sites, and the only one the gate sees is the one whose constraint is too long to fit on a
line.
So the two siblings carry the same unreachable branch, excused by their formatting rather than by being built differently. Writing the member comparison out removes it instead of excusing it and needs no exclusion to pass, and it is what the comment above it claims: a static owner's equality IS its one member's. Reformatting it onto the pattern would work as well and teach nothing; writing all three out would spend two defaulted comparisons to buy symmetry.
the-views-are-the-adaptors¶
bit_set_view<Blocks, N> derives from set_adaptor<Bits, storage::borrowed> and bit_span<Blocks, N> from
sequence_adaptor<Bits, storage::borrowed, window::all>, where Bits is the storage the blocks map to
(the-views-are-named-by-their-blocks), each passing itself as the adaptor's last argument so that
the adaptor hands back the view; bit_subspan is the same shape with the window argument set. They are the
referring adaptors under the names of the-public-names, one header each, the two sequence views in <xstd/bits/bit_span/>,
and not a second implementation of either reading. Views with iterators, proxies and customization points of
their own — a set_find, a sequence_find, a block_access and a bit_extent — would be a storage trait under
another name, and with the adaptors reading the storage alone there is nothing left for one to do.
The views are classes, not alias templates. An alias is the adaptor, so every opt-in the adaptor carries would apply to the name for free, and a storage the library does not wrap would be diagnosed where the alias is written. Both claims are correct, and a class pays for both rather than collecting them (below), because an alias costs something neither buys back: the name itself.
Deduction through an alias is what MSVC 17 cannot be relied on for. It is class template argument deduction
for alias templates (P1814), which Clang has had since 19 and GCC since 10. On this
shape -- one pinned non-type argument plus a defaulted, constrained argument depending on the first -- MSVC 17
(VS 2022) has answered C2976: 'xstd::bit_set_view': too few template arguments alongside C2641: cannot deduce
template arguments, 151 times over both views in msvc and msvc_analyze, Debug and Release, where MSVC 18
(VS 2026) deduces through the same aliases. Microsoft's conformance table lists [P1814R0](https://wg21.link/P1814R0) CTAD for alias
templates as VS 2019 16.7, gated only by a flag this project clears at /std:c++23; the table describes the
feature and not this shape of it, and a diagnostic quoted from the compiler is the better evidence. A probe (#237,
asked in #235) that puts the two view guides back on the detail adaptors and deduces through aliases of that
shape deduces every case on the MSVC 17 the stable rung resolves to, as on MSVC 18, the Preview and clang-cl, so
the 151 diagnostics belong to an older toolchain and the classes are a choice rather than a compiler's limit. The
probe tests the alias shapes, not today's headers. #235 weighed aliasing the views and took them to blocks instead
(borrowed-blocks), the guides for which are the classes' own.
The stable MSVC rung costs two workarounds, both for MSVC 17 alone. It does not implement
P0849R8 — C3878 and C2760 at f(auto(pos)) — so the functor's argument is
copied through a three-line decay_copy helper in each of two adaptors rather than through auto(x)
(the-functor-takes-a-value). And it rejects std::integral T = X::key_type on the
default argument of a constrained type-parameter, C2061: syntax error: identifier 'integral', where GCC, clang,
clang-cl and Apple clang take it, so test/include/test/set/primitives.hpp keeps that one typename under a
NOLINT; it is the one site in the tree where P0634R3 permits the omission, the
remaining typename X::value_type sites being template arguments and functional casts, which P0634R3 does not
reach. msvc.yml and msvc-analyze.yml carry
stable,qualification,development.
That is the shape of the trade rather than a verdict on it. A rung costs whatever the vendor's oldest front end cannot do, paid in workarounds that the other five toolchains carry without needing them; what it buys is that the front end keeps building this library. Here the price is a helper and a keyword.
The constraint is the MSVC compiler, not the VS 2022 platform. clang_cl passes on all three rungs, 2022
included, because clang-cl is Clang and Clang has had P1814 since 19, so VS 2022's runner, STL and platform are
covered whatever the MSVC 17 front end asks.
What deriving costs is restatement, and every entry is paid. A derived class needs its own constructors,
its own two deduction guides, and its own enable_view, enable_borrowed_range, std::hash and is_range
specializations, because a derived class is not its base to a partial specialization — the base's opt-ins
say nothing about the derived name. Each view carries all of them, the trait specializations delegating to the
adaptor's through adaptor_type rather than restating a body. The constructors are inherited, with
using base_type::base_type; and using base_type::operator=;; the second matters, because a derived class's
implicit copy-assignment hides every base operator=, the initializer_list overload included. Inheriting
constructors inherits the primary's guides as well (P2582, which GCC implements), which would tie with restated
guides — so each view's pair is spelled on its own name, where the primary's cannot be what a consumer deduces.
The diagnostics were the other entry, and restating the constraint settles them. An alias is transparent,
so a storage the library does not wrap is diagnosed where the alias is written: void f(my_set<int>) is an
error at that declaration, quoting the unsatisfied constraint. A derived class that leaves the constraint to
its base is not: naming one in a declaration does not require a complete type, so the same line compiles,
the base is never instantiated, and the diagnosis waits for whoever first completes the type. It then arrives
twice, because a dependent base is named twice and cannot be named once -- in the base-specifier, and again in
the using-declaration that inherits the constructors. Measured on set_adaptor over a storage the library does
not wrap: 13 lines and one error through the alias, 22 lines and two errors through such a derived class.
Restating the constraint on the derived class's own parameter recovers all of that, and then some: it fails at
the declaration, once, in fewer lines than the alias, having no indirection to explain. Which is why all
three views spell their constraint, xstd::bit_blocks Blocks, on their own template
parameter instead of leaving it to the base. A four-line reduction holding a constrained class template, an
alias of it, and both derived forms reproduces the shape exactly, GCC and Clang agreeing to the line, so it is
the language rather than a diagnostic quirk. The failure mode is the derived class that skips the restatement,
and it would bite the views and only the views: the owners choose their own storage, so a storage the
library does not wrap cannot arise through them at all, the one parameter a user supplies being the Block,
constrained at every layer. A view takes the storage -- that is what a view is for
(owning-is-ours) -- so a view is exactly the place where a constraint left to the base would
go undiagnosed until use.
The printed name is what the classes collect. An alias is not what a compiler prints, so over aliases a
diagnostic about bit_array<100> would name sequence_adaptor<bit_block_container<array<unsigned long, 2>, 100>,
...>, a spelling the user did not write and cannot write back. std::string makes that trade and the world lives
with basic_string<char, char_traits<char>, allocator<char>> -- but std::string aliases a class, where two
layers of aliases would let the printed name fall through to the adaptor rather than stop at
basic_bit_array. Each owner is a class, so the name stops there: a diagnostic about bit_array<100> says
basic_bit_array<unsigned long, 100>, a name the user can write back.
One constraint sits in two places rather than one. The guide for a plain storage is viable for an owner too,
an owner being nothing but a storage under a wrapper, and would tie with the owner guide -- so it is
constrained to non-owners, as an-owner-reads-as-its-storage describes. It is
written on set_adaptor's and sequence_adaptor's own guides, and again on each view's restated pair, which
is where a consumer's deduction lands.
The sequence view pays the span half of views-follow-their-precedent by
being the adaptor: it has no == or <=>, and the harness checks the sequence reading through the iterators
instead.
A trait the views specialize names the windows. sequence_adaptor returns a window from first, last and
subspan, and the public name of that window is spelled in blocks the adaptor never sees: bit_subspan<Blocks,
Extent, N>, where the adaptor holds only the storage. So the adaptor asks window_of<Derived, Bits, E>, whose
primary answers the adaptor windowed, and detail/views.hpp specializes it for both views from declarations of
their templates, which carry no default arguments since those are given once, on each class. bit_span/bit_span.hpp and
bit_span/bit_subspan.hpp include it before defining their class -- the specialization has to exist when the base's member
declarations are instantiated, which is while the view is still incomplete. blit_source needs no entry
there: whatever derives from its adaptor_type, a view or a sequence owner, blits as that adaptor does. That is the hook back into the header above, and it
spares the adaptor a forward declaration of bit_subspan.
the-views-are-named-by-their-blocks¶
Every public view is named by blocks, never by the storage built over them: an unsigned block, or a sized
contiguous range of unsigned blocks that subscripts, which is the public concept xstd::bit_blocks. A packed container
has bit storage and is not bit storage itself: bit_set is a range of keys, bit_array of proxies, and
std::bitset has one too without exposing it. bit_block_container
is a storage device and appears in no public template argument list. detail/blocks.hpp holds the map:
| blocks | a view stores |
|---|---|
std::uint64_t |
bit_block_container<std::span<B, 1>>, by value |
std::array<B, K>, std::vector<B>, ... |
a pointer to an owner's storage over the same blocks |
std::span<B, E> |
bit_block_container<W>, by value |
The views line up as std::span<T, N> does -- bit_set_view<Blocks, N> -- and N defaults to the width the blocks name, so a view needs it only over an owner
of a narrower width, such as bit_span<std::array<std::size_t, 1>, 20> over a bit_array<20>. A default in a
public argument list is public too, so that width is xstd::bit_blocks_extent_v<Blocks>, beside the concept: a
block's digits, all the bits of std::array<B, K>, B[K] or std::span<B, E>, and std::dynamic_extent for everything
else, a span of dynamic extent included. The storage keeps its own sentinel for that span's width, and it never
reaches an argument list.
bit_subspan<Blocks, Extent, N> takes the extent second, so bit_subspan<Blocks, 4> is a four-bit window as
std::span<T, 4> is four elements.
The guides answer in blocks. A block deduces itself and a const block itself const: bit_set_view(board) is
bit_set_view<std::uint64_t>. A range that is not one of ours has no storage object for a view to point at, so
it deduces the span that lends it: bit_span(blocks) over a std::vector<std::uint32_t> is
bit_span<std::span<std::uint32_t>>. An owner is named by its block and width instead, and deduces both from
the blocks it is handed: basic_bit_fixed_set(from_blocks, block) is a basic_bit_fixed_set at the block's
digits, stored as a block array of one.
bit_blocks is stricter than what itsy-bitsy's bit_view<R> adapts, which reaches its blocks through R's
iterators and so takes a std::deque of blocks. Contiguity is what the rest leans on: a view borrows the blocks as a
std::span, a conversion between two things that have bit storage is one memcpy of the blocks, and the block-wise
algorithms run on raw blocks. So bit_blocks<std::deque<std::uint32_t>> is false, and asserted false.
windows¶
bit_subspan<Blocks, Extent, N> derives from sequence_adaptor<Bits, storage::borrowed, window::sub>: the referring adaptor
windowed, and the name first, last and subspan hand back on a bit_span or on another window. It is a
class for the reason the other two views are (the-views-are-the-adaptors), and
being one is what lets the adaptor return it by name rather than by respelling its own parameters. It stores what
std::span stores, a pointer and a size, with the pointer's role split over a pointer and a position
because bits are not addressable: the sequence iterator's two fields and a count, 24 bytes beside the whole
view's 8. The offset is applied once, in a private offset() that answers zero for every other shape, so
begin(), operator[], at, front and back are written the same way for all three.
The three members are [span.sub]'s, on a view and never on an owner, since std::array and std::vector
have no subviews either; they assert their preconditions rather than throw, and std::dynamic_extent is the
to-the-end sentinel. A window is a view in std::ranges' sense and borrowed like span, and like span it
neither compares nor hashes (views-follow-their-precedent).
A window's end blocks are shared with what lies outside it, so every write to one is masked blocks:
xstd::ranges::bit_fill over a window is bit_block_container::set(pos, len, value), a block at a time through
block_at, and the copies, bit_sort and bit_partition write a window the same way
(algorithms-not-members). Like std::span, no view combines: elementwise logic is
the valarray reading's, over an owner (the-valarray-reading), and no sequence has shifts,
window or whole (no-shifts-on-a-sequence).
A window over a const storage writes nothing, and the predicate has to be asked of the right type to say so.
bits_type is Bits with the const stripped, because a view over a const owner names Bits const and the
typedef wants the storage's own name. Asking whether that takes a write is always yes, so a writer is
constrained through the storage expression itself, which carries the const: xstd::ranges::bit_fill drops out of overload
resolution on a const window rather than failing inside its body. set_adaptor's const view does the same: every
one of its mutators constrains through the storage expression.
static-windows¶
first<Count>(), last<Count>() and subspan<Offset, Count>() are [span.sub]'s compile-time three, and they
hand back bit_subspan<Blocks, Count, N>: a window whose width is its type's, as std::span<T, N>'s is. The extent is
the adaptor's fifth parameter, defaulted to std::dynamic_extent so that a four-argument spelling means a dynamic
extent, and it is the adaptor's and not the derived class's because the derived class is incomplete when the base
lays out its members -- which is where the count has to disappear. It does: the count is a
conditional_data_member_t, so a static window is the pointer and the position, 16 bytes beside the dynamic
window's 24, and size() is the extent.
Where the type already says a request cannot fit, it is ill-formed rather than asserted: first<21>() over a
view of a twenty-bit array, or subspan<10, 11>() over the same, fail their constraints, as [span.sub] makes
them fail. Over a run-time width the count is checked at run time, which is where the storage's width is known.
The extent of subspan<Offset>() is what is left of a static one, and dynamic otherwise. Between extents the rule
is std::span's: a static window converts to a dynamic one implicitly, and a dynamic one to a static one only
explicitly, asserting the sizes agree.
What is not here is a static offset. A window's start is a bit position, so unlike std::span's it cannot be
folded into the pointer unless it is a whole number of blocks, and putting it in the type is a step past anything
the standard has; the position stays a member.
constant-sizes¶
A size-like member whose value the type fixes is a static std::integral_constant, and empty a
std::bool_constant, after Jonathan Müller's
static constexpr std::integral_constant idiom:
static constexpr std::integral_constant<std::size_t, N> size = {};
static constexpr std::bool_constant<N == 0> empty = {};
| type | constants | functions |
|---|---|---|
bit_array<N> |
size, empty, max_size |
-- |
bit_subspan at a static extent |
size, empty, max_size |
-- |
bit_inplace_vector<N> |
capacity, max_size |
size, empty |
bit_fixed_set<N>, bit_inplace_set<N> |
max_size |
size, empty, which count the keys |
The run-time widths keep functions, and so do the whole views, bit_span and bit_set_view, whose width is
whatever they borrow. A set has no capacity at any width (growth).
One declaration gives three spellings. A::size is a value whose type carries N, for metaprogramming by type;
A::size() and a.size() call integral_constant's operator(), which is constexpr and noexcept and returns
std::size_t. So every call std::array, std::span or std::inplace_vector allows compiles with the same result
type and noexcept, and the synopsis checklists hold as written. The one observable difference is &A::size, which
[namespace.std] does not let a program take of a standard library member in the first place. Through a parameter
A const& a, std::remove_cvref_t<decltype(a)>::size is a constant expression on every compiler; a.size is one
only under P2280, as a.size() on a std::array is.
The adaptors declare none of these members. A static data member cannot carry a requires-clause, and a member
function size() declared in the adaptor hides a base's size even where its own constraints fail, so the members
come from a base between the adaptor and its storage, chosen per column: fixed_sizes, inplace_sizes or
run_time_sizes under the sequence reading, fixed_max_size or run_time_max_size under the set reading. The
adaptor's using-declarations make them visible to its own unqualified calls, and a call size() there resolves
through operator() as it would through a function. capacity is not among them, a fixed column having none, so the
inplace column's own members name it as members_type::capacity().
Two consequences follow from the members being constants rather than functions:
auto n = A::size;deducesstd::integral_constant<std::size_t, N>, notstd::size_t. It converts implicitly, so this rarely matters, butauto n = a.size();is the spelling that givessize_type.a.sizewithout parentheses compiles, and names the constant.
a-set-needs-no-width¶
A set owner could hold its blocks at blocks_extent, and the question is worth answering because the reading lets
it: nothing a set reading says depends on its width. max_size() answers the storage's capacity, not the width;
set_equal and both orderings read a missing block as empty; the hash appends positions. A bit_set whose width
were always its blocks' would answer every question the same and hold one member less, eight bytes beside a
vector's twenty-four, and no tail for any member to mask.
What it would cost is a growth path of its own. growing_insert resizes a stored width by the position asked
for; at blocks_extent it would resize the blocks and have no width to move, and every storage member gated on
has_stored_size -- the moves, replace, extract, the allocator constructors -- would need a third arm for an
owner whose width is not a member. The sequence reading over the same storage does need the width, so
the saving would be the set reading's alone, over a storage the two readings otherwise share. It is not done;
the note is here so that the question does not have to be asked again from the start.
the-range-members¶
append_range has two tiers. Where the source is a sequence adaptor of any shape, owner, view or window, over
a storage whose blocks are the destination's block type, the source's bits are read as blocks at the source's own
alignment through block_at and appended a block at a time through bit_block_container::append(block),
which splits each block over the destination's own alignment; then the width is trimmed to the count, resize
clearing whatever the last block carried past it. Everything else, a std::vector<bool>, an iota under a
transform, a sequence over another block type, is packed a block at a time, which is boost's private
bit_appender, and trimmed the same way. A source inside the very storage being appended to is safe: the blit
reads positions below the old width alone, and no append writes one.
Under a static capacity [inplace.vector.modifiers]/3 asks more of append_range: past the capacity, no
effects. A sized source gets that for free: room for the width it needs is made before a block is written, and
refused there. A source with no size finds the capacity only by overrunning it, so the inplace owner remembers its
width, and on any exception resizes back to it before rethrowing -- the resize clearing the tail as every
shrink does. assign_range has no such remark in [sequence.reqmts], so its clear-and-append keeps only the
basic guarantee, and an overflowing source leaves what it appended.
insert_range, insert in its four shapes and emplace shift wherever the count is known before the first
element is read: a value, a count of them, a sized or forward range, or another sequence's blocks. Room is made
first, and that is the one step that can throw: the block container's own resize, so the storage's growth
policy picks the capacity and this library adds no factor of its own. std::vector's resize grows geometrically
on every standard library here, so insert(end(), b) is amortized constant as std::vector<bool>'s is, and an
inline capacity too small for the growth throws bad_alloc before anything has moved, as
[inplace.vector.modifiers]/3 asks. A sized append makes its room the same way, a resize to the new width and
back, so the blocks that follow are appended without reallocating and many small appends grow as push_back does:
an exact reserve of each new width instead reallocated on every call that needed a block, 157 allocations for
ten thousand one-bit appends against std::vector's 15. The tail then moves up
a block at a time, descending, each write landing above every read still to come, and the middle is written into
the gap; a source that throws part way has its gap erased again before the exception leaves. A single-pass source
with no size, and a window of the sequence itself, which would move with its own tail, rebuild instead: the head
through first(pos), the middle, the tail through subspan(pos), into a fresh sequence that is then moved in, at
the blit's tier, with the strong exception guarantee that std::vector::insert_range gives on reallocation.
subspan pays for itself here, the head and the tail being exactly windows. Both erases shift: the tail moves
down a block at a time, ascending, each write landing below every read still to come, and the width shrinks behind
it. A rebuild allocates, and [vector.modifiers]/5 lets an erase throw only what the element's copy, move or
assignment throws, which for a bool is nothing.
[vector.bool]'s flip() and its static swap(reference, reference) are left out. Negating every element is a
bulk operation, the valarray reading's ! (the-valarray-reading), negating one is
v[i] = not v[i], and two proxies swap through their own hidden friends, which the static member, deprecated by
P3612R1, only renamed.
what-a-sequence-may-add¶
the-sequence-contract is a floor: bit_vector answers every line of [vector.bool]'s
synopsis. A floor with nothing above it admits whatever the storage happens to offer -- the shifts among them,
declared, never argued for, and spelled backwards (no-shifts-on-a-sequence) -- so the
sequence adaptor needs a ceiling as well.
The ceiling: the sequence adaptor adds an operation only where the sequence reading is what asks for it, and the spelling is the one that reading already uses. Two questions, and a candidate answers both or it does not cross:
- Does a sequence of
boolwant this? The elementwise operators arestd::valarray<bool>'s, the standard's one model for a bulk logical operation over bools, and no sequence container of the standard has them: they answer no here and are the valarray reading's instead (the-valarray-reading). So is[vector.bool]'sflip(), the one bulk operationstd::vector<bool>has. A difference answers no anywhere: no standard sequence of bools spellsa and not b, andvalarray<bool>'soperator-=is arithmetic. A shift answers no twice over. - Is this the name that reading gives it?
<<=fails here even where the operation is wanted, because a sequence already spells moving elementsstd::shift_leftandstd::shift_right, in the opposite direction.
Being free is not an argument. Every operation the storage already has is free to forward, which is what
makes the forwarding tempting and the ceiling necessary: -= and the shifts were each one line over a
storage member that exists regardless, and the cost of a wrong one is not compile time but a caller who
reads v <<= 1 as std::shift_left. What the storage provides is the union of the two readings'
demands (the-two-adaptors); each adaptor exposes its own reading's share, and the
shares are not the same set.
The four ways a reading answers an operation are all visible in the current surface:
| one name, a different thing per reading | size() -- the sequence's element count, the set's cardinality |
| one reading alone | cshift and apply, the valarray's, after [valarray.members] |
| one reading spelling it otherwise | the set's cardinality size() is xstd::ranges::bit_count over the sequence, an algorithm rather than a member |
| one reading declining it | -= and the shifts on the set, not the sequence |
no-shifts-on-a-sequence¶
std::vector<bool> has no shifts, and neither has any sequence here. The storage keeps <<= and >>=
because the set reading asks for them, and means by them what [bitset.members]'s truncating shift means:
<<= translates, keeping the keys that land below max_size(), and >>= empties past the width. The sequence reading is the one with nothing to add. Worse,
it already spells moving elements, and spells it the other way round: operator<<= is implemented with
std::shift_right and operator>>= with std::shift_left, because a sequence's low index is its front
where a bit string's low bit is its right. Exposing the operators would have v <<= 1 mean the opposite of
the algorithm whose name the sequence reading already owns. So the shifts stay off the sequence reading. A row
of bools reaches them through the valarray reading, which declares them std::bitset's, in the bit string's
direction, beside valarray's own shift (the-valarray-reading).
algorithms-not-members¶
A container here has its standard counterpart's members, and no member that std::ranges spells as an algorithm.
Every block-wise operation the standard expresses as an algorithm over the elements lives in
<xstd/bits/bit_algorithm/bit_*.hpp>, one header each, exported together by <xstd/bits/bit_algorithm.hpp> and by
<xstd/bits.hpp>, as a free function in xstd::ranges named bit_ and the algorithm's name, as the algorithm is in
std::ranges. Each takes the arguments the std::ranges algorithm takes and returns what it returns: xstd::ranges::bit_count a range_difference_t, signed as
std::ranges::count's is, xstd::ranges::bit_mismatch a std::ranges::mismatch_result, xstd::ranges::bit_rotate a
borrowed_subrange_t, xstd::ranges::bit_copy a std::ranges::copy_result and xstd::ranges::bit_for_each a
std::ranges::for_each_result. The std::ranges call over the same container is the specification, and the tests
hold each algorithm to it, on std::vector<bool> and std::set<std::size_t> models of every column's inputs;
what the prefix adds is the blocks.
A namespace for each of the standard's. A port from the standard library is a rename that works the same way
everywhere: each std namespace becomes the matching xstd one, and the name takes bit_. So
std::ranges::copy(v, out) is xstd::ranges::bit_copy(v, out). A range signature puts an algorithm in
xstd::ranges even where std::ranges has no counterpart yet: xstd::ranges::bit_lexicographical_compare_three_way
takes two ranges and answers only the ordering, the shape P2022R3 proposes for
std::ranges::lexicographical_compare_three_way, where the standard has only the iterator-pair form. xstd itself is where the classic
iterator-pair shapes go when they come, as ordinary functions a user's overload joins through argument-dependent
lookup, so std::copy and std::ranges::copy land on two names, as they are two in the standard. The split also
keeps apart what the standard keeps apart: xstd-ints' xstd::bit_reverse(x) reverses the bits of an integer as
P3104's std::bit_reverse does, and xstd::ranges::bit_reverse(v) reverses a sequence as std::ranges::reverse
does. The iterator operations live in xstd::ranges too, as std::ranges::advance and its siblings do. Inside
namespace xstd, an unqualified ranges:: finds xstd::ranges before std::ranges or range-v3's ::ranges, so
tools/qualified_ranges.py, run by the Audit job, fails on a ranges:: that names none of the three.
Which algorithms get a bit_ form. One whose work on packed bits is a pass over blocks rather than a walk of
proxies: it beats a bit at a time because a block answers, moves or writes digits positions in one instruction,
or, on the set reading, because a loop can hold a block an iterator cannot. That is the whole list below, and it is
a closed list in the sense that an algorithm not on it is one of two kinds. It reduces to one of these on
bools: every false is like every other, so stable_sort, nth_element, partial_sort and ranges::sort with
std::ranges::less are all bit_sort, stable_partition is bit_partition, find_if(v, std::identity()) is
bit_find(v, true), fill_n and copy_n are
bit_fill and bit_copy over a subspan, lexicographical_compare is bit_lexicographical_compare_three_way(a, b)
< 0, and upper_bound, lower_bound and equal_range over a sorted sequence are bit_partition_point and
bit_find. Or packing gains it nothing: an algorithm taking a predicate or a function of an element the
algorithm cannot see as a block -- count_if, find_if with a lambda, remove_if, unique, search with a
comparator -- calls the caller's code once per element whatever the storage, and std::ranges already runs it
correctly over these ranges. bit_transform is the exception that proves the rule: a function of one or two
bools has a truth table of two or four entries, so the algorithm asks it once per entry and applies the table as
one block expression. A rejected predicate would have no such table, since a predicate's type does not say what it
tests; the set reading's predicate algorithms are the one family still open, the walk of
the-set-for-each carrying a predicate as cheaply as it carries a functor.
| algorithm | std::ranges equivalent |
reading | a window |
|---|---|---|---|
bit_count(r, value) |
count(r, value) |
sequence | yes |
bit_all_of(r) |
all_of(r, std::identity()) |
sequence | yes |
bit_any_of(r) |
any_of(r, std::identity()) |
sequence | yes |
bit_none_of(r) |
none_of(r, std::identity()) |
sequence | yes |
bit_find(r, value) |
find(r, value) |
sequence | yes |
bit_find_last(r, value) |
find_last(r, value) |
sequence | yes |
bit_mismatch(r1, r2) |
mismatch(r1, r2) |
sequence | yes, a bool at a time |
bit_is_sorted(r) |
is_sorted(r) |
sequence | yes |
bit_is_partitioned(r) |
is_partitioned(r, std::identity()) |
sequence | yes |
bit_partition_point(r) |
partition_point(r, std::identity()) |
sequence | yes |
bit_copy(r, out) |
copy(r, out) |
sequence | yes, either side |
bit_copy_backward(r, out) |
copy_backward(r, out) |
sequence | yes, either side |
bit_move(r, out) |
move(r, out) |
sequence | yes, either side |
bit_swap_ranges(r1, r2) |
swap_ranges(r1, r2) |
sequence | yes |
bit_reverse_copy(r, out) |
reverse_copy(r, out) |
sequence | yes, either side |
bit_rotate_copy(r, middle, out) |
rotate_copy(r, middle, out) |
sequence | yes, either side |
bit_transform(r, out, f) |
transform(r, out, f) |
sequence | yes, either side |
bit_transform(r1, r2, out, f) |
transform(r1, r2, out, f) |
sequence | yes, every side |
bit_fill(r, value) |
fill(r, value) |
sequence | yes |
bit_sort(r) |
sort(r) |
sequence | yes |
bit_partition(r) |
partition(r, std::identity()) |
sequence | yes |
bit_reverse(r) |
reverse(r) |
sequence | no |
bit_rotate(r, middle) |
rotate(r, middle) |
sequence | no |
bit_shift_left(r, n) |
shift_left(r, n) |
sequence | no |
bit_shift_right(r, n) |
shift_right(r, n) |
sequence | no |
bit_for_each(r, f) |
for_each(r, f), and over std::views::reverse(r) |
both | yes |
bit_equal(r1, r2) |
equal(r1, r2) |
both | yes |
bit_lexicographical_compare_three_way(r1, r2) |
lexicographical_compare_three_way over both ranges |
both | yes |
bit_includes(s1, s2) |
includes(s1, s2) |
set | — |
bit_intersects(s1, s2) |
set_intersection(s1, s2, out), writing a key |
set | — |
bit_disjoint(s1, s2) |
set_intersection(s1, s2, out), writing nothing |
set | — |
bit_set_intersection(s1, s2) |
range-v3's views::set_intersection(s1, s2), as a set |
set | — |
bit_set_union(s1, s2) |
range-v3's views::set_union(s1, s2), as a set |
set | — |
bit_set_difference(s1, s2) |
range-v3's views::set_difference(s1, s2), as a set |
set | — |
bit_set_symmetric_difference(s1, s2) |
range-v3's views::set_symmetric_difference(s1, s2), as a set |
set | — |
bit_advance(it, n), bit_advance(it, n, bound) |
advance(it, n), advance(it, n, bound) |
both | yes |
bit_next(it, n), bit_next(it, n, bound) |
next(it, n), next(it, n, bound) |
both | yes |
bit_prev(it, n), bit_prev(it, n, bound) |
prev(it, n), prev(it, n, bound) |
both | yes |
bit_distance(first, last) |
distance(first, last) |
both | yes |
bit_distance(r) |
distance(r) |
both | yes |
The iterator operations dispatch on the iterator's category. On a sequence, distance is an offset difference and
advance an offset step, which a random-access iterator does in O(1), so the four forward to std::ranges::advance,
next, prev and distance, bounded forms included. On a set a step is a key, so they pop and select whole blocks.
Each asks no more of its iterator than its std::ranges counterpart: bit_prev a bidirectional one, as
std::ranges::prev does. bit_distance(r) takes a set, or a std::ranges::subrange of its iterators such as
subrange(s.lower_bound(a), s.lower_bound(b)): counting keys is a distance on a set and not a count, since
std::ranges::count(s, k) asks how often k occurs, which s.contains(k) answers.
Free, because the counterpart has no such member. std::vector<bool> has no count, std::array no rotate
and std::set no is_subset_of, and a member would answer a question the counterpart's synopsis does not ask, in
a vocabulary of its own choosing: a nullary count(), an all() taking a bool, a rotate(n) reducing its
argument modulo the size. An algorithm takes the shape std::ranges already fixed, so a reader who knows
std::ranges::count(v, true) knows xstd::ranges::bit_count(v, true). The standard libraries make the same split for
vector<bool>: libc++ answers std::count, std::find and std::fill a word at a time through overloads on its
__bit_iterator, not through members of the container. A user may not add overloads to std::ranges' algorithms,
so these take the bit_ prefix instead, and a call says which of the two it is.
One door to the blocks. An algorithm reaches the storage through storage_access, the one friend both adaptors
and their iterators declare, as libc++'s overloads reach vector<bool>'s words through the __bit_iterator they are
written against. Nothing else about the container is opened: each algorithm asks the storage member that answers it,
count, all, any, none, first_difference, reverse, rotate, fill, the ranged set and count, or the
shifts, over a whole sequence's blocks, and reads and writes a window's blocks through both sides of block_at
(the-blit, windows); bit_includes and bit_intersects read the blocks themselves. An
iterator opens the same door: storage_access hands an algorithm the storage an iterator steps over and the
position it stands at, which is how bit_copy writes through an output iterator and bit_advance moves one.
Whole sequences and windows. bit_reverse, bit_rotate and the two shifts permute a whole storage in place,
and take an owner or a whole view, never a window or a const sequence: a window shares its end blocks with
positions outside it, and its permutation would be a masked walk of its own. Everything else takes a window,
reading its blocks at its offset through block_at and writing them masked to its own positions. bit_mismatch
takes two operands of one type, and compares two whole sequences a block at a time, at two run-time sizes as well,
stopping at the shorter's end as std::ranges::mismatch does; two windows it compares a bool at a time through
std::ranges::mismatch itself.
The writes, at any two offsets. bit_copy, bit_copy_backward, bit_move, bit_reverse_copy,
bit_rotate_copy and bit_transform read their source and write their destination a block of the destination at
a time: the source's bits at any offset, assembled from its own blocks where the two block types differ, land
through the write side of block_at under the mask of how many remain. So source and destination may sit at
different offsets in different storages over different block types, and nothing walks a proxy. Overlap follows the
standard's rules: bit_copy writes lowest first, so a destination below its source is safe, as
std::ranges::copy allows; bit_copy_backward writes highest first, for a destination above; and bit_transform
may write over its first input in place. bit_swap_ranges trades a block of the narrower of two block types at a
time. bit_fill, bit_sort and bit_partition are the storage's ranged set: sorting bools is counting the ones
and filling the zeros before them, partitioning them is the same fill the other way round, and both are stable,
every false being like every other. bit_shift_left(v, n) is std::ranges::shift_left, element i taking
element i + n, which is the storage's >>= because a sequence's front is a bit string's low end
(no-shifts-on-a-sequence); bit_shift_right is the storage's <<=. Each leaves the
positions it vacates false, which std::ranges leaves valid but unspecified, and a shift by the size or more has
no effect, as the standard's has none.
bit_transform asks its function once per entry of a truth table. A function of one bool is one of four --
constant false, constant true, the identity or negation -- so the unary form calls f(false) and f(true) once
each, as the valarray's apply does (the-valarray-reading), and applies
(x & T) | (~x & F) a block at a time, T and F being blocks of all ones or all zeros by the answers. A function
of two bools is one of sixteen, so the binary form calls f on the four pairs once each and applies
(x & y & T11) | (x & ~y & T10) | (~x & y & T01) | (~x & ~y & T00). std::bit_and<>, std::logical_or<>,
std::not_equal_to<> and an arbitrary lambda all take that one path, there being nothing a functor's type says that
its four answers do not. The difference from std::ranges::transform is the number of calls, two or four rather
than one per element, which a function with side effects would see; its result over the sequence is the same.
bit_for_each walks either reading a block at a time and hands its functor each element by value
(the-set-for-each, the-sequence-for-each,
the-functor-takes-a-value); the walk against the order is the overload on
std::views::reverse over a range of ours, so the containers' for_each and for_each_reverse members became one
algorithm with the standard's spelling for both directions.
bit_equal and bit_lexicographical_compare_three_way cross block types and storages. Every owner's == and
<=> stays a hidden friend over its own type, as std::array<T, 3> does not compare with std::array<T, 4>;
containers of different types compare through these two, as through std::ranges::equal and
std::lexicographical_compare_three_way. Two operands of one storage type compare in their own blocks, by the
storage's first_difference, the set reading's set_equal and the reading's own three-way; any other pair compares
64-bit words assembled from each side's blocks at each side's offset. The set reading's order is std::set's, the
keys in iteration order, lexicographic over ascending keys and the masks as numbers over descending ones. The
operands must be of one reading, a set's of one key, mapping and comparator, and of one width kind: two widths
fixed in the type and equal, or two run-time widths. The width kind is what bit_hash hashes differently
(the-hashing-invariant), so whatever compares equal hashes equal, and
xstd::bit_equal_to, the transparent equality over bit_equal, pairs with the transparent bit_hash for a
lookup across block types and storages: std::unordered_set<K, xstd::bit_hash<K>, xstd::bit_equal_to>::find takes a
view, or the same value over narrower blocks.
The iterator operations are rank and select. std::ranges::advance, next, prev and distance over a set's
iterators step one key at a time; bit_advance(it, n) skips whole blocks by their popcount until the block holding
the target key, then selects within it, counting from the top when it walks down, so it reads the blocks between
it and the target, n / digits of them where the keys are dense, rather than taking n steps. Its bounded form stops at bound and answers the steps it did not take, signed as n is, as
std::ranges::advance does; bit_next and bit_prev are it over a copy. bit_distance(first, last) is a popcount
over the whole blocks between two iterators and the masked ends, so select is *bit_next(s.begin(), n) and the rank
of k is bit_distance(s.begin(), s.lower_bound(k)). The select within one block is xstd-ints' xstd::bit_select,
pdep and a countr_zero where BMI2 is fast and a broadword select elsewhere.
The second set converts. bit_includes, bit_intersects and bit_disjoint take S const& s1, std::type_identity_t<S> const& s2:
S deduces from the first argument alone, and the second converts to it. That puts a flag set's mask on the second
side, xstd::ranges::bit_includes(perms, fs::perms::owner_read), and never first (flag-types). All three answer
at any two run-time widths (width-is-capacity), and every other relation between two sets is
spelled from them (the-set-queries).
The set operations return a set, and an operator spells each. std::ranges::set_intersection and its three
siblings write through an output iterator, and that form already works on these sets, element by element. What the
blocks add is the result as a value, the shape range-v3 gives its lazy views::set_intersection,
views::set_union, views::set_difference and views::set_symmetric_difference, so xstd::ranges::bit_set_intersection,
bit_set_union, bit_set_difference and bit_set_symmetric_difference take their names and return the set those
views would hold, a block at a time. The operators &, |, - and ^ are their shorthand, the notation set
theory already uses, and each algorithm returns exactly what its operator does. Only an owner has a result to hold,
so a view, which writes through to blocks it does not own, takes neither; the second argument converts to the
first's type, as for the queries, so xstd::ranges::bit_set_union(perms, fs::perms::owner_read) adds a flag.
rotation-and-reversal¶
xstd::ranges::bit_rotate and xstd::ranges::bit_reverse cross where the shifts did not, and the ceiling's two questions say why
(what-a-sequence-may-add). A sequence of bool wants them: std::ranges::rotate and
std::ranges::reverse are sequence algorithms, and over packed bits each is a pass over the blocks rather than a
walk of proxy swaps. So each is the algorithm it packs, under its name, with its signature and its result
(algorithms-not-members). xstd::ranges::bit_rotate(v, middle) has the effect of
std::ranges::rotate(v, middle): bit i takes bit (i + n) mod N for n = middle - v.begin(), so a whole turn,
middle == v.end(), is no turn, and it returns {v.begin() + (v.end() - middle), v.end()}, the range from where
the old first bool now stands. xstd::ranges::bit_reverse(v) has the effect of std::ranges::reverse(v): bit i takes bit
N - 1 - i, and it returns v.end().
P3103R2 is the prior art. It gives std::bitset a reverse(), and a rotation
as the pair rotl and rotr, after std::rotl and std::rotr in <bit>, which name the direction by bit
significance: left is towards the high bit. That is the right spelling for a bit string, whose low bit is printed on
its right, and the wrong one for a sequence, whose low index is its front. In the sequence reading the paper's
rotr(n) is xstd::ranges::bit_rotate(v, v.begin() + n), and its rotl(n) is xstd::ranges::bit_rotate(v, v.end() - n), for n
up to the size. One algorithm says both, in the direction std::rotate already fixes, and leaves no left or right
for a reader to map onto front and back. It is the mismatch that keeps the shift operators off the sequence reading:
a direction named for a bit string reads backwards on a sequence.
An owner and a whole view take them and a window does not: a window shares its end blocks with
what lies outside it, and its rotation would be a masked walk of its own. The set reading does not take them
either; std::set has no counterpart, and a rotation of keys has no meaning there that a shift does not already
give.
The storage does the work, at every width without allocating, so each is noexcept. A rotation by
n = q · digits + r, reduced modulo the width first so the shifts' n < size() precondition never arises, is
std::ranges::rotate over the blocks by q, then a funnel shift by r through straddled_block, the first block
wrapping round into the last (the-funnel-shift). Over the blocks' whole width that leaves the
n bits that wrapped sitting above a gap as wide as the padding, so where there is padding one more pass moves
them down onto it through both sides of block_at (the-blit). A reversal is the blocks in reverse
order, each block's bits reversed by log2(digits) masked swaps, and the padding, now at the bottom, rotated out by
the same funnel shift.
libstdc++ before 16 gives a reason of its own not to forward to std::ranges::rotate. It holds the
element a closing rotation by one displaces as auto, which over a proxy reference is a proxy to the position
about to be overwritten, so at a turn coprime with a width of three or more the bit that should wrap round comes
out as a copy of its neighbour: std::vector<bool> and this library's sequences lose it alike, while std::rotate
and libc++ do not (GCC PR 121913, fixed in 16 and not backported). xstd::ranges::bit_rotate
is a pass over the blocks that never asks the standard library to move a proxy, so it is exact on every library, and
the tests build their own rotations by index for the same reason.
the-valarray-reading¶
The containers here are principled versions of the standard's: bit_vector is what an unspecialized
std::vector<bool> would be, bit_array<N> std::array<bool, N> and bit_inplace_vector<N>
std::inplace_vector<bool, N>. None of those has a bitwise operator, so none of ours does. What &= means on a
row of bools is elementwise logical, a[i] = a[i] and b[i] at every position, and the one standard type
that spells it is std::valarray<bool>, R's logical vector. That is a reading of its own, the sequence read
elementwise, with its own four owners over the sequence's storages:
std::valarray<bool> packed |
|
|---|---|
static size, std::bitset<N> read elementwise |
bit_fixed_valarray<N> |
run-time size within a capacity N |
bit_inplace_valarray<N> |
run-time size inline up to N, on the heap past it, in <xstd/bits/ext/boost.hpp> |
bit_small_valarray<N> |
run-time size on the heap, boost::dynamic_bitset read elementwise |
bit_valarray |
All four are one valarray_adaptor over the storage, whose reading tag refines the sequence's: the algorithms of
algorithms-not-members and xstd::bit_convert take a valarray as they take a sequence, and
a bit_span views one as a std::span<bool> views a std::valarray<bool>. Its proxy and iterators are the
sequence's, handed out through a whole view of its own storage. On packed bits every elementwise operation is
the set operation's own instruction a block at a time, so the reading costs nothing to serve.
The interface is [valarray]'s at T = bool, line by line. value_type, and the proxy reference that
stands in for T&; the default, count, value-and-count (the value first, as [valarray.cons] orders them),
pointer-and-count, list and proxy constructors; assignment from an array, a bool filling every element, a list
and each of the four proxies; operator[], a proxy where valarray hands out T& and a bool where it hands out
const T&, and the four subscripts of [valarray.sub] described below; swap, size, sum, min, max,
shift, cshift, apply in both shapes and resize, which assigns its value to every element as
[valarray.members] says; !, &=, |= and ^= against an array and a scalar; &, |, ^, &&, || and
the six comparisons over two arrays and over an array and a scalar on either side; swap, begin and end as
non-members, which makes it a random-access range, and no member begin or end. A static size has no count
constructors and no resize, and takes std::array's list initialization and a built-in array of bools as
bit_array does; its pointer constructor and its constructors from a proxy assert that the count is the type's
own. Each array operator asserts that its operands have one size, the precondition [valarray] makes
undefined behaviour. Everything is constexpr as far as the storage is: the fixed one entirely, and structural at
whole blocks; the heap one in a constant expression as std::vector is; the inplace one where its blocks are a
std::inplace_vector. Besides [valarray], the owners take from_blocks as every owner does.
The deviations, each one forced by bool or chosen for it:
~is logical not.valarray<bool>::operator~applies~to each element, which promotesbooltointand converts~0and~1back totrue: an array of alltrue, whatever it held. Nobody wants that, andstd::bitset's users mean complement, so~is!here.std::bitset's shifts are kept, in its sense.[valarray.cassign]'s<<=and>>=shift each element's value, which on aboolis arithmetic again. Herev <<= nmoves positionitoi + n, zeros filling in, andv >>= nmoves positioni + ntoi, each emptying the array atn >= size(), with<<and>>returning copies: the operatorsstd::bitsetandboost::dynamic_bitsetcode is written with.valarray's ownshift(n)stays beside them in its own sense, elementitaking elementi + n, sov << nisv.shift(-n).- Empty reductions are defined.
[valarray.members]leavessum,minandmaxof an empty array undefined; heresum()andmax()arefalse, the identity of or, andmin()istrue, the identity of and. - No arithmetic.
+,-,*,/,%, unary+and-, and their compound forms are left out, as are the<cmath>functions: onbooleach either promotes out of the type or is one of the logical operators again. - Comparisons answer arrays.
==,!=,<,<=,>and>=return the array of elementwise answers, asvalarray's do, so the type is notequality_comparable, has no<=>and nostd::hash, exactly likestd::valarray. Whole-array equality is(v == w).min(), orxstd::ranges::bit_mismatch. applyis asked twice. A function of oneboolis one of four, soapply(func)callsfunc(false)andfunc(true)once each and applies the answer a block at a time: a constant, the identity or a negation.- The proxies take scalar compound assignments and no shifts.
[template.slice.array]and its siblings have=from aTbut compound assignment only from an array; here&=,|=and^=take abooltoo, as the owners' do. Their<<=and>>=shift each element's value, which on aboolis arithmetic, and a position shift over positions that need not be contiguous has no counterpart, so a proxy has neither. - A fixed size gathers into the inplace array. A const subscript returns a valarray of the run-time size it
selects, so
bit_fixed_valarray<N>returns abit_inplace_valarray<N>over the same block: no heap, andconstexpras far as the inplace one is. A gather longer thanN, which a slice of stride zero, a gslice or an index array can make by repeating positions, throwsstd::bad_allocas the inplace array does past its capacity.
The subscripts. [valarray.sub]'s four each come in two forms: on a const array a gather into a valarray of
its own, and on a non-const one a proxy, bit_slice_array, bit_gslice_array, bit_mask_array or
bit_indirect_array, over the same block type. A proxy refers to its array's blocks, as std::slice_array refers
to its std::valarray: it is made by the subscript alone, copies as a handle to the same elements, and must not
outlive the array, or a mask or an index array it was made from, any more than the standard's must. Through a const
proxy, =, &=, |= and ^= take an array of the proxy's size or a bool, and assigning one proxy to another
assigns elements, never the reference. An array here is any owner of the valarray reading over the same block, or
another proxy, which gathers what it selects before any position is written, so v[s] = v[t] reads v before it
writes it. A mask is any owner of the valarray reading over the same block and of the same size; a
std::valarray<bool> would have to be packed into blocks the proxy then owns, so it is not taken. Every owner is
constructible from each proxy, implicitly as [valarray.cons] makes it, and assignable from it, so the compound
operators and the binary ones take a proxy on the right as they do in std::valarray.
Each subscript asserts its positions lie inside the array, and the mask its size. [indirect.array.assign] leaves a
write that names a position twice undefined, and the indirect proxy asserts against it, as every undefined case of
[valarray] here is asserted. A slice of stride zero and a gslice that repeats a position are left defined by the
standard, and write one position at a time, in order, so the last write to a position stands: libc++ does the same,
and libstdc++ leaves a stride of zero's compound writes undone, its loop bounded by the stride times the size.
The gather and the scatter. Each subscript runs on one of two paths. The mask path is a block at a time:
a gather takes xstd::bit_compress(src_k, mask_k) of each block, P3104's PEXT, and lays it at a running output
offset that grows by popcount(mask_k), through the write side of block_at (the-blit); a scatter
reads the next popcount(mask_k) bits at a running input offset through block_at and deposits them with
xstd::bit_expand, PDEP, combining them with the block as the operator says: assignment keeps d & ~m and adds the
deposit, &= is d & (e | ~m), |= and ^= take the deposit as it is. A scalar is a deposit of the whole mask
or of nothing. A mask of all ones needs no permutation and passes the block through. The index path is a position
at a time, through test and assign.
| subscript | path |
|---|---|
v[std::slice], v[bit_slice] |
a run at stride one, whole blocks copied from a block boundary and the rest through block_at; mask, a stride below the block width, one period of stride / gcd(stride, digits) masks made once with xstd::bit_repeat and loaded, the first and last blocks cut; index, a stride of zero or of a block or more |
v[std::gslice], v[bit_gslice] |
mask, where the positions ascend, from the bit_gslice's stored words; index, where they repeat or turn back |
v[mask] |
mask, the mask's own blocks |
v[std::valarray<std::size_t>] |
index |
bit_slice and bit_gslice. std::slice has no constexpr constructor, so a slice could never be taken in a
constant expression; bit_slice is the same three numbers, constexpr and regular, and v[std::slice] forwards to
v[bit_slice]. std::gslice works its positions out once, in its constructor, and keeps them private, so a subscript
by one had to work them out again, every time. bit_gslice keeps them: where its shape says they ascend, which is
whether each stride clears the span of every dimension inside it, a check on the lengths and strides alone, it stores
one bit per position as 64-bit words, which every block width reads as its masks, a block of up to a word inside one
word and a wider one across several; where they repeat or turn back, it stores the positions in row-major order. The
index is immutable and shared between copies, so a proxy holds its own copy and never outlives what it reads, which a
std::gslice_array referring to a std::gslice can. v[std::gslice] builds one per subscript.
Not worse than handwritten. The benchmark holds each subscript to three columns: std::valarray<bool>, this
library, and the same algorithm written by hand over raw 64-bit words with P3104's word functions, each gather
returning a clear array of its own as v[s] does. A gather collects its packed bits in a register and stores each output
block once, full: an or into memory per input block chains each update on the last store through store forwarding,
which at stride eight, eight blocks to an output word, held it to two thirds of the handwritten loop. Where xstd-ints runs PEXT and PDEP in hardware, a full mask goes through
them like any other; the shortcut that saves the portable kernels a full permutation mispredicts at high densities.
Handwritten means what a programmer writes at the call site with the information the library has there, and no more:
the block width, the kind of selection, and the stride or the mask words at run time, but not how dense the selection
is. The handwritten column is therefore the library's own algorithm as a raw loop over the same blocks of the same
owners, with no hints, and what it measures is the cost of the abstraction alone. On GCC 15 and Clang 22 that cost is
nil: both columns compile to the same instructions per block. What remains between them is code layout. The gather
stores an output block on a branch that depends on the data, and GCC places that store either in line, so the common
path jumps over it, or ahead of the loop head, so every path takes one branch; which it picks depends on the
surrounding function, not on the loop, and at five per cent density the difference is a cycle a block. Clang lays the
two columns out alike. -freorder-blocks-algorithm=simple gives GCC the better layout at every density and is the
user's to set for the whole program; [[likely]], [[unlikely]] and __builtin_expect_with_probability each help one
density and hurt another, and profile-guided layout fits whichever density it was trained on, so the kernels carry none
of them.
A programmer who knows the density knows something the library does not, and can beat it: by a branch hint that suits
the data, or, for a sparse enough mask, by walking its set bits instead of permuting every block. That is not an
abstraction penalty, any more than a counting sort over small keys is a penalty of std::sort. Where such knowledge
should reach the library, it belongs in the interface, as a selection type with a path of its own, the way
bit_gslice carries a shape a std::gslice cannot; none is added without a workload that needs it.
The two permutations come from xstd-ints, which runs BMI2 where the target has it fast and portable kernels
elsewhere, choosing at compile time. Neither is public here: no std::ranges algorithm packs selected elements, so
there is no signature for an xstd::bit_compress over this library's ranges to borrow
(algorithms-not-members), and the block-range kernels stay in detail, under the proxies.
std::slice, std::gslice and std::valarray<std::size_t> have no constexpr constructor, so a mask's subscripts
and a bit_slice's are the ones that run in a constant expression; they do, on the fixed and inplace arrays.
The kernels. Each operator is one bitwise expression over a block of either side, after which the padding past
size() is cleared, so a count over whole blocks stays right. A scalar is a block of all ones or all zeros.
| operator | per block | padding cleared |
|---|---|---|
a & b, a && b |
a & b |
no need |
a \| b, a \|\| b |
a \| b |
no need |
a ^ b, a != b |
a ^ b |
no need |
a == b |
~(a ^ b) |
yes |
a < b |
~a & b |
no need |
a > b |
a & ~b |
no need |
a <= b |
~a \| b |
yes |
a >= b |
a \| ~b |
yes |
The storage applies one kernel to every block through transform, against another storage of one width or
against a scalar's block, and clears the padding once at the end, whether the kernel needed it or not.
the-sequence-contract¶
bit_vector answers every line of [vector.bool]'s synopsis and bit_array<N> every line of [array]'s,
and the test says so declaration by declaration rather than as a claim: each line is asserted in the case of
the clause under test/src/spec/ that describes it, over that clause's list, where std::vector<bool> and
std::array<bool, N> come first. A line the model itself fails is a wrong line, so the check is known to be
honest before ours is held to it; the C++23 range members are asserted only where the standard library has
them. Where a packed sequence parts from its model because its reference is a proxy -- no data(), random
access where the model is contiguous, a tuple_element that converts to bool -- the case says so through
test::proxy_reference in test/include/test/reference.hpp, the named relaxation of test::real_reference.
The sweep found what the range members had not needed. The allocator: allocator_type through the same empty
base set_adaptor has, get_allocator, and the allocator-extended constructors,
[container.alloc.reqmts]'s copy and move included, which bit_block_container gains beneath them.
Each takes allocator_type const& as the standard does, not a deduced Alloc matched to it: a matched template
refused every allocator that only converts -- a memory_resource* for a polymorphic_allocator, a rebound
std::allocator, a braced {} -- and so broke uses-allocator construction, a std::pmr::vector of owners failing
to compile at emplace_back. Where the storage has no allocator the parameter is an explicit tag, no_allocator,
and a requires removes the overload, so a static owner still has none. [vector.erasure]'s erase and erase_if
as non-members over the owner's erase(first, last), std::ranges::remove_if running unchanged over the
proxies, which move and swap. And std::array's aggregate initialization as an initializer_list constructor
on the static owner, the listed values leading and the rest false, a longer list being the error it is on
std::array.
data() is not offered, because packed bits have no address, and the pointer and const_pointer typedefs are
void: they name what a proxy cannot give, and the checklist leaves them out of [container.reqmts]'s typedefs
rather than inventing a pointer to a bit. std::array's tuple interface is offered on bit_array as far as a proxy
carries it. std::tuple_size is N; std::tuple_element<I> names the proxy, reference, or const_reference for
a const array, rather than bool; and get<I> is four overloads found by ADL, one per value category, each
constrained on I < N and returning the proxy by value, std::get being the standard's to declare. The two
specializations are written for basic_bit_array itself, forwarding to the adaptor's, since a partial
specialization on the adaptor never matches a class derived from it. std::hash is asked of the vector alone,
std::array having none, while bit_array hashes as every owner does
(the-hashing-invariant).
borrowed-blocks¶
A view over blocks that belong to no container of ours takes them directly: bit_set_view(board) over one
std::uint64_t, bit_span(blocks) over a std::array, a built-in array, a std::vector or a std::span of unsigned blocks. The
storage underneath is bits::detail::borrowed_bits<Block, Extent>, which is bit_block_container over a
std::span<Block, Extent>, and a deduction guide on each view names it from the argument: one block is
std::span<Block, 1>, a range is whatever std::span(blocks) deduces, which over a built-in array Block[K] is
std::span<Block, K>, a static width, as [span.deduct] takes the bound of T (&)[N]. One block is taken by
lvalue, a bit_block_range by lvalue or as a borrowed_range, so a contiguous range that does not subscript, such
as a std::initializer_list, is not lent at all, and bit_set_view(std::uint64_t{5}) and bit_span(std::vector<std::uint32_t>{})
do not compile, and a std::span temporary does. Const blocks make const storage, and a view over const
storage reads and cannot write, as a view over a const owner cannot.
Every bit of the blocks is a position: bit n of block i is position i * digits + n, which is the order
from_blocks already reads an integer in. So the width is the blocks', and there is no tail for the storage to keep
clear -- the invariant a width of our own needs is vacuous here, which is what lets the blocks be anyone's. The
width takes one of two forms, both named by the span's own type through default_extent_v, which is
xstd::bit_blocks_extent_v wherever that is fixed, blocks_extent for a span of dynamic extent, and the
storage's capacity otherwise:
- A span of static extent
Kis a static width ofK * digits, and runs the same arms as astd::array, the one- and two-block paths included. One block isstd::span<Block, 1>. - A span of dynamic extent is
blocks_extent, a third value ofNbeside a bit count andstd::dynamic_extent: a run-time width that is not a member,size()being the span's length timesdigits.
So has_static_size asks whether the width is a template argument and has_stored_size whether it is a member,
and the members that grow, the written-out moves and the allocator constructors are the second question's: a
borrowed run-time width neither grows nor moves anything but a span. extent still answers
std::dynamic_extent for blocks_extent, which is what every reading already asks to mean "not static".
The class admits the span beside owned_bit_blocks, and only at the span's own default width: a narrower
N over someone else's blocks would have a tail the storage could not keep clear. The span is refused as a
owned_bit_blocks on purpose and stays refused -- it is not regular, and its const subscript writes --
so borrowed_block_span is its own concept, with a mutable unsigned element.
The view holds that storage by value, as std::views::all holds a view. A view over an owner holds a
Bits*, which is std::ranges::ref_view: the owner is not a view, so the view refers to it. Span-backed
storage is a view, and views::all would copy it rather than point at it. Pointing at it would cost a named
object for the pointer to reach: auto bits = borrow_bits(board); before bit_set_view(bits), with bits bound
to outlive the view. storage_ref_t<Bits> is the choice, made once in
detail/storage_ptr.hpp: a Bits*, or a storage_copy holding the span-backed storage. The copy is mutable,
because the blocks are not the view's and a const view writes through them as a const std::span does. So the
view is the size of the span, 8 bytes over one block or an array and 16 over a vector, and there is nothing
beside it to keep alive.
The iterators hold the blocks, not the view. The views are borrowed_ranges: an iterator outlives the
view it came from, which is what std::ranges::find(bit_set_view(board), 7) returning a live iterator
needs. An iterator that held a pointer to the view's copy would dangle there. storage_ptr_t<Bits> is the
iterator's and the proxy's hold: a Bits*, or a block_ptr holding the blocks' address and, for a dynamic
extent, their count, from which -> rebuilds the storage for the one call it makes. It holds no storage
object because std::span<Block, 1> has no default constructor and an iterator must have one. An iterator
over one block is a pointer and a position, as it is over an owner.
borrow_bits and borrowed_bits are in detail/: with the views taking the blocks directly, they are how a
view gets its storage and not something a caller needs to name.
views-over-owners¶
An owner is not itself a storage — bit_fixed_set and bit_array are thin wrappers over a
bit_block_container — so a view over an owner is a view over the storage it wraps:
bit_set_view(xstd::bit_fixed_set<64>&) is set_adaptor<bit_block_container<std::array<size_t, 1>, 64>, refers>, and the pointer in
the iterator is to the bit_block_container, never to the bit_fixed_set. The owner hands its storage over through
owned_storage<Owner>, declared beside it and never defined for anything else, so
owner_of<Owner, Bits, R> reads "this owner wraps exactly the storage this view refers through, and is not
already committed to another reading". Const flows one way: a const owner gives a
view over Bits const, a mutable owner either.
The view's converting constructor takes the owner's private member directly, which is why an owner befriends
a referring adaptor — the one friendship in the tree that runs upward, from a container to the views over it,
and it grants access to a member and to nothing that member's type does not already expose. Which adaptors it
befriends is the-readings-do-not-mix. Storage stays private; nothing on an owner's
surface says bit_block_container.
viewing-an-owner-is-implicit¶
bit_set_view(my_set) is a converting constructor on the view, not a conversion operator on the owner, and
the choice is forced rather than stylistic. Deduction runs through constructors and guides, wherever they are
written -- on each view, or on the adaptor behind an alias; a conversion function contributes nothing to class
template argument deduction either way. Measured on a model of both shapes: with only the
operator, view(owner) is no matching function for call to 'adaptor(...)'. The constructor has to exist
anyway, and once it does the operator is a second mechanism for a conversion already spelled. Three lesser
reasons agree. The constructor is where owner_of<Bits, R> already hangs, so the readings-do-not-mix rule is
stated once. Const falls out of deducing Owner& rather than needing an operator view<Bits>() & and an
operator view<Bits const>() const& kept in step by hand. And Owner& is an lvalue reference, so a temporary
owner never binds — the string_view foot-gun closed by the signature instead of by a &-qualifier someone has
to remember.
The standard's own split is about layering, not taste: string → string_view is an operator because
<string_view> must not depend on <string>, while vector/array → span is a constructor because span
is generic over its sources and names none of them. Owner and view here are the same class template, so there is
no layer to respect, and the second shape is the one that fits.
Implicit from an owner, explicit from raw storage, and span supplies the criterion. Its conditional
explicit is usually read as "static extent", which is not what it says — the span-to-span constructor spells
it extent != dynamic_extent && OtherExtent == dynamic_extent, so static-to-static stays implicit and only
dynamic-to-static is explicit. The rule is therefore: explicit exactly where the conversion asserts a size the
source cannot prove, which is why those constructors carry a hardened precondition
(ranges::size(r) == extent) and why span(array<T, N>&) and the C-array overload are implicit even at a
static extent — an array carries its N in the type.
Viewing an owner is the array row. The width comes from the owner's own Bits, owner_of requires the
storage to match exactly, and the lvalue parameter closes the lifetime hole: nothing is asserted
that is not already proven, and there is no precondition to violate. So that constructor is implicit — spelled
explicit(false) with a NOLINT(misc-explicit-constructor), as the five other deliberate implicit conversions
in the tree are, because misc-explicit-constructor holds that every one-argument constructor must be explicit
and cannot know that this is the conversion the type exists for. Saying explicit(false) rather than omitting
the keyword is what makes the intent readable at the declaration instead of inferable from its absence.
set_adaptor(Bits&) stays explicit — not for any size claim, but because reaching past a container to the
storage underneath it is an act worth spelling, and it is the constructor a user adapting their own storage
reaches for deliberately.
the-readings-do-not-mix¶
A view over an owner is a second reading of bits that already have one, and a view of the other reading would be
choosing for the caller. bit_set is a set of positions; spanning it as bools would present the
container's capacity as its contents. bit_vector is a sequence of bools; viewing it as a set would present
the indices of the true ones as the elements. Neither is wrong in the way a bug is wrong — the bits do support
the reading — but neither is what the owner's own name says it holds, and the owner is the thing the caller
named. So a set owner admits only bit_set_view, a sequence owner only bit_span.
Blocks that no owner holds are committed to neither reading, so they admit either view: that is what a view over
raw storage is for, and std::bitset<N> reaches it through xstd::bit_convert to its blocks.
The rule is one typedef on each adaptor, reads_as, naming one of the empty tag types set_reading_tag and
sequence_reading_tag, which every owner and view built on it inherits, and one concept over it, reads<T, R>,
asked with std::derived_from: the container's reading is R or refines it. owner_of asks it of the owner
for the view's reading, and the hash, bit_convert's widths and the proxies' recognition ask the same concept.
The readings are types rather than enumerators so that the set of them is open: another reading, a string one
say, declares a tag of its own and touches nothing already there, and one that refines an existing reading
derives its tag from that one's, so it is accepted wherever its base is asked for. It has to live in the constraint
and not in the friendship alone. Dropping only the friendship leaves the constructor declared and viable, and
its m_bits(&c.m_bits) is a mem-initializer — not the immediate context — so the access check happens at
instantiation and nowhere earlier. Measured: std::is_constructible_v<bit_set_view<Blocks>, bit_array<8>&>
still answers true, and the actual construction fails with 'm_bits' is private within this context
pointing into set_adaptor.hpp. A type trait that lies and an error inside a constructor the caller never
meant to reach are both worse than the constructor simply not being there, which is what the clause gives: no viable
deduction guide for bit_span(bit_set{}), and is_constructible_v false.
It is the constraint that does the work, so the friendships follow it rather than the other way round:
set_adaptor befriends set_adaptor and sequence_adaptor befriends sequence_adaptor.
What this gives up is real and small: bit_span(some_bit_set) does not compile. Nothing in the library or the tests
wants it — an assertion that it compiles would assert the mechanism, not a use — and a caller who genuinely wants
the other reading of an owner's bits is asking for a conversion between owners, which is an owner's to offer
explicitly and not a view's to perform silently. None exists today; if one ever should, it belongs beside the
containers, where it can be named and its cost seen.
the-interface-line¶
If a user never spells it, it lives in detail/. The name or the header, either counts. That is the whole
rule, and it is a test rather than a judgement: bit_fixed_set is spelled, bit_block_container is not;
set_adaptor's nested basic_reference is reached only through the iterator and reference typedefs and is
spelled by nobody.
What the rule keeps on the interface side: the eleven owners and the three views -- the set and sequence
readings over four storages each, the small ones among them under ext/, and the valarray reading over three --
with the concepts they are named by (bit_blocks and the mapping concepts), the tag, the conversion, the
transformations and the bit_ algorithms over them. The common vocabulary is a
test concept rather than a public one: nothing is constrained on it, so it is a claim about std::bitset and
boost::dynamic_bitset rather than an interface (the-common-vocabulary).
That is the whole of it. There is no storage extension point to publish (one-storage), Bits
having to be a bit_block_container. The adaptors are the harder call. Every container and every view derives
from one, so a name a consumer cannot avoid reading could be called interface whether or not anyone can write it,
and storage, window and the axis tags would come along because no adaptor can be named without them. What
settles it is that a consumer never has to ask. A container is what it is, and what it does -- bidirectional with
a key_type, or random-access over bool -- is askable in the standard's own vocabulary, without naming a base at
all. So the adaptors and their vocabulary are in xstd::bits::detail, and what namespace xstd holds is the
owners and views, their short aliases, the transformations over them, and the concepts.
On the other side, the two that have to be argued. The four proxy types are reached only through container
typedefs, so no user spells them. bit_block_container and its three aliases are the device that turns
two readings times three storages into two plus three (the-one-vehicle), and the
basic_ layer already exposes the block parameter — basic_bit_fixed_set<std::size_t, std::uint8_t, 24> reaches the
capability without the storage being named. Recorded against: bit_block_container is instantiated by
name throughout test/, which is a real signal, and demoting it makes the test tree reach into detail/. The
counter is that a test is not a user; a test tree that mirrors the library, detail/ included, is what testing
an implementation looks like.
Enforced, not asserted. test/consumer/main.cpp includes <xstd/bits.hpp> and, the ext/ column being
opt-in, <xstd/bits/ext/boost.hpp>, and no other header of ours. It names the eleven owners, the set and sequence
readings' over the fixed, inplace, small and dynamic storages and the valarray reading's over the fixed, inplace and
dynamic ones, and the three views, and nothing from detail/. It pattern-matches each container against its
reading — bidirectional with a key_type, random-access over bool, or random-access over bool with an == that
answers an array rather than a bool — for all eleven and over the three views besides, which is the
claim the-views-are-the-adaptors rests on, asked from outside and in the
standard's vocabulary rather than in ours. The view names it reaches by deduction, which is both the only way
in and the thing the classes are for. The three consumption
configurations build it against the installed headers, so a name that stops being reachable from the umbrella,
or an interface header that starts needing one from detail/, fails there rather than in a user's build.
Two failures are what it guards against. The first is a public header missing from bits.hpp, which reaches
consumers only transitively, through whichever headers include it — the same thing the include order guards
against, our headers before Boost's and the standard's so that a transitive include is found rather than leaned
on. The second is worse, and no compiler leg can catch it: CMakeLists.txt's FILE_SET HEADERS is hand-written,
and a header that reaches include/ and bits.hpp without reaching it is never installed, which breaks
every installed consumer's umbrella include on every compiler. Every compiler leg builds from the source
tree, where the files are present; only the consumer translation unit builds against the install tree, which is
why it includes the umbrella rather than one container header. The file set is checked against include/xstd/
at configure time, the way test/ checks that every public header is mirrored — a hand-written list that nothing
verifies is a list that drifts.
The rule reaches include/ as a whole, not only include/xstd/. A header beside the library under include/ is
never installed and so invisible to a find_package consumer, yet on the build-interface include path of every
add_subdirectory and FetchContent consumer, to whom its names are perfectly reachable. So the sieve the
containers are benchmarked with lives in examples/, in namespace opt, and the file-set check globs all of
include/, so a stray directory is a configure error rather than a silent extra (the-sieve).
ext/ is the one interface piece the umbrella leaves out. It costs nothing here — a consumer that wants the
small column includes the one header for it — and it keeps boost::container::small_vector off the path of
every consumer who does not.
the-public-names¶
Two layers of names, over a third nobody spells. The adaptors under detail/ carry the reading and take the
storage, the parameters each reading needs and no more (the-two-adaptors). The
basic_ layer leaves the storage open, flat_set-style, where the width is a run-time value, and the block open
where the type fixes the width: basic_bit_array<Block, N> beside basic_bit_vector<Blocks, N>, whose Blocks is
the whole storage and N an optional cap, and whose inplace and small siblings are aliases naming a storage. A set's
basic_ name leads with its key, as std::set<Key> does, then the storage, then the defaulted policies, the key's
mapping first: basic_bit_fixed_set<Key, Block, N, KeyMapping, Compare> and
basic_bit_set<Key, Blocks, N, KeyMapping, Compare>, with basic_bit_inplace_set<Key, Block, N, KeyMapping, Compare>
and basic_bit_small_set<Key, Block, N, KeyMapping, Compare, Alloc> naming two storages of the latter, and
KeyMapping defaulting to bit_key_mapping<Key> and Compare to std::less<Key>. The block leads the storage in
every column, so a basic_ name hands its base clause the arguments in the order it was given them --
basic_bit_fixed_set<Key, Block, N, KeyMapping, Compare> derives from set_adaptor<bit_block_container<std::array<Block, K>, N>,
storage::owned, basic_bit_fixed_set<Key, Block, N, KeyMapping, Compare>, Key, KeyMapping, Compare>, straight
through. Taking <N, Block> in the static and inplace columns and transposing at the call would gain nothing:
Block carries no default in those columns, so it is free to lead, and leading is what
std::array<T, N>, std::inplace_vector<T, N> and std::span<T, Extent> all do with the pair. The restricted
layer fixes std::size_t and std::allocator: bit_fixed_set<N> and bit_array<N> keep one
parameter, and bit_set and bit_vector keep none, so the flagship is xstd::bit_set, without the <>. A set's
short name fixes its key to std::size_t as well: bit_fixed_set<N> is basic_bit_fixed_set<std::size_t, std::size_t, N>.
The key is spelled wherever the basic_ form is, because it changes the interface: key_type, what iteration
yields, and what insert, find and contains accept. The positions stay the storage, and KeyMapping is the key's
mapping: an order-preserving bijection from a finite universe of keys onto the positions [0, N), given by two
static members, to_index(key) and from_index(index), and, where the mapping closes the universe, a size that is
that N and an is_key(key) that says whether a value is one of those N keys. Two public concepts, each in its own header under <xstd/bits/bit_concepts/>, say so. bit_index_mapping<M, Key> asks for
M::to_index taking a Key to a std::size_t and M::from_index giving back exactly a Key, and carries what no
syntax check can see: a < b exactly where to_index(a) < to_index(b), and from_index(to_index(k)) == k for every
k in the universe. sized_bit_index_mapping<M, Key> adds M::size and M::is_key, returning exactly bool, and
with them to_index(k) < size for every key. is_key(k) is the universe tested, which is to_index's precondition:
a range's First <= k < First + N, computed as the unsigned distance from First so that no bound overflows the key
type, a list's binary search, the same one to_index ranks by, and a flag's one bit set below N. The open identity
has an is_key too, true for every key that names a position, which is every key no wider than std::size_t, and
bits::detail::is_key<M>(k) asks it of any mapping, answering true where the mapping declares none; an unsized
mapping need not, its universe being open. An owner with a width or a capacity in its type must equal that size, checked by a static_assert. Every set
owner constrains its KeyMapping with the first and asks the second wherever the universe bounds the width. The
family is four templates, each taking its key first. bit_key_mapping<Key> is the default an owner takes and the
customization point, specialized beside a key type as std::char_traits is beside a character type.
bit_range_mapping<Key, First, N> maps consecutive keys by subtraction, bit_find_mapping<Key, Keys> maps a sorted
list of keys by binary search, and bit_flag_mapping<Key, N> maps one-bit values by their bit
(flag-types). Only the unsigned identity leaves the universe open. bit_key_mapping<Key> is the identity with no size for every xstd::unsigned_integer key, from
std::uint8_t to the 128-bit types: a key narrower than std::size_t names fewer positions, and one wider must name
a position that fits, as a precondition. A signed key has no default: offsetting by its most negative value would put
0 in the middle of the universe, so a bit_set<int> holding {0} would take 2³¹ bits. It names its range instead,
through bit_range_mapping<Key, First, N>: the N consecutive keys from First, a contiguous range, at positions
0 to N - 1, with a size of N. The arithmetic is modular in the key's unsigned counterpart, an enumeration's
through std::to_underlying and its underlying type's, so First may be the type's minimum and an enumeration whose
values run without a gap needs no list. A strong index type specializes
bit_key_mapping or is given a mapping of its own. to_index must preserve order, so that ascending positions
are ascending keys. Every member that takes a key maps it through to_index, and the iterator and its proxy hand out
from_index of the position, so a set formats as its keys do; set algebra, comparison, hashing and the block
exchange work on blocks and never see a key. The views stay keyed by std::size_t: a view reads positions it does
not own, and names no key of its own.
An enumeration is keyed by rank, not by value. Its author declares the values once, beside the enumeration:
template<> struct xstd::enum_traits<E> { static constexpr std::array values = {E::a, E::b, ...}; };, ascending by
underlying value and each value once. enum_traits only lists; bit_key_mapping<E>, specialized for every
enumeration that declares a list, picks the mapping that reads it. Where the list turns out contiguous, each value one
above the last, which is decided at compile time, it derives from bit_range_mapping<E, values[0], size>, and
to_index is a subtraction in the underlying type's unsigned counterpart, so a dense enumeration starting anywhere,
negative included, pays no search. Otherwise it derives from bit_find_mapping<E, values>, which checks the list
strictly ascending with a static_assert: size is its length, from_index(i) is values[i], and to_index(e) is
e's rank, found by binary search. Gaps cost nothing, so
{pawn = 1, knight = 3, bishop = 4, rook = 8, queen = 9, king = 100} takes six bits rather than a hundred. A value
not in the list ranks at size or above under either, and every owner refuses to write it there with
std::out_of_range: a fixed set because size is its width, and a growing one, whose storage could hold the
position, because size closes the universe all the same. A growing owner's max_size() is that size as well, so
a left shift drops what it carries past the last key, as it does at a static width. A lookup asks is_key before
to_index, so contains, count, find and erase(k) answer absent for any
value of the key type, as std::set's do; the bounds of a value that is no key bisect the keys under key_compare,
which puts First - 1 before a range's first element where the wrapped distance alone would have put it after the
last, and an unlisted value between two listed ones between them. A key type with no order of its own, a
std::bitset, has no place between two keys to give, and its bounds keep is_key as their precondition. basic_bit_fixed_set<E, Block, N> with no mapping spelled keys it the same way. An enumeration that lists nothing
but ends in a sentinel, enum class E { a, b, c, count };, is keyed by that sentinel, below. One that declares
neither has no default mapping, and so no set, unless its author specializes bit_key_mapping<E> directly, say as a
bit_range_mapping from its first enumerator. The same bit_find_mapping keys sparse integers, such as identifiers
drawn from a fixed table, to as many positions as there are identifiers.
The count comes from the declaration because C++ has no portable way to count enumerators. Many enumerations
already carry one: enum class piece : std::uint8_t { pawn, knight, bishop, rook, queen, king, count }; says
"from the first enumerator, count of them", and type_safe's flag_set_traits reads such a sentinel too. So
bit_key_mapping<E> reads it where the author declared it. For an enumeration with an enumerator named count and
no enum_traits<E>::values, it derives from bit_range_mapping<E, E{}, N>, N being count's distance from
E{}: to_index is a subtraction, size is N, and the sentinel, at position N, is never a key, so
bit_enum_set<piece> is one byte holding six keys and insert(piece::count) throws std::out_of_range as any
other non-key does. The name is exactly count, one spelling, since every further spelling (size, COUNT,
_size) is another way for an unrelated enumerator to be read as a sentinel, and an inclusive alias such as
Chromium's kMaxValue = kLast would be a second rule rather than a synonym. The range starts at E{}, the value
0; an enumeration starting elsewhere lists its values or spells its bit_range_mapping. A count below zero is
rejected by a static_assert, and a count of 0 is the empty universe, as bit_range_mapping<E, E{}, 0> is. The
sentinel means that every value from 0 to count - 1 is a key, and C++ cannot check that: an enumeration
{a = 0, b = 2, count = 3} gets a three-key range in which the undeclared value 1 is a key. A list is the
enumeration's own account of its keys and keeps priority, so an enumeration that lists its values keeps the list's
mapping whether it ends in count or not, and the two specializations never compete.
The sentinel is read, never required. Requiring one would make the width a value of E, which every exhaustive
switch must then handle and which cannot be added to an enumeration someone else owns; an enumeration without one
behaves as it would if the rule did not exist. Counting by parsing compiler-generated function names, as magic_enum
does, works only within a guessed value range and is no part of the language. enum_traits<E> remains the general
way: it leaves E as it is, takes gaps and any first value, and can be declared by whoever needs it; C++26
reflection, std::meta::enumerators_of(^^E), can generate values later without changing what bit_key_mapping
reads.
bit_enum_set<Enum> is bit_least<basic_bit_fixed_set<Enum, std::size_t, N, bit_key_mapping<Enum>>>, N being
the mapping's size, for any enumeration whose bit_key_mapping models sized_bit_index_mapping. The block is
bit_least's (block-and-width-transformations), the narrowest of std::uint8_t to
std::uint64_t that holds the N keys, since an enum set mostly lives inside other structures as a flag field: a
three-value set is one byte, not eight. Wider than 64 values, it is several std::uint64_t. The alias takes no
block, so the block policy is stated once, by bit_least. A field of fixed wire width is
basic_bit_fixed_set<E, std::uint32_t, N>, whose mapping defaults to the same bit_key_mapping<E>, and a set over
another mapping of the enumeration is bit_least<basic_bit_fixed_set<E, std::size_t, N, M>>, which spells the
mapping and keeps the block policy. least_block_t is public, in <xstd/bits/bit_type_traits/bit_least.hpp>, so that the policy has
a name outside the alias. A deduction guide on the class template, not on the alias, deduces that same type from a
braced list of enumerators, basic_bit_fixed_set{E::a, E::b}; alias deduction is not relied on, being unreliable
on older compilers. An enumerator meets a set through the set's own type: |, &, ^ and - take a set on one
side and an enumerator on the other, and |=, &=, ^= and -= an enumerator on the right, the enumerator acting
as the one-element set holding it. The value-returning forms are hidden friends of the set, so two enumerators
never reach them, and nothing is declared on the enumeration: E::a | E::b does not compile, and two enumerators
combine as bit_enum_set<E>{E::a, E::b}. Each is constrained to an enumeration key_type whose mapping is no mask
mapping (flag-types), where a value is a set of keys rather than one, so a set of integers
gains no mixed operator, and with key_type an enumeration nothing converts an int into one, so insert(1),
contains(1) and find(1) do not compile.
Compare keeps std::set's place, after the key's mapping and before the allocator, and is defaulted because it
leaves every member signature as it is. Position order is structural, so a comparator can only choose a direction:
std::less<Key> and std::less<> ascend, std::greater<Key> and std::greater<> descend, and any other type is
rejected by the owners' template heads. The mapping comes first because the direction is defined over the order
to_index preserves. Under std::greater the set behaves as std::set<Key, std::greater<Key>> specifies: begin()
is the highest set position and ++ steps down, lower_bound is the largest key not above its argument and
upper_bound the largest below it, key_comp() and value_comp() return std::greater, and xstd::ranges::bit_for_each walks
in that order, and against it over std::views::reverse. The iterator carries the direction rather than wrapping
std::reverse_iterator, which would step back on every dereference; both directions end at size(). Equality, set
algebra, hashing and the block exchange are unchanged, since they see the same blocks. <=> is
[associative.reqmts]' lexicographic comparison of the keys in iteration order, which over descending keys is the masks
compared as unsigned numbers, highest block first, a missing high block reading as zero; numeric_three_way beside
set_three_way does that a block at a time. The transparent forms add the heterogeneous find, count, contains,
lower_bound, upper_bound, equal_range and erase: a key of another type has no position, so they bisect the
positions for the run of keys equivalent to it under Compare. The short names keep std::less, and the views ascend.
The unmarked name goes to the flagship — bit_set is the dynamic set
benchmarked against std::set and std::flat_set — and the qualifier marks the special case, bit_fixed_set.
The sequence row is named after the std container it packs, bit_array for std::array<bool, N>. The rows
therefore mark different columns, and that is correct by each row's own analogy rather than an inconsistency
to fix.
Why the prefix leads. bit_ is a storage-strategy prefix, and the Standard already has the other one:
std::flat_set keeps a sorted sequence of the elements that are there, so it is sparse in the universe of
possible keys, where bit_set keeps one bit per position in that universe, so it is dense but packed. Same
container, same interface, different representation — and the prefix is what says which, so it has to come
first. fixed_bit_set would read as a qualified bit_set; bit_fixed_set is bit_ applied to a
fixed set, the way flat_set is flat_ applied to a set.
Why fixed and inplace. inplace is the standard's word for a run-time size under a compile-time
capacity, and the qualifier takes it so that bit_inplace_vector is what std::inplace_vector<bool, N> would be.
It names that behaviour, not one storage: boost::container::static_vector holds the same blocks where the
standard library has no std::inplace_vector, as it held them before P0843 renamed boost::static_vector to
std::inplace_vector, in part because static is overloaded in C++. Where the standard has no word, the
qualifier names what the user gets: fixed is a width fixed at compile time, and finite would select nothing,
since bit_set is a finite set of positions too. The qualifier comes after bit_, as small does in
bit_small_set.
One header per restricted name, holding its basic_ form beside it, each over one storage: bit_set and
bit_vector over std::vector<Block, Allocator>, beside bit_fixed_set and
bit_array over std::array<Block, num_blocks_v<Block, N>>, and bit_inplace_set and bit_inplace_vector
over inplace_blocks<Block, num_blocks_v<Block, N>> (the-inplace-column). The
header is the name's home and the only place it is spelled; bits.hpp includes them all.
What bit_ names. The prefix marks what works on the packed bits: the containers, the concepts and
transformations over blocks (bit_concepts/, bit_type_traits/, bit_align, bit_least), and bit_hash. The unprefixed name
works on the value as the standard model sees it, as xstd::hash does. So bit_hash is the
representation-level hash and xstd::hash the value-level one. The prefix names what is hashed, the packed
representation, not whether the code is optimized: the hooks behind xstd::hash expand bits to bools with
_pdep_u64, AVX-512 or a table, and are as optimized, yet still append the model's bytes.
block-and-width-transformations¶
Two type transformations, one header each under <xstd/bits/bit_type_traits/>, take an owner and return the same
owner with one argument changed, every other passed through, as std::simd's rebind_t and resize_t do:
bit_rebind<Block, X> puts it over another block, its allocator rebound with it through
std::allocator_traits<A>::rebind_alloc<Block>, and bit_resize<N, X> gives it another width. Four more are named
choices written through that pair, the block or width computed from X's own:
| Transformation | Block | Width |
|---|---|---|
bit_rebind<B, X> |
B |
X's own |
bit_resize<M, X> |
X's own |
M |
bit_least<X> |
least_block_t<N>: the narrowest of std::uint8_t to std::uint64_t holding N, else std::uint64_t |
N |
bit_fast<X> |
fast_block_t<N>: std::uint_fast8_t to std::uint_fast64_t by the same thresholds |
N |
bit_align<X> |
X's own |
align_up(N, digits), whole blocks of X's block |
bit_underlying<X> |
underlying_block_t<X::key_type>: the key's underlying type, enumeration or integer, made unsigned |
N |
bit_rebind takes every owner: basic_bit_array<Block, N>,
basic_bit_fixed_set<Key, Block, N, KeyMapping, Compare> and basic_bit_fixed_valarray<Block, N>, and
basic_bit_vector<Blocks, N>, basic_bit_set<Key, Blocks, N, KeyMapping, Compare> and
basic_bit_valarray<Blocks, N> over a storage with a bit_storage_rebind, the inplace and small aliases and the
short names included. bit_underlying takes the sets among them, the owners with a key_type. bit_resize,
bit_least, bit_fast and bit_align read or write a width, so they take the owners whose type carries one: a
fixed width, a cap, or a storage whose bit_storage_rebind counts its blocks, the inplace ones changing their
capacity and the small ones their inline blocks. So bit_rebind<std::uint8_t, bit_vector> is
basic_bit_vector<std::vector<std::uint8_t>>, bit_least<bit_fixed_set<9>> is
basic_bit_fixed_set<std::size_t, std::uint16_t, 9>, two bytes where bit_fixed_set<9> is eight, and
bit_align<bit_array<9>> is bit_array<64>, whose last block carries no unused tail. A type with nothing to change,
a view, a standard bit container or a bare block, and a run-time owner asked for a width, is rejected by the alias's
constraint rather than deep inside it.
The names are <cstdint>'s: std::uint_least8_t is the smallest type of at least 8 bits and std::uint_fast8_t the
fastest, and the platform decides how wide that is. glibc on x86-64 makes std::uint_fast16_t and
std::uint_fast32_t 64 bits wide where MSVC makes them 32, so bit_fast names a block and promises no size.
bit_underlying<X> asks more of X: a set whose key_type is an enumeration or a built-in integer type with an
unsigned counterpart, which excludes enum : bool, bool, the character types and every class key, a std::bitset
mask among them. Its block is the word an existing field or ABI already stores those flags in,
std::make_unsigned_t of the enumeration's underlying type or of the integer type itself, and named
underlying_block_t<K> beside least_block_t and fast_block_t: underlying_block_t<int> is unsigned, which is
what an int field of flags is read as. The rule is by type, not by mapping, so a set of positions has one too:
bit_fixed_set<N> is keyed by std::size_t, whose word is its own, and
bit_underlying<bit_least<bit_fixed_set<9>>> is bit_fixed_set<9> again. That is what a flag set wants at a
boundary: bit_underlying<bit_fast<bit_flag_set<E>>> is back in E's own word,
bit_underlying<bit_flag_set<E, 9>> holds its nine flags in that word rather than the two bytes bit_least chose,
and either way the set's block is the mask's representation bit for bit, so xstd::bit_convert, from_blocks and
std::bit_cast to and from that word are copies. For a flag set at the mask's full width, the default, bit_least
already chose that word, and bit_underlying is the identity; it is the transformation that says why, rather than
one that happens to agree. The precedent for the form is std::make_unsigned_t<T>, a type in and a related type
out, and C++26's std::simd, whose rebind_t<U, V> changes the element type of a basic_simd and resize_t<N, V>
its width, each keeping the other, and bit_rebind and bit_resize take the names and the argument order of that
pair. Each owner specializes one exposition-only trait that names its block, its width where its type has one, and
itself over another block or width; the two aliases are written once against that trait, and the four choices once
against the two. Spelling the same choices as namespaces, aligned::bit_fixed_set<N> beside
xstd::bit_fixed_set<N>, would re-declare every class template's parameter list in each namespace with its own
defaults and constraints, and such copies drift: an aligned::basic_bit_fixed_set taking an unconstrained Compare
where the class takes only a direction surfaces a misuse inside the class rather than at the name. A transformation
has no parameter list of its own to keep in step, and so cannot drift; it also applies to a type a user has already
named, mapping and comparator included, rather than asking for them again.
They compose, and the order matters. bit_align<bit_least<bit_fixed_set<3>>> picks the byte first and then fills
it, basic_bit_fixed_set<std::size_t, std::uint8_t, 8> in one byte: least first, then align, is the compact form
with no padding and no unused tail. bit_least<bit_align<bit_fixed_set<3>>> rounds to the 64 bits of the default
block first, and the least block of 64 bits is std::uint64_t, eight bytes. bit_align<bit_fast<X>> is the fast
block filled the same way. Each is idempotent.
Past 64 bits the least block is std::uint64_t and the set takes several of them, so a wide set still scans a word
at a time. The choice has prior art. N4202, Strongly Typed Bitset, proposed a block
parameter for std::bitset and a small_bitset<N> over the smallest of std::uint_least8_t to std::uint_least64_t;
it chooses by N % 64, so a 72-bit small_bitset is nine one-byte blocks where bit_least<bit_fixed_set<72>> is two
words. type_safe's flag_set<Enum> stores its flags in the smallest of
std::uint_least8_t to std::uint_least64_t that holds them, and takes no more than 64. bit_enum_set is
bit_least over an enumeration's fixed set, as above.
A block need not be a power of two. One that is, of a byte or more, tiles any width: no position and no byte
straddles two blocks, so every owner and view takes it. Any other width, C23's unsigned _BitInt(17) or
unsigned _BitInt(127) spelled xstd::bit_uint<N>, holds one fixed-width value: bit_array and bit_fixed_set
take it for a width up to its own, in a single block, and the inplace, small and growing owners refuse it, since a
second block would put a position across two of them. Clang on x86-64 stores such a block in the next standard
integer size, so _BitInt(23) takes the four bytes of a std::uint32_t and nine padding bits the library never
reads; what the block buys is the exact width in the type, not fewer bytes.
Other libraries either pick the block themselves, from the width, or take it from the user as a template argument:
| Library | Who picks the block | Rule | Whole-block width | Changing the block |
|---|---|---|---|---|
| xstd-bits | the user, by transformation or argument | std::size_t by default; bit_least<X> the least type holding N, bit_fast<X> the fast one, bit_underlying<X> the key's underlying type made unsigned; bit_enum_set and bit_flag_set are bit_least already; any block as the Block argument of a basic_ template |
bit_align<X> |
by transformation, on any fixed-width owner, its key, mapping and order kept; the transformations compose |
type_safe ts::flag_set<Enum> |
the library | std::uint_least8_t to std::uint_least64_t, the least holding the number of flags; no more than 64 flags |
no | no |
itsy_bitsy bitsy::bit_view<Range, Bounds>, bitsy::bit_sequence<Container>, bitsy::dynamic_bitset<T> |
the user | the value type of the range or container adapted, or T, with no default |
— | by naming another range, container or T |
Chromium base::EnumSet<E, Min, Max> |
the library | uint8_t, uint16_t or uint32_t by the number of values from Min to Max, and a std::bitset past 32 |
no | no |
Mozilla mozilla::EnumSet<T, Serialized> |
the user, with a default | Serialized, by default the enumeration's underlying type made unsigned, which is bit_underlying's rule; any unsigned type, or a mozilla::BitSet |
no | by spelling the argument again |
MSVC STL std::bitset<N> |
the library | unsigned long up to its 32 bits, else unsigned long long |
no | no |
libstdc++ std::bitset<N> |
the library | unsigned long: 64 bits on LP64 targets, 32 on LLP64 ones such as MinGW |
no | no |
libc++ std::bitset<N> |
the library | std::size_t |
no | no |
Boost boost::dynamic_bitset<Block, AllocatorOrContainer> |
the user, with a default | Block, by default unsigned long |
— | by spelling the argument again |
LLVM llvm::Bitset<NumBits> |
the library | uintptr_t, as for llvm::BitVector |
no | no |
A dash marks a width chosen at run time. Checked against type_safe's
flag_set.hpp
at 292e8c1, itsy_bitsy's
bit_view.hpp
and bit_sequence.hpp
at d5b6bf9, Chromium's
enum_set.h
at b0840fa, Firefox's
EnumSet.h
and BitSet.h
at 3c71a54, MSVC STL's <bitset>
at da52dc0, libstdc++'s <bitset>
in GCC 16.2.0, libc++'s <bitset>
and LLVM's Bitset.h and
BitVector.h in LLVM
23.1.3, and Boost's
dynamic_bitset.hpp
in Boost 1.92.0.
None of the others changes the block of a type already named or rounds a width to whole blocks on request: where the block is an argument the type is spelled again with another, and where the library picks it the user cannot.
flag-types¶
A flag type replaces a bitmask type such as std::filesystem::perms: code written against the mask keeps the
spelling of its constants, only the variable's type changes, and the set vocabulary comes on top.
bit_flag_set<Mask, N> is the set of Mask's one-bit values below N: its key_type is Mask itself, each key a
value with exactly one bit set, ranked at its bit by bit_flag_mapping<Mask, N>, and N defaults to the mask's
width. It is no class of its own. There is one fixed set, basic_bit_fixed_set, and the flag type is an alias of it,
bit_least<basic_bit_fixed_set<Mask, std::size_t, N, bit_flag_mapping<Mask, N>, std::greater<Mask>>>, in the least
block holding N as bit_enum_set is. What makes a fixed set a flag type is its mapping. bit_mask_mapping<M, Key>
refines sized_bit_index_mapping with M::to_block, which takes any value of Key, one-bit or not, to the block
holding its bits at their positions, and M::from_block, which reads one back; bit_flag_mapping models it and no
other mapping does. Under that concept alone, basic_bit_fixed_set declares the members a flag type has: the
conversion from the mask and to it, the union of a braced list and the mixed operators; through the conversion,
xstd::ranges::bit_includes, xstd::ranges::bit_intersects and xstd::ranges::bit_disjoint take a mask as the other set. A set of positions or of listed enumerators has none of them, and a flag set
over another block, bit_fast<bit_flag_set<Mask>> or basic_bit_fixed_set<Mask, std::uint8_t, 16, bit_flag_mapping<Mask, 16>>
over two bytes, or in ascending order, has all of them.
examples/include/xstd/filesystem.hpp is one line, using perms = bit_flag_set<std::filesystem::perms, 16>;,
sixteen bits rather than the twelve permissions so that std::filesystem::perms::unknown, 0xFFFF, survives the
round trip; its four high bits are keys like the others.
Mask is an enumeration, a built-in integer type or a std::bitset<M> of any width M, the three forms
[bitmask.types]/1 allows. Its word is the enumeration's underlying type made unsigned, the integer type's unsigned
counterpart, or for a bitset the least block holding M, and past the widest block an array of them:
std::bitset<12> is read as a std::uint16_t, and std::bitset<147> as a std::array<std::uint64_t, 3>. Every
conversion between the mask and the set goes through that word, position i staying position i: a cast for an
enumeration or an integer, and for a bitset the bytes xstd::bit_convert moves a bitset by, which reach a word wider
than M with its high bits clear. bit_mask_mapping asks the word to be fixed bit blocks, one unsigned block or a
fixed array of them, which is xstd::bit_blocks' own two cases with the width in the type, and never the signed type
under an enumeration. Of the three shapes weighed for a mask wider than a block, this is the word array: the set's
from_blocks already takes any fixed blocks at least N wide and drops what lies above, so the conversion, & and
- need nothing new, and an enumeration or an integer keeps its one-cast path. Dropping the mapping's to_block and
from_block for an equal-width bit_convert between set and mask would have cost that path and still needed a
widening step below M, and a bit-precise word, xstd::bit_uint<M>, exists on Clang alone. A rank and its key are
one bit counted across the blocks of the word, and fits, no bit at or above N, compares the word with the one
that keeps only the bits below N, so bit_flag_set<std::bitset<147>, 140> refuses a mask with bit 145 set on the
way in and truncates it under & and -. bool and the character types are integral but no integer type, and are
not masks. The 128-bit integers are masks too: bit_flag_set<xstd::uint128> is 128 flags over two 64-bit blocks on
GCC and Clang alike, in the GNU dialect the library builds in and under a strict -std=c++23, where they are no
std::integral and xstd::integer admits them instead.
An enumeration is a mask when xstd::bit_mask says so, its operators returning the enumeration, and also whenever
its | compiles, whatever that returns. Qt's flag enumerations return a QFlags<Enum> from |, and a C enumeration
with no operators of its own returns the int of the built-in one; both are bitmasks by intent, and writing
bit_flag_set<E> already says so. A scoped enumeration with no |, such as std::filesystem::file_type or
std::regex_constants::error_type, is an enumerated type and no bitmask, and is turned away: it is the likeliest
accident. An unscoped enumeration that is no bitmask still has the built-in |, and is admitted; that is the rule's
cost, paid only by someone who names it in bit_flag_set.
The width an enumeration defaults to depends on how it is declared. With an unsigned underlying type, it is
every bit of that type: Qt's WindowType and glibc's EPOLL_EVENTS put a flag on bit 31 that way. With a signed
underlying type, it is the integer's width less the sign bit, below: 31 for an int-based enumeration such as
std::launch, exactly as for int itself. With no fixed underlying type there is no default, and bit_flag_set<E>
does not name a type: such an enumeration's values are only those of the smallest bit-field holding its enumerators
([dcl.enum]/8), and a flag type over more bits than that forms a value outside the enumeration on ~ or on
iterating its high keys, which is undefined behaviour and which UBSan reports. E{0} is well-formed exactly where the
underlying type is fixed ([dcl.init.list]/3.8), and that is the test. The range itself cannot be computed: Clang
rejects an out-of-range static_cast<E> in a constant expression and GCC accepts it, and C++ cannot list
enumerators. So the user writes the width, as bit_flag_set<Qt::AlignmentFlag, 9> for an alignment whose
enumerators reach bit 8. libc++'s regex_constants::syntax_option_type and match_flag_type, and libstdc++'s
ios_base::fmtflags, iostate and openmode, are such enumerations. C++26 reflection lifts the restriction:
std::meta::enumerators_of(^^E) gives the enumerators, and with them the range.
An integer mask keys its one-bit values, not its numbers. bit_flag_set<std::uint32_t>'s keys are 1, 2,
4, … 0x8000'0000, exactly as an enumeration's are; it is bit_flag_mapping, not the key type, that decides,
and bit_fixed_set<N>, keyed by the positions 0 to N - 1 through bit_key_mapping<std::size_t>, is a different
set over the same std::size_t. xstd::bit_mask admits only the unsigned integer types, but [bitmask.types]/1
says "an integer type" without a sign, and an implementation picks one: the flag type also takes a signed Mask,
and keeps its sign bit out of the universe. N is at most
std::numeric_limits<Mask>::digits, which counts no sign bit, and defaults to it: 31 for int, 7 for
std::int8_t, every bit for an unsigned type. An enumeration on a signed underlying type is counted the same way,
so int and an int-based enumeration agree about bit 31. So every value a signed flag type gives back is
non-negative, and a value with the sign bit set is a value wider than N, below: it narrows under brace
initialization, bit_flag_set<int>{0x8000'0000U}, and fails the conversion's assertion in a constant expression and
at run time. The arithmetic is the unsigned counterpart's: a rank is its countr_zero and rank i is 1 shifted
left i places in that word, cast back, which is why the sign bit, the one position a shift there would reach, is
excluded rather than handled.
A bit set's order comes from bit positions, and a signed value order cannot match it once the sign bit is set:
std::greater<E> ranks a flag on the sign bit lowest, where the walk from the highest position visits it first, so
iteration, lower_bound and <=> would disagree with std::set<E, std::greater<E>>. Four ways to keep such a flag
were weighed and rejected, each costing machinery or a comparator users cannot name: rotating the storage by one
bit so that the sign flag ranks lowest, a cyclic walk starting at the sign bit, a signed index type for to_index,
and a position comparator in place of std::less and std::greater. The one real case found, POSIX's sa_flags
with glibc's SA_RESETHAND at 0x80000000, is an unsigned constant stored in an int field: the word is int,
the flags are unsigned, and bit_flag_set<unsigned> keys it, with a cast on each side of the field.
The standard's own bitmask types are the case in point. [ios.base] declares fmtflags, iostate and openmode
as bitmask types and leaves the rest to the implementation: libstdc++ makes each an unscoped enumeration with no
fixed underlying type and the operators overloaded, libc++ unsigned int, and MSVC int, its constants of an
unscoped enumeration beside it. libstdc++'s enumerations take no default width, so the spelling for all three is
bit_flag_set<std::ios_base::fmtflags, 16>, every standard constant of every implementation below bit 16, and code
written against the stream carries over with only the variable's type changed:
fmtflags f = os.flags(); f -= ios::basefield; f |= ios::hex; os.flags(f);.
- Implicit conversions both ways. Values from the standard's functions flow in,
xfs::perms p = fs::status(path).permissions();, and ours flow out,fs::permissions(path, p). The converting constructor takes any value of the mask, so a composite such asfs::perms::owner_allis an ordinary argument, and a braced list is the union of its values:{m}is the conversion fromm, and{a, b}of two one-bit values is the set of both, which is what [set.cons] makes of it too. - The mask's constants are the flag type's.
fs::perms::owner_read | pis a flag type, so the standard's spelling carries over unchanged and no constant is written twice. This is the split Qt'sQFlags<Enum>makes, a set type over the enumeration that names its values, where Rust'sbitflags, Python'senum.Flagand Swift'sOptionSetmake the element the type itself. - The set reading. Iteration yields the one-bit values, highest first;
contains,insert,erase,find, the bounds,rbegin,clear,swap, the hash andmax_size(), which isN, come with the adaptor.
The flag type is itself an xstd::bit_mask, a [bitmask.types] type with ~, |, &, ^ and their compound
forms closed over it, which the tests assert of each instantiation.
Other libraries offer one of an enum set or a flag type, seldom both:
| Library | Enum set | Flag type | Element | Iterates? |
|---|---|---|---|---|
| xstd-bits | bit_enum_set<E>, keyed by rank |
bit_flag_set<Mask, N>, over an enumeration, an integer or a std::bitset |
the mask's one-bit values | both iterate |
| C++ standard | — (std::bitset<N> is indexed, not keyed) |
bitmask types ([bitmask.types]: fs::perms, ios_base::fmtflags) |
— | no |
| Qt | — | QFlags<Enum> |
Enum |
no |
Chromium base |
EnumSet<E, Min, Max> |
— | — | yes |
| Rust | enumset crate |
bitflags crate |
Self |
yes |
| Python | — (a plain set of Enum members) |
enum.Flag, enum.IntFlag |
Self |
yes, named members only |
| Java | EnumSet<E>, keyed by ordinal |
— | — | yes |
| Swift | — (a hashed Set<E>) |
OptionSet |
Self |
no |
| C# | — (HashSet<E>) |
[Flags] enum |
— | no |
Java's EnumSet keys by ordinal, which is bit_enum_set; Qt's QFlags keys by the mask's own values, which is
bit_flag_set. Only Rust, through two crates, and this library offer both, and here both are the same set reading.
WG21 has worked on bitsets and bitmasks in pieces. Each paper below is cited where its point matters; here they are together, with what each contributed and where this library takes it up:
| Paper | Year | What it proposed | Where this library meets it |
|---|---|---|---|
| N2050 | 2006 | A dynamically sizeable bitset for TR2, after boost::dynamic_bitset, with set queries such as is_subset_of beside push_back and resize; libstdc++ still ships it as std::tr2::dynamic_bitset. |
bit_set and bit_vector. The paper joins a set and a sequence in one type; here they are two readings of one storage. |
| N3110 | 2010 | Problems with the library's bitmask types, ios_base::fmtflags, iostate, openmode and the regex_constants flags: operators declared as members, an operator~ that was a no-op or recursed, an underlying type that should be fixed so that ~ stays in range, and the point that the standard must not require enumerations, since [bitmask.types] allows integers and std::bitset too. |
Flag types over an enumeration, an integer or a std::bitset. ~ stays within N on the unsigned block, and an enumeration with no fixed underlying type spells its N. bit_flag_set<std::ios_base::fmtflags, 16> is one spelling across three standard libraries. |
| N4202 | 2014 | Strongly Typed Bitset: a storage parameter T for std::bitset, small_bitset<N> over the least uint_least*_t holding N, fast_bitset<N>, and underlying(). |
The Block parameter, bit_least, bit_fast, from_blocks and bit_convert. N4202 leaves the bits at or above N implementation-defined; here they are always zero, so ==, hashing and bit_cast are exact. |
| P0125R0 | 2015 | std::bitset::is_subset_of and is_superset_of, members that may short-circuit, the names N2050 used. |
xstd::ranges::bit_includes, bit_intersects and bit_disjoint, free functions after std::ranges::includes. |
| P3103R0, R2 | 2024 | More bitset operations: rotl, rotr, reverse, countl_zero(pos) and countr_zero(pos) and their one forms, and one(). |
xstd::ranges::bit_rotate and bit_reverse, in std::rotate's direction. Iteration, lower_bound and upper_bound cover the positional counts. |
| P4313R1 | 2026 | Bitmask operations for enums: \|, &, ^, ~ and their compound forms for an enumeration annotated [[=std::bitmask_type]], as operator templates in the global namespace, each returning the enumeration. |
Complementary: P4313 gives the enumeration its operators, and bit_flag_set gives it the set reading, contains, size, iteration and the containments, which the paper leaves out. Its global-namespace operators are what this library never declares for a user's type (the-adl-firewall). |
An enumeration with P4313's operators is an xstd::bit_mask, and bit_flag_set<E> takes it unchanged, where lookup
finds the operators. xstd::bit_mask is a concept in namespace xstd, so its a | b sees what unqualified lookup
finds at the concept's definition and what argument-dependent lookup finds at the use. An enumeration in the global
namespace brings the global operators in by ADL; one in a named namespace finds them only if they were declared
before the concept, since ADL never searches the global namespace for it. P4313R1 puts the operators in the global
namespace and says nothing about an enumeration declared in a named one, so including <bitmask> after the xstd
headers would leave such an enumeration with operators its own callers find and no flag type. The tests emulate the
annotation with a trait and pin all three cases: global, named with the operators first, and named with them last.
A value wider than N meets the flag type as the enumeration always has. Its positions at or above N are
truncated by &, - with the flag type on the left, their compound forms and ==, which finds such a value
unequal, and are precluded by an assert on the way in: the conversion, |, ^, their compound forms and - with
the mask on the left. xfs::perms(xstd::from_blocks, 0xFFFF) takes the block as it is, bitflags'
from_bits_retain, and xstd::bit_convert<std::uint16_t>(p) reads it back; a block wider than N has its bits at or
above N dropped, as every fixed set's from_blocks does.
A flag type orders as the mask it replaces, highest flag first. The alias has no Compare parameter: it always
passes std::greater<Mask> to the fixed set, and over a descending set the adaptor's <=> is numeric_three_way, the block
compared as an unsigned number: the order the bitmask enumeration's own relational operators give, the order of a
bitset's to_ulong(), and the Ord that Rust's bitflags derives. It stays the container's lexicographic
comparison over its own iteration, since walking from the highest position down, the first difference is the
highest bit that differs. So iteration, front(), back() and lower_bound run from the highest flag down: for
perms, set_uid through others_exec, the order in which ls -l reads the mode. std::greater<std::bitset<M>>
is never called, a bitset having no <: the adaptor orders by position and only hands the comparator out through
key_comp(). A transparent comparator would turn on the heterogeneous contains(K), and is not offered either.
The mixed operators are generated, because hand-writing them goes wrong. With only the homogeneous operators,
p ^ std::filesystem::perms::owner_write is ambiguous: ours wants a conversion on the right, the mask's own
operator^ one on the left. The flag type declares ==, |, &, ^ and - against the mask in both orders,
each an exact match for both operands, so it wins outright; == is one declaration, its reversed form being the
language's. Each is a hidden friend template whose mask parameter must deduce exactly as Mask, so that a flag
type, converting to the mask, never deduces as one, and each takes the flag type by value, an identity conversion
that beats the adaptor's key-typed operators, which reach the flag type through its base and would otherwise read a
multi-bit value as one key. The adaptor's operators that read an enumerator as the one key it is are constrained off
wherever the mapping is a mask mapping, so they never compete. The compound forms against the mask are hidden friends
taking the mask as it is, beside the adaptor's member set forms, which a mask cannot reach and so need no redeclaring.
contains(k) is std::set's membership of one flag. A value of several bits, or of none, is no key, so it is no
element either: contains answers false, count zero, find end() and erase zero, and over an enumeration the
bounds place it by the mask's order. Writing one stays bit_flag_mapping's precondition, there being no single position to write. All-of
is xstd::ranges::bit_includes(p, m), any-of xstd::ranges::bit_intersects(p, m) and none-of xstd::ranges::bit_disjoint(p, m): each takes its second argument
as std::type_identity_t of the first's type, outside deduction, so a mask converts there and the flag type declares
nothing of its own for them (algorithms-not-members). A mask never stands first, so
m ⊇ p is xstd::ranges::bit_includes(xfs::perms(m), p), the conversion written out. There is no operator[] and no
proxy for a bool: a set changes one flag through insert(k) and erase(k), as std::set does. There is no
nullary count() either: size() answers it.
A user may still derive a class of their own from bit_flag_set to add names, but its operators then return
the fixed set, as a class derived from any standard container's would.
Where it is not drop-in:
- The variable's type changes.
fs::perms pbecomesxfs::perms p; the constants, the functions and the rest offs::are untouched. - It is a class, not an enumeration.
std::to_underlying(p)andstatic_cast<unsigned>(p)do not compile;switch (p)andfs::perms(p)do, through the conversion. - An integer mask meets only its own type. The mixed operators deduce their mask parameter exactly, so an
operand of another type, an
intliteral against astd::uint8_tmask or MSVC's enumerator constants against itsintfmtflags, takes the built-in operator through the conversion, as the integer code it replaces did:p | 1is anint, andp - 1is a subtraction. The compound forms take any value that converts toMask, sop -= ios::basefieldis the set's on every implementation. A shift by anint,p << 1, is ambiguous between the set's shift and the integer's;p << 1UZis the set's. - A standard function's result keeps the standard type.
auto q = fs::status(path).permissions();is astd::filesystem::perms, with none of the queries until it is assigned toxfs::perms. - Printing needs a
formatterfor the mask, which a user may not specialize forstd::filesystem::perms; a program-defined mask prints through its own,{exec, read}.
a-name-by-storage¶
A bit_sequence<C> naming the sequence owner by the storage its blocks sit in -- bit_sequence<std::uint64_t>
for basic_bit_array<std::uint64_t, 64> -- is how itsy_bitsy's bit_sequence<Container> spells a sequence. As an
alias over a trait nothing deduces through it (C sits in a nested-name-specifier, a non-deduced context by
[temp.deduct.type]/5.1), and it gives the storage spelling to one reading of two. No public name here
spells a reading by its storage: every owner is named by its block and width, as basic_bit_array<std::uint64_t, 64>
is, and the storage is its base clause's business (the-container-adaptor).
the-generated-table¶
Six cells over two adaptors over three storages is the shape where an inconsistency hides in one cell and
nowhere else, so what the compiler generates is a table, held by test/src/bits/generated.cpp rather than by
whichever cell was read last.
Every cell answers the same to all but one column: default-constructible, copyable, movable, ==, <=>,
swap as both a member and a free function, and nothing-throwing in both move directions -- a move that
could throw would cost every growing container its strong guarantee.
The allocator is the exception, and it follows the column, not the row: the dynamic column allocates and both
of its readings answer get_allocator; the static column is a std::array and has none to show; the
inplace column holds its blocks inline and has none either. boost::container::static_vector does name an
allocator_type, one that holds the elements rather than allocating them, so a storage's allocator counts as one
only where it has allocate. So bit_set and bit_vector have
it and the other four do not, which is a fact about std::vector rather than about sets or sequences.
That is the table's use: a cell that answers differently from its column, such as a set owner without
get_allocator over storage that allocates, is an absence nobody decided, and reading across finds it.
swap is not merely std::swappable, which the implicit moves would satisfy on their own. It is the storage's
own exchange through std::ranges::swap, so the test checks that values actually move rather than only that
the expression compiles.
the-inplace-column¶
The third storage point gets public names, one per reading and each a class like every other
owner: basic_bit_inplace_set<Key, Block, N> and basic_bit_inplace_vector<Block, N> over
inplace_blocks<Block, num_blocks_v<Block, N>>, with bit_inplace_set<N> and bit_inplace_vector<N> at the
machine word. inplace is one qualifier down each column rather than a second vocabulary for the same thing.
N is a capacity in bits here, where the static column's N is a width, and it is exact in the same way:
basic_bit_inplace_vector<std::uint8_t, 9> holds nine bits, as std::inplace_vector<bool, 9> does, in two
blocks whose last seven bits it never uses. bit_block_container reads its second parameter by storage -- the
width of fixed blocks, the capacity of blocks that resize under a constant one, and dynamic_extent for blocks that
grow without bound -- and defaults it to that, so a container named by its storage alone holds every bit of it:
bit_block_container<inplace_blocks<std::uint8_t, 2>> is the storage basic_bit_inplace_vector<std::uint8_t, 16>
wraps, one storage however it is spelled. The constant capacity is read without an object: from a capacity()
usable in a constant expression, as std::inplace_vector's is, or from the static_capacity beside a static
capacity() callable only at run time, as boost::container::static_vector's is. boost::container::small_vector
has a static_capacity too, but it is the inline part of a capacity that grows onto the heap, and its capacity()
needs an object, so an owner over it is unbounded by type.
The class passes N through to the storage rather than leaving it in the block count alone, which is what makes
distinct capacities distinct types and lets its guide name N: num_blocks_v<Block, N> is not a
deducible context, and no inverse exists, since every N from 9 to 16 names two std::uint8_t blocks. The
names carry the capacity-versus-width distinction and the parameter lists do not, which is the same hazard
basic_bit_fixed_set<Key, Block, N> and basic_bit_inplace_set<Key, Block, N> share by shape.
The column exists on every standard library, at C++23 and at C++26 alike. detail/inplace_blocks.hpp names its
storage inplace_blocks<Block, K>: std::inplace_vector<Block, K> where __cpp_lib_inplace_vector is defined, in
practice libstdc++ >= 16 at C++26, and boost::container::static_vector<Block, K> everywhere else -- MSVC,
libc++, and every library at C++23. Boost.Container is already a dependency, so the fallback costs a consumer
nothing new. Over std::inplace_vector the owners are constant-evaluable, and the header says so by defining
XSTD_BITS_HAS_CONSTEXPR_INPLACE to 1; static_vector's constructors are not constexpr, so over it the owners
are run-time values only. Code that constant-evaluates an inplace owner tests #ifdef XSTD_BITS_HAS_CONSTEXPR_INPLACE,
never __cpp_lib_inplace_vector, which says what the standard library has rather than what the owners are built
on. Growth past the capacity throws std::bad_alloc over either storage, given by the container before the
storage is asked; on the set reading that is where insert stops being total. A left shift is not refused: it
translates every key by n and keeps those below N, the width growing to min(width + n, N) first, which the
blocks always have room for, so operator<<= is noexcept over this column. The width is no part of a set's
value, so a set whose width outran its keys -- an insert(20) undone by erase(20) -- shifts exactly as one at a
narrow width does, and a shift that keeps no key empties the set at the width it has. insert past N still
throws: it names one key the set cannot hold, where a shift is a set-wide operation with a rule for what it drops,
as std::bitset's is. The unbounded column follows the same rule with max_size() for N
(width-is-capacity).
The two storages promise different things, and the owners pass the copies' promise on rather than paper over it.
std::inplace_vector of trivially copyable blocks copies and swaps without throwing; boost::container::static_vector
declares its copy constructor, its copy assignment and its swap potentially throwing, so over it the inplace
owners' copies are not noexcept either. Their swap, member and free, is noexcept over both, as
[inplace.vector.modifiers] declares it for bool. bit_block_container still calls the storage's own swap through
std::ranges::swap, and swaps_without_throwing_v names the one storage whose swap cannot throw without saying so:
static_vector, which moves unsigned blocks between two inline buffers of one capacity and never allocates. Every
other storage answers with its own specification. The moves are noexcept over both. Neither makes an owner
trivially copyable, std::inplace_vector included: a run-time width's move leaves its source at width zero, which a
trivial move could not. generated.cpp asserts each storage's answer, that the owner copies as its blocks do, and
that it swaps without throwing over either.
A capacity of nought holds nothing. [inplace.vector.overview]/5 makes inplace_vector<T, 0> empty, trivially
copyable and trivially default constructible, and static_vector<Block, 0> is none of these: it keeps a size.
The inplace vector therefore holds inplace_blocks_for<Block, num_blocks_v<Block, N>>, which is inplace_blocks
itself except at nought where the library's own type is not empty, and no_blocks<Block> there. inplace_blocks stays a
plain alias, so the owners' deduction guides deduce its capacity; a conditional_t would make that a non-deduced
context. no_blocks is an empty contiguous range whose growth past nought throws std::bad_alloc, as
std::inplace_vector<Block, 0>'s does, and it goes with the fallback once every leg ships <inplace_vector>. A
std::array<Block, 0> would not do: its layout is the implementation's (one byte in libstdc++, a whole Block in
libc++, and an empty type in neither), and it has no growth members, which would turn the inplace owner into a fixed
one. Over a capacity of nought bit_block_container stores no width (has_zero_capacity), defaults its moves, and
takes the bit_members whose two members overlap and have no initializer; every adaptor holds the container
[[no_unique_address]] in its adapted_bits base, so basic_bit_inplace_vector<Block, 0> is an empty type, except
under the MSVC ABI, which gives a class whose members are all empty a byte of its own. There the vector's range constructor and both append tiers
refuse any element up front through one refuse_any, and the ordering never compares unequal widths. A loop that
cannot go round a second time is a branch no test can take, and being trivially destructible the owner compiles to
control flow no other capacity shares, so gcov counts that branch on its own; it is also code MSVC's C4702 calls
unreachable. The inplace set, bound by no such paragraph, holds inplace_blocks<Block, 0> at N == 0
rather than no_blocks, and reaches the same capacity of nought: no width is stored, and every growth past nought
throws std::bad_alloc. Over no_blocks the set would carry paths of its own that a width of nought never takes,
its padded equality and its bulk operators across two widths, each a branch no test can reach. The set's left shift takes an arm there, for the same reason the vector's ordering does: with no element
to hold, the rest of the body is a branch no test can take.
P0843 declined to repeat vector<bool>, so std::inplace_vector<bool, N> holds real bools and is a model
only up to its reference. The [inplace.vector] clauses assert each declaration on it first where the standard
library has it, exactly as the [vector] ones do (the-sequence-contract); what the
packing adds, hash, is under test/src/spec/xstd/inplace_vector/.
Testing both storages takes two standards. __cpp_lib_inplace_vector is a C++26 macro, and the library asks
for C++23, where every library in the matrix holds the column in boost::container::static_vector. The two
owners' suites run the same cases at either standard; what the standard changes is the storage underneath, whether
XSTD_CONSTEXPR_INPLACE_CHECK_EQUAL adds a static_assert to its run-time check, and whether the lines that hold
the owners to std::inplace_vector itself, the model, are compiled at all.
XSTD_BITS_CXX_STANDARD is how a build asks for more -- 23 by default, 26 to reach the std::inplace_vector
storage. It raises the standard for the tests and benchmarks only, deliberately: the INTERFACE cxx_std_23 a
consumer inherits is the library's real requirement and must not move because one storage wants more. Verified at
GCC 16 with -std=gnu++26, and over boost::container::static_vector at C++23, where the column behaves as the table says -- regular, swappable by
member and free function, no allocator, and the two readings differing exactly where they should, the sequence
starting at width zero and resizing while the set's width is its capacity (width-is-capacity).
What is still missing is CI. CMake 3.28 cannot spell C++26 for GCC or Clang at all -- not a GCC 16 gap, a
CMake one -- and 3.28 is this project's declared minimum, so the option fails on the toolchain the matrix
currently runs. It fails legibly: the configure step asks CMAKE_CXX_COMPILE_FEATURES what CMake actually
knows and says so, rather than letting a try_compile blame the compiler for CMake's ignorance. Reaching the
std::inplace_vector storage in CI needs a newer CMake on one leg, which is a change to the shared workflow rather than to this
repository.
max_size() is 24 on both names over <24, std::uint8_t>, this column being where the readings would part on
it without the rule they share (max-size-is-the-bits).
capacity() is the sequence reading's; the set has none, std::set having no capacity() and a
set growing by insert (growth), so there a capacity is felt at the throw and reported by
max_size().
The column is graded over every block the other two are, xstd::uint128 included, which it can be because the
width member carries its own alignment (padding). A 16-byte-aligned block after a std::size_t width
would pad the class, and -Wpadded under -Werror rejects that; the inplace column is the only cell that can
reach it, a static width carrying no width member at all and a std::vector over a stateless allocator being
aligned as a pointer whatever it holds.
max-size-is-the-bits¶
[container.reqmts]/57 asks for distance(begin(), end()) for the largest possible container, and under every
reading of bits that counts the same thing: the positions there are to hold. The set reading iterates the
positions it holds, so its largest is every position set; the sequence reading iterates one bool per position.
There is no separate key domain -- a set over [0, W) holds at most W
elements because there are W positions, which is the same W.
What the two do not share is where that count stops, because their counterparts stop in different places and each reading owes its own:
| reading | counterpart | where the count stops |
|---|---|---|
| set | [set], which names no such bound |
whole blocks the blocks hold and a size_t counts |
| sequence | std::vector<bool> |
that, clamped to whole blocks no wider than PTRDIFF_MAX |
So max_size() is not one function with two callers. The storage computes both -- max_size() and
addressable_max_size() -- because both are made of bits_per_block and the block
container, which is knowledge only the storage has, and each reading returns the one its counterpart names. That
is what bit_block_container is for: the primitives are its, the contracts are the readings'.
The difference is observable, and is not small:
| over a 64-bit block | ours | the counterpart |
|---|---|---|
bit_vector::max_size() |
(PTRDIFF_MAX / 64) * 64 |
libstdc++'s std::vector<bool>: the same |
bit_set::max_size() |
SIZE_MAX - 63 |
-- |
The first row is the one the standard leaves open, and it is the one place a counterpart is a specification
rather than an implementation. [container.reqmts] only requires max_size() to bound what resize accepts, and
the two major implementations already disagree by sixty-three over a 64-bit word: libstdc++ answers
(PTRDIFF_MAX / 64) * 64, libc++ answers a bare PTRDIFF_MAX. There is no single number to match. Ours is the
rounded one on both, so bit_vector answers the same wherever it is built, which std::vector<bool> does not.
That is a number and not a behaviour. Measured at every
boundary of the sixty-three-size window, on both libraries -- n0 = (PTRDIFF_MAX / 64) * 64, then n0 + 1,
n0 + 32, PTRDIFF_MAX:
resize(n) |
libstdc++ vector<bool> |
libc++ vector<bool> |
bit_vector, either |
|---|---|---|---|
n0 |
bad_alloc |
bad_alloc |
bad_alloc |
n0 + 1, n0 + 32, PTRDIFF_MAX |
length_error |
length_error |
length_error |
Nothing diverges. And libc++'s number is one libc++ cannot honour: it reports PTRDIFF_MAX and then throws
length_error on resize(PTRDIFF_MAX), its own max_size(), where [container.reqmts] makes that the bound
resize accepts. Ours is the bound: resize(max_size()) reaches the allocator. Matching libc++'s number would
create the divergence that is not there now -- measured with the ceiling removed, resize(PTRDIFF_MAX) is
bad_alloc where libc++ answers length_error.
So the test asserts the table row for row, asking both containers rather than claiming a value of one, and asserting of the counterpart only what every implementation of it promises:
| row | asserted of bit_vector |
asserted of std::vector<bool> |
|---|---|---|
max_size() <= PTRDIFF_MAX, and ours <= theirs |
yes | yes |
max_size() % 64 == 0 |
yes | no -- libc++'s is not |
resize(max_size() + 1) -> length_error |
yes | yes, against its own |
resize(PTRDIFF_MAX + 1) -> length_error |
yes | yes |
resize(max_size()) -> bad_alloc |
yes, off the sanitizer legs | no -- the two libraries disagree |
The "ours <= theirs" row is the one that orders them, and it holds of any block width rather than of a measured
pair: a bound rounded down to whole 64-bit blocks is the smallest such bound any block width can produce, and
bit_vector is the name whose block is a size_t, so ours is never the larger claim.
The last row asks for the memory instead of refusing, so the allocator answers -- and under AddressSanitizer that
answer is an abort, not an exception. Measured: allocation-size-too-big, and allocator_may_return_null=1
only renames it to out-of-memory, because the throwing operator new calls ReportOutOfMemory on a null
return rather than throwing. So that row is guarded on the sanitizer and on nothing else, which is as narrow as
the evidence allows: of the nine CI failures that led here, every one was a sanitized build or a discarded
temporary whose new/delete pair clang elided at -O2, and the msvc and mingw legs -- neither of those --
never failed on it at all. The rows use a live object for the same reason: an elidable temporary is what broke
clang, not the allocation.
The source of the answer still differs by what can grow:
max_size() |
|
|---|---|
| a width in the type | the storage's extent |
| an owner over growing storage | the storage's answer for that reading, in bits |
| the same, under a mapping that closes the universe | the smaller of that answer and the mapping's size |
| a view, a window, a static owner | its own width, which it cannot grow |
Nothing above the storage restates the arithmetic, and nothing above it should: a constant at the adaptor drifts
from the storage the moment the storage learns something: a set adaptor answering SIZE_MAX - 1 over blocks
whose storage answers SIZE_MAX - 63.
The set's is static exactly where the type fixes it, at a static width or under a static capacity, and is then
the storage's extent or static_capacity() as a constant (constant-sizes). Elsewhere an owner
must ask its storage and a view must ask what it views, neither of which a static member can reach.
std::set::max_size() is not static at all.
the-sum-that-wraps¶
A ceiling only holds if the number reaching it is the number that was meant. Every growth here computes the
width it asks for by addition over a size_t the caller names, and every one of those additions wraps:
| where | the sum |
|---|---|
bit_block_container::growing_insert |
n + 1, to admit the position |
set_adaptor::operator<<= at a run-time width |
width + n, the translation being total over size_t; capped at max_size() |
set_adaptor::insert_range, consecutive tier |
lo + len - 1, the range's last position |
sequence_adaptor::insert(position, n, value) |
size() + n |
sequence_adaptor::blit |
size() + count, the source's own width |
sequence_adaptor::pack |
size() + ranges::size(rg), made room for ahead of the packing loop |
Not every wrapping sum is a growth. exclusive_find_next(n) steps to n + 1 to scan from the position after
n, and at the top of size_t that step is zero, so a scan from there would restart at the beginning and answer
the first set position. Its callers refuse that sum before they step: the set reading's upper_bound asks for a
position within the width first.
A wrapped sum is small. It passes the ceiling it was meant to fail, and the operation then proceeds against
a width far below where it writes. A block count spelled align_up(n, bits_per_block) / bits_per_block makes that
concrete: it rounds a width near the top of size_t to zero, a one-block floor turns that into one block, and
the container answers size() with SIZE_MAX over sixty-four bits. bit_set().insert(SIZE_MAX - 1) reaches
exactly that, and not only in release -- an assert(n < SIZE_MAX) in growing_insert refuses one position of the
two that get there, n + 1 being a width nothing can hold for every n above max_size() -- and under
-fsanitize=address the write that follows is a segfault at a high address.
Two rules, and the second is what keeps the first from being written six times:
The block count cannot wrap, and that is a spelling rather than a guard. blocks_for says what
boost::dynamic_bitset::calc_num_blocks says -- divide, then round up by the remainder -- which is total over
every size_t. The rounding spelling, align_up(n, bits_per_block) / bits_per_block, adds first, and for the
sixty-three widths above max_width that sum wraps to zero blocks which a floor then turns into one: a container
claiming SIZE_MAX positions in sixty-four bits. Only a ceiling would stand between that spelling and its own
arithmetic. This one has nothing to stand between.
blocks_for is public for that reason, and the claim is a static_assert:
blocks_for(SIZE_MAX) == max_num_blocks + 1, one block more than the widest whole number of them, where the
rounding spelling answers one. Asserting it by growing to such a width instead asks std::allocator for 2^61
bytes, and two of this tree's CI legs will not answer that question -- a sanitized build aborts on a request
that size (allocation-size-too-big) rather than reporting bad_alloc, and clang at -O2 elides the
new/delete pair of a discarded temporary, so (void)V(SIZE_MAX) never allocates and never throws. Both were
measured on this tree, on exactly that assertion, across nine CI jobs. The compile-time form is the better one
anyway: it names the count the rounding spelling gets wrong rather than inferring it from an exception, and it holds
on every leg.
The refusal still wants looking at, and std::inplace_vector is where it can be: it is the one block
container here that refuses a width without asking anyone for memory, so the refusal arrives at a size a test can
name. bad_alloc and not length_error is what comes back from the storage, which asks no ceiling at a door of
its own.
The ceiling is therefore a policy, and it belongs to the reading, because the counterparts disagree about it.
Two ceilings are computed in the storage and two refusals are spelled there -- check_width above max_width,
check_addressable_width above max_addressable_width, both std::length_error -- and the storage asks neither
at any door of its own. The set reading asks check_width in guard_key: a key past the widest it could
ever grow to is the one thing insert on a dynamic extent can refuse. Its operator<<= asks neither, capping
width + n at max_size() and dropping the keys that land past it. The sequence reading
asks check_addressable_width -- at its width constructors, resize, reserve, and each of the three sums it
computes -- because std::vector<bool> throws length_error for a size it cannot represent, and what it cannot
represent is a distance, not a size_t.
Three growths name no width of their own -- clear() resizes to zero, pop_back() to one less, and
grow_to_admit to another storage's own width -- and each is reached from something that promises not to throw
(the sequence reading declares clear() and pop_back() noexcept; grow_to_admit is how every set
operation across widths widens, and the test tree's composable checks are noexcept over |, &, - and
^). They go through resize_to, the growth behind the door, which asks no ceiling, so what they are exposed to
is only bad_alloc, which no noexcept here promises against anyway.
The mirror of that rule is that a growth which does name a width keeps the ceiling, and whatever promises not
to throw above it is what has to give. growing_insert is the case: a key past max_size() is length_error,
which is the one way insert on a dynamic extent can refuse (asking-is-total). The four
composable checks in the test tree over |, &, - and ^ each build their expected value with ranges::to,
which inserts, so they carry no noexcept, which would be a promise about a reachable exception, and the test
factory carries none over resize. The inclusion check beside them, xstd::ranges::bit_includes asked both ways round,
constructs nothing and is noexcept. The narrower ceiling stays the blocks' own: m_blocks.resize and
m_blocks.reserve are handed a count and answer for it, which is why resize(max_width) is bad_alloc and
resize(max_width + 1) is length_error. resize takes that count before it writes the last block, so a
refused growth leaves the width and the bits exactly as they were.
That holds of resize(n, true) only because of the order of its steps, which the inplace_vector test above
checks. Growing with ones sets the tail above size() in the current last block, those bits being the first new
ones; set before the blocks grow, a refused growth would return with the width unchanged and the tail dirty, which
is the class invariant broken rather than a partial growth: measured on a storage refused at 25, the next
resize(20) comes back with every bit above 9 set and count() at 8 instead of 1. Which block and which bits is
read off the old width, so it is taken before the growth; the write itself comes after, where nothing can
throw between it and m_size.
The addition is width_sum. base + count where that is a width, and the top of size_t where it is not.
Saturating rather than throwing keeps the rule at one throw site: a saturated width is one blocks_for already
refuses, so the readings above hand it their sum and inherit the diagnostic without naming a ceiling of their
own -- which is the same reason max_size() is not restated above the storage.
Not every sum here was worth a check. append(block_type) raises the width by bits_per_block and capacity()
multiplies the blocks' by it; both overflow only once the blocks already hold some 2^64 bits, which the
allocation fails long before, so a branch there is one no test can reach and the coverage gate would be right to
say so.
sequence_adaptor::pack makes room ahead and is not left alone, because of what its count is bounded by.
pack is handed a range of bool, and a sized range answers size() for elements it never materializes: d.append_range(views::iota(0UZ, SIZE_MAX))
is one call, and size() + SIZE_MAX wraps for every d that is not empty. Wrapped, the room asked for is
none, and the packing loop then walks a range of 2^64 elements a block at a time -- so the
length_error the same call answers immediately on an empty sequence becomes, one element in, a call that
ends when the allocator gives out rather than when the range does. Saturated it is that length_error at
both.
Where the counterparts stand, measured rather than assumed -- Boost 1.83, libstdc++ 14, -O2 -DNDEBUG
-fsanitize=address,undefined:
growth at SIZE_MAX |
a ranged write past the width | |
|---|---|---|
boost::dynamic_bitset |
bad_alloc |
assert in debug, heap-buffer-overflow under NDEBUG |
std::vector<bool> |
length_error from resize, reserve and insert; the fill constructor corrupts the heap |
no ranged form |
std::bitset |
no growth | no ranged form; the single-position members throw out_of_range |
The split is exact, and it says which of the two rules above a packed container can get wrong.
The ceiling is, and the answer is not to need one.
boost::dynamic_bitset::calc_num_blocks is n / bits_per_block + (n % bits_per_block != 0) -- a division that
cannot overflow -- which is why dynamic_bitset(SIZE_MAX) reaches the allocator and answers bad_alloc, and
blocks_for says it the same way. The rounding spelling, align_up(n, bits_per_block) / bits_per_block, is
exactly libstdc++'s _S_nword(n) = (n + word_bit - 1) / word_bit, which its vector<bool> fill constructor calls
with no max_size() check: std::vector<bool> v(SIZE_MAX) there constructs, answers size() with SIZE_MAX over
a capacity() of zero, and aborts on the first write. resize, reserve and insert all check and throw
length_error; only the constructor does not. So the hazard has vector<bool>'s shape, not boost's, and a
constructor is the unchecked way in.
the-invariant-stays-in-the-storage¶
The padding invariant could live one level up, each adaptor restoring it after the operations that dirty it, and it does not. Two reasons, one measured and one structural.
Measured, the prize is one masked store. operator<<= at a run-time width erases where the set reading
has already grown past anything the shift can reach (the-set-operations-across-widths)
unless the growth stopped at max_size(), so the call is dead in that path short of it. Removing it, GCC 14, -O3 -march=native, best of twenty-five:
| width | with | without |
|---|---|---|
| 64 | 4.18 ns | 3.44 ns |
| 256 | 7.75 ns | 7.67 ns |
| 4096 | 22.50 ns | 22.45 ns |
| 65536 | 311.08 ns | 311.72 ns |
0.74 ns at one block and nothing beyond it, because the erase touches one block where the shift touches all of them.
Structural, and the reason that decides it: the invariant is not a reading's operation, so the ceiling of
what-a-sequence-may-add does not reach it. It exists to serve the storage's own
blockwise reads -- operator==, count, all, any, none and first_difference are every one of them
storage members that read a whole block and would have to mask without it, xstd::ranges::bit_includes and
xstd::ranges::bit_intersects read whole blocks the same way, and set_equal and the orderings are built on them. Moving the restoration up would put the obligation in the adaptors and the
dependency in one storage, and a missed call would be silent. Three dirtying operations across two adaptors
is six places to be right instead of four, to save a masked store on a one-block set.
That is also what the three standard libraries disagree about, and the disagreement is a real choice rather
than an oversight: a tail invariant is needed only where blockwise reads are unmasked. libstdc++'s
vector<bool> reads element-wise and keeps no invariant, so flip() dirties the whole allocation and nothing
notices. libc++'s reads blocks and masks at every read site. This tree reads blocks unmasked and masks at the
four writes that can dirty them.
the-kept-count¶
A set's size() is its storage's count(). At a static width, bit_fixed_set<N> and bit_inplace_set<N>, that is
a popcount bounded by N: one or two instructions at a board's width, constant in the number of elements as
[container.reqmts] measures it, and a kept count would charge every write a read and an add to save them. Where
the width grows without bound, bit_set and bit_small_set, the popcount runs over every block the set has ever
grown to, so a megabit set holding one key pays thousands of popcounts per size(), and per empty() with it.
Those two keep the count as a member: set_bits_t<Blocks> is bit_block_container<Blocks, N, true> exactly where
unbounded_width<Blocks, N> holds. bit_vector holds the same blocks without it, its size() being its width.
Only the count is kept, not where the keys are. begin() and every step of an iterator scan a block at a time for
the next key, so a step costs one plus the empty blocks it skips, and those skips add up over a whole traversal to the
blocks once: a loop from begin() to end() costs O(width / w + size()), the scan boost::dynamic_bitset's
find_next and Java's BitSet.nextSetBit make too. At a static width that is bounded by N, and so constant as the
standard counts. At a growing width it is the departure: [iterator.requirements.general] asks for amortized
constant steps, which a dense set meets and a sparse one does not, and the width scanned is the largest the set has
grown to, since neither erase nor clear() shrinks it (width-is-capacity). No cheap
bookkeeping closes the gap. A kept first block would make begin() constant for a word and a branch in every
writer, and leave every ++ scanning as before; only tracking every key, which is the representation this library
exists to avoid, would make the steps constant. size() and empty() are different: a loop condition calls them
once per iteration, and keeping them costs an add per write.
The count is the storage's for the reason the padding invariant is
(the-invariant-stays-in-the-storage): a bit_set_view over a bit_set
writes through the owner's storage and never through the owner, so a count the owner held would go stale at the
first insert through a view. set_view_storage_t maps a view over unbounded blocks onto the counted storage its
owner holds. Every write keeps the count:
- a single bit by the bit it read, which
insertanderasealready return; - a range,
set(n, len, value), by the range's popcount taken before the write; - the shifts by the positions they drop, and
resizeby the positions that leave and arrive, applied once the growth has succeeded, so a refused allocation leaves the count with the blocks it describes; &=,|=,^=and-=by a popcount summed in the loop that writes the blocks, so a set operation stays one pass, andtransformthe same way, less whaterase_unusedis about to clear, which only the last block holds;set(),reset()andflip()by arithmetic on the width, and a rotation or a reversal not at all, a permutation of the bits leaving their number as it was.
A writer of raw blocks, through blocks() or the subscript, owes recount() exactly as it owes erase_unused();
bit_convert into a run-time owner is the one such writer in the tree. The count is eager. A flag set by the bulk
writes and cleared by a recount in size() would make a const member write, which [res.on.data.races] forbids
without synchronization, as reference-counted copy-on-write strings found. In a debug build count() asserts the
kept count against the popcount, so every test that asks a size also checks the invariant.
width-is-capacity¶
std::set has no width, so bit_set treats its run-time width as capacity, never as value: two sets holding
the same positions are equal whatever their storages' widths, and the width neither orders, nor hashes, nor is a
precondition of the set operations.
operator~ is the one operation that cannot honour this, so it is not offered at a run-time width.
Complementing needs a universe, and capacity is not one. Two bit_sets holding {1, 3} are equal whatever
their storages grew to, but complementing reads the width, so the results are not:
a == b : true a, b both { 1 3 }
~a size : 2 a's storage never grew
~b size : 199 b's had admitted 200 once
~a == ~b : false
~ is then not a function of the set's value, which is a stronger objection than an inconvenience: equal
inputs, unequal outputs. At a static width there is no such gap, because N is part of the type and
equal sets share it, so operator~ is constrained on has_static_width and bit_fixed_set<N> keeps it.
bit_set keeps toggling one key, which needs no universe -- an enumerator's ^=, or contains and then erase or
insert -- along with the four set operators. The alternative -- letting ~ read the width --
would make the width value for exactly one operation, which is the thing this section says the set reading
does not do.
bit_block_container's operator== cannot say that: it is width first, which is what the sequence reading
means, as std::vector<bool> and boost::dynamic_bitset do. The width is part of the value there -- a
vector<bool> of two elements is not one of three -- and it is not part of the value for a set. So the set
reading gets an entry of its own, set_equal, beside the operator== the sequence reading keeps, for the same
reason set_three_way sits beside sequence_three_way.
Two spellings for two meanings, and it does not come out as evenly as the orderings. Ordering has two
meanings and no structural answer at all -- a defaulted <=> would order by m_size first, which no reading
means -- so the storage declares none and names both, and nothing is left over. Equality has two meanings
and one of them is the structural answer: memberwise, width then blocks, exactly what = default produces
and exactly what std::regular asks of a storage. the common vocabulary requires that
(the-common-vocabulary), and it is not an accident of the concept: std::bitset and
boost::dynamic_bitset both have == and both mean width first by it. So the count is one operator and one
name rather than two names, and giving the structural meaning a second name would be three spellings for two
meanings.
Both are non-members: the defaulted operator== is the storage's hidden friend, and set_equal a free
function beside the orderings, equality asking about two values with neither as its subject exactly as an
ordering does. What remains asymmetric is only that one of the two meanings gets to keep the operator.
At a static width the distinction is unobservable, every instance carrying the one width, which is why the set
adaptor can default == there and nowhere else.
The storage answers at any two widths, and the adaptor calls it or the comparisons built on it. set_equal,
set_three_way, xstd::ranges::bit_includes and xstd::ranges::bit_intersects each carry their own width-crossing arm, so the read operations
in set_adaptor, and xstd::ranges::bit_includes, xstd::ranges::bit_intersects and xstd::ranges::bit_disjoint over it, are calls with no same_width test
between them -- only the four compound
operators still ask, because they mutate. The logic belongs where the blocks and the invariant are, and putting
it there is also what keeps the adaptors alike, where sequence_adaptor had nothing at all. At two run-time widths that differ, ==,
bit_includes and bit_intersects all ask whole blocks rather than walking positions. Only the blocks both
storages have can disagree; above them the answer is the invariant, capacity holding no element. So == wants
the shared blocks equal and the longer one's remainder clear, bit_includes(a, b) wants each of b's shared blocks
inside a's and nothing of b's above a's last block, and bit_intersects is the negation of every
shared pair being disjoint -- their blocks above ours never need a look. Positions were the obvious spelling and
the wrong one, a walk over the elements being a find_next per position where this is one load per sixty-four.
Measured over two thousand elements held at two different run-time widths, each in the case that denies the
element walk its early exit, with the inputs made opaque to the optimizer so the call is not hoisted out of the
timing loop:
| positions | blocks | |
|---|---|---|
==, equal |
11.04us | 0.07us |
bit_includes, a subset |
9.39us | 0.05us |
bit_intersects, disjoint |
10.35us | 0.05us |
The padding above size() being zero is what lets a whole block stand in for the positions it holds, which is
the same invariant the orderings already rest on. The shared prefix needs no index arithmetic: std::views::zip stops at the
shorter range, which is exactly the blocks both storages have, and the blocks past it are a separate question
asked of one storage alone. Only the ordering needs a block one storage may not have, and padded_block reads
those as zero -- not a convention but the same invariant one block further out.
The same gate is why each of the three arms sits under if constexpr (not has_static_width) rather than the
plain if that same_width would fold anyway. Folding is not enough: a static width still instantiates the
arm, and blocks_agree carries a lambda no other call site shares, so those instantiations are reachable from
no test and their branches are uncovered by construction -- the same hazard the coverage job's own
XSTD_BITS_BUILD_BENCHMARKS=OFF exists to avoid. A translation unit naming only bit_fixed_set instantiates
thirty such functions with a plain if and none with the guard.
<=> is blockwise for the same reason and by a different route. The set ordering is lexicographic over the
ascending positions, and lexicographic order over two sets is decided by exactly one position: the lowest at
which they disagree. Whoever lacks it is less -- but for two different reasons, and the second is the one worth
naming. Usually it holds a larger element there. When it holds nothing above that position at all, its positions
are a proper prefix of the other's, and it is less because it runs out rather than because it compares
smaller. So the comparison is a search for the lowest differing block and a single look above it:
padded_first_difference, then padded_any_above on whichever side lacks the position. Those are the
storage's own first_difference and any_above with the index bound dropped, and set_three_way dispatches to
them through padded_set_three_way when the widths differ, so the generalisation lives beside the algorithm it
generalises rather than in the adaptor calling it. Measured as above, at two
different run-time widths: 10.62us to 0.09us over equal sets, and 11.03us to 0.06us where one set is a proper
prefix of the other.
|= &= ^= -= are blockwise too, and
the-set-operations-across-widths is what it took to mutate rather than
merely read. The shifts translate the set: <<= grows the width towards max_size() and keeps the keys that
land below it, and >>= empties past the width. Hashing appends the positions and the count at a run-time width and the bits at a static one, where equal
sets share a width (the-hashing-invariant).
A run-time width is storage to a set. boost::dynamic_bitset's width is part of the value, so ==
includes it, and the width changes only when the caller asks; xstd::bit_set
and xstd::bit_inplace_set are the set reading, and their width is storage: operations grow it as they need --
insert, |, and <<= up to max_size() -- invisibly, since growth changes nothing observable. Hence the
shift's rule, grow and then truncate at max_size(): that is the one bound fixed by the type rather than by how
the set happened to be stored, and truncating at the current width would give two equal sets two different
results. insert past max_size() still throws, since it names one key the set cannot hold, where a shift is a
set-wide operation with a rule for what it drops, as std::bitset's is. At a static width the width and
max_size() are both N, so bit_fixed_set<N> shifts as std::bitset<N> does. A mapping that closes the
universe caps a growing set's max_size() at its size, so the shift keeps no position that names no key.
| who sets the width | operations change it | part of the value | a left shift truncates at | |
|---|---|---|---|---|
boost::dynamic_bitset |
the caller alone | no | yes | size() |
bit_set, bit_inplace_set |
the operations, as they need | yes, growing | no | max_size() |
std::bitset<N>, bit_fixed_set<N> |
the type | no | the type's | N, which is both |
the-set-operations-across-widths¶
The four compound operators work a block at a time across two widths as the read-only operations do, with one question those never have to answer: they mutate, and two of them may have to grow.
Measured at two run-time widths, 4096 against 4000, dense, element by element and a block at a time:
| elements | blocks | |
|---|---|---|
&= |
12.12us | 0.22us |
\|= |
9.41us | 0.22us |
^= |
9.58us | 0.22us |
-= |
10.27us | 0.22us |
They keep the operator spelling, and that is the point worth stating, because set_equal and
set_three_way do not. A name is owed where the readings genuinely disagree: two orderings over one
storage, and two equalities (two-readings-disagree). &= |= ^= -= are not
that. Both readings mean the same bitwise thing by them, and a precondition of equal widths on the storage's
operators would be the only difference, where the set reading wants an answer. A precondition is not a second
meaning: an operator that answers where it could assert takes nothing away from the readings that never ask,
since what they pass is always equal-width. So the operators themselves are total, under no new names, and no
same_width test chooses between the storage's operator and an element walk.
What the storage's operator deliberately does not do is grow. Growth is the set reading's rule about
capacity, so it lives with the reading that has it: set_adaptor's |= and ^= call grow_to_admit and then
the operator, and its &= and -= are the operator alone. That is the adaptor adding a guard, which is all an
adaptor should be doing; the operator stays reading-neutral, padding with the zero the invariant already keeps
and never widening what it was handed.
Intersection and difference never widen. A position the other lacks is a position it does not hold, so the
missing blocks read as the zero they already are and the result fits where it already sat. Both only ever
clear bits, so the invariant that padding above size() is clear survives with no erase_unused to restore
it.
Union and symmetric difference do widen, and the target is not the obvious one. This is the part that is
the set reading's alone, and the reason grow_to_admit sits at the call rather than inside the operator.
Growing to the other operand's size() would be wrong. growing_insert(n) resizes to n + 1, so inserting the other's elements one
at a time arrives at one past its largest element -- and a storage far wider than anything it holds must not
drag this one up with it. A 301-wide operand holding nothing above 7 widens a 61-wide set not at all; the same
operand holding 280 widens it to 281, never to 301. grow_to_admit is that rule and nothing else, and it
returns early on an empty operand, where there is no largest element to ask for and exclusive_find_prev would
assert.
Each operator keeps an equal-width fast path: at a static width that is the whole function, the unrolled one-
and two-block arms included, and at a run-time width it skips a per-block bound check. grow_to_admit returns
at once on an empty operand and does nothing at all at a static width, where there is neither anything to widen
nor another width to meet. Measured at 4096 against 4096, the equal-width case is unchanged.
The width is the part that needs a way to see it. An owning set reports max_size() as everything it could
grow to rather than what it currently spans (max-size-is-the-bits), so the growth rule
above is invisible from the owner. A view over the same storage reports the storage's own size(), which is
the width, and that is what the test asserts through, over 577,600 width pairs; comparing max_size() against
max_size() would prove only the elements, and every width claim would be vacuous on both sides.
an-opinionated-reimagining¶
The charter is that each container is the packing of a standard one and speaks that container's vocabulary,
the way std::flat_set speaks std::set's: keeping what time has proven effective, and throwing out what is
not. The counterpart's synopsis, asserted declaration by declaration, is how the keeping is enforced
(the-sequence-contract). This is the other half, and the two are not in tension: the
synopsis governs what the containers do, and it says nothing about where an operator is declared.
std::bitset is the clearest case of what has not proven effective, and is why this library has no bitset of its
own (interop-not-a-bitset). It is the oldest type in the library's neighbourhood and
reads like it: a member operator== where every container has a non-member one, no swap at all, and
operator<< and operator>> as members beside &, | and ^ as non-members. C++20 changed none of it. On
where operators sit the tree follows the ordinary guidance instead: the operand a mutator belongs to keeps its
member -- flip, <<=, &= -- and the operators that make a new value do not. ==, <=> and swap are
hidden friends; the shifts and ~ sit at namespace scope beside &, |, ^ and -.
The rule is hide where hiding is free, and never where it widens, with friend doing two separable jobs.
Only == and <=> need friendship for what it says: a defaulted == may be a member or a friend and nothing
else ([class.compare.default]/1), and <=> reads the storage. swap, the shifts and ~ need no access at
all -- their bodies are x.swap(y), nrv <<= pos, nrv.flip(), every one of them public -- and the standard's
own free swap is a namespace-scope template for exactly that reason. swap is a friend anyway.
For swap the hiding earns its keep. A qualified swap is a mistake people make by accident: the habit of
writing std::swap(a, b) transfers, xstd::swap(a, b) looks equally reasonable, and it silently defeats the
customization the two-step using std::swap; swap(a, b) exists to find. A hidden friend has no qualified name,
so the accident cannot be spelled. That is a Murphy guard, which is the kind this tree undertakes.
The keyword is doing only that. swap's body is x.swap(y) and reaches nothing private, so it is a friend
that wants no friendship -- and there is no other spelling for what it wants. A function declared at namespace
scope is always reachable by ordinary lookup; C++ offers no "namespace scope, unqualified only". So a hidden
friend is the sole mechanism for ADL-only lookup, and using it here overloads a keyword that says access to
mean placement. Reading friend in this tree, check the body before assuming it needs one.
The shifts and ~ are not hidden, and that is the same rule reaching the other answer. Nobody calls an
operator qualified -- xstd::operator<<(s, 3) is not a thing anyone writes by accident or otherwise -- so
hiding would foreclose nothing that was going to happen, and claiming a hazard there would be a
rationalisation. Guarding against Machiavelli is not this tree's business. So they are namespace-scope
templates beside &, |, ^ and -, which is where set_adaptor has its whole set.
Which leaves three that deviate from the standard containers, and only one of them by choice.
std::vector and std::set spell ==, <=> and swap as namespace-scope templates; here all three are
hidden friends.
== is forced: it is defaulted where it can be, and [class.compare.default]/1 admits a defaulted comparison
only as a non-static member or a friend. A namespace-scope template cannot be defaulted at all. The standard
containers hand-write theirs, which is what leaves them free to put it at namespace scope; wanting = default is
what takes that option away here, and it is a want worth having -- the storage is the one member, and a
comparison nobody writes is a comparison nobody gets wrong.
<=> needs the access. It reads the storage, so a namespace-scope template would have to be granted friendship
anyway, and then it is a friend that is not hidden -- the worst of both.
swap is the one deviation that is a choice, and the Murphy guard above is the reason.
What this costs is not much: the address of an operator, which nothing takes and which is a use worth discouraging in any case -- an operator is meant to be found by the grammar, not by name. What it buys is one shape to learn instead of one per type.
interop-not-a-bitset¶
The library has no bitset of its own. std::bitset predates the STL, and its interface is three at once: a
set's (&, |, ^, count), a sequence's (operator[], test, set, reset and flip by position) and a
string's (to_string and the string constructors). The grid gives each of those its own reading, with one
vocabulary across every storage -- the set reading in bit_fixed_set, bit_inplace_set, bit_set and
bit_small_set, the sequence reading in bit_array, bit_inplace_vector, bit_vector and bit_small_vector,
and a string reading planned as bit_string. A bitset here would be a fourth interface over the same blocks, with
nothing to say that one of those does not say better, and with a contract owed to two counterparts that disagree
with each other about growth, element access and order.
It interoperates instead, through one function for every pair of widths, xstd::bit_convert<To>(from) in
<xstd/bits/bit/bit_convert.hpp>. Its constraint is a rule table over the two types as declared, kept in
detail as std::convertible_to keeps is_convertible_v beneath it. The public concepts are two:
xstd::bit_convertible_to<From, To> says the call is valid, taking the source first as std::convertible_to does
so that bit_convertible_to<To> auto constrains a source, and xstd::bit_constructible_from<To, Blocks> says that
Blocks is bit storage To takes as it is through the tag (is-and-has). A public concept for the
rule table as well would accept exactly the types bit_convertible_to does, under a second name. Each of the two
has its own header under <xstd/bits/bit_concepts/>.
- Its two ends. A target is any owner of either reading at any width and block width, an unsigned integer, a
std::arrayof blocks, or astd::bitset<N>; never a view, which would write bits it does not own. A source is any of those, or a view, a window included: a window is read from its offset, its position zero being position zero of the target. - Positions. Position
iof the source is positioniof the target, across the readings and across block widths. That is the whole contract, and the name says so: this is a conversion of positions, not a reinterpretation of an object. - Two fixed widths widen and never narrow. A source
Mpositions wide converts into a targetN >= Mwide, positionito positioniand the positions[M, N)clear, sobit_convert<std::uint8_t>(bit_array<5>)is whatto_ulonggives for astd::bitset<5>of the same positions, and abit_fixed_set<100>is anxstd::uint128or astd::array<std::uint64_t, 2>. Into a narrower target there is no conversion at all and the call does not compile: both types state their widths, so a truncation is a mistake to report where it is written, and narrowing is a checked conversion only from a run-time width, below. Nothing truncates, so nothing can fail: the call isnoexcept, and the round trip, back through a run-time width or byfrom_blocksof the low positions, is the identity. Widening means one thing in both readings, positionistayingi: a set's fixed width is a capacity, so it gains positions no key names yet, and a sequence gainsfalseelements.std::bitset<N>crosses this way, read onstd::bit_cast's terms (the-bytes-they-agree-on):bit_convert<xstd::bit_fixed_set<N>>(b)is the set reading of astd::bitset, andbit_convert<std::bitset<N>>(s)hands one back. The set and sequence readings cross the same way, directly, with no bitset between them. - A run-time width into a fixed one goes by value, as
std::bitset::to_ulongand Boost'sto_numberdo: a shorter source zero-extends, and a set position at or past the target's width throwsstd::overflow_error. Sobit_set{3}, one 64-bit block wide, converts to abit_fixed_set<10>, andbit_set{3, 64}to nostd::uint64_t. - Into a run-time width, a sequence target takes the source's width: a sequence's
size(), and a set's universe, which is its whole blocks up to what it can hold --bit_set{3, 64, 129}over 64-bit blocks is abit_vectorof 192, and abit_fixed_set<100>one of 100. A set target covers that width in whole blocks of its own, so abit_vectorof 70 becomes a set over 128 positions in 64-bit blocks and over 72 in 8-bit ones. The bits above the width are clear afterwards, as everywhere (padding). An inplace target too small throws what its own growth throws,std::bad_alloc, before a block is written: a sequence target whose capacity is short of the source's width, and a set target, capped at its capacity rather than rounded past it, for a key at or beyond that capacity. A set never throws over padding: a wide universe whose keys all fit converts. - Blocks. From a non-const rvalue whose extracted container is
bit_constructible_fromthe target -- its allocator with it, being part of that type -- under a target whose capacity is the whole of that container, the blocks move:extract()into the adopting constructor, O(1), the source left at width zero. The capacity clause leaves out abit_inplace_vector<100>, whoseinplace_vectorof two 64-bit blocks holds 128 bits the owner may not, and those blocks are inline, so a copy is all a move would have been. Everything else copies: the object bytes in one call, asmemcpywould, where both block types are padding-free built-ins and the target is little-endian outside a constant expression, and byte shifts everywhere else. All of it isconstexpr. noexceptholds exactly where both ends are fixed widths. A run-time end can allocate, refuse an inplace capacity, or throwstd::overflow_errorinto a fixed target.
Position i staying position i is the one thing to watch with a std::bitset or a boost::dynamic_bitset as
the source: their to_string() writes position 0 last, so its text reads reversed against a string of the
positions written first to last.
boost::dynamic_bitset<Block, AllocatorOrContainer> crosses both ways through the same name, from
<xstd/bits/ext/boost/dynamic_bitset.hpp>, a header of its own so that Boost.DynamicBitset is a dependency only of
code that includes it (the dynamic-bitset feature in vcpkg.json). As a source it is a sequence of its
size(), read through to_block_range; as a target it takes the source's width and is written through
from_block_range, from any of our owners or a std::bitset. Both directions copy. Boost 1.92 hands its buffer
out nowhere -- the container is a private member, and no member, friend or constructor moves it in or out -- so
there is nothing to adopt even where its container is exactly ours.
asking-is-total¶
Asking is total whatever the extent: a position past the width is a key the set does not hold, which is an
answer and not a precondition violation. That is what [set] gives contains and find — s.find(k)
returns end() for any k it does not hold, never refuses the question — and it is the difference between
the set reading and the sequence reading, where sequence_adaptor::operator[] indexes and out of range is
out of bounds.
Writing is not total, and that is the whole of the asymmetry. insert carries no noexcept, for the reason
std::set::insert carries none: growing a dynamic extent allocates. It is the one operation a set can be unable
to satisfy — there is nowhere to put the key — and each of the three storages says so in its own way:
insert(k) past the width |
|
|---|---|
| a dynamic extent | grows to admit it; past max_size(), std::length_error (the-sum-that-wraps) |
| an inplace extent | grows within its capacity; past it the blocks say std::bad_alloc |
| a static extent | std::out_of_range, which is what std::bitset<N>::set says for a position past N |
The three differ because the reasons do — a domain, a capacity, a representable size — but none of them is silence,
which at a static extent would be a write through a block index the array does not have. Leaving it undefined for a
performance benefit does not survive measurement: on the sieve at N = 2^16, GCC 14 -O3 -march=native, best of
twenty-five, 188.0µs unchecked against 188.1µs checked, and 75.1µs against 75.1µs over 65536 inserts. The comparison
is against a compile-time constant and is never taken; it costs nothing to keep.
The operators are the unchecked doors. That measurement holds where the key is a constant or a value the compiler
can bound, and there the comparison folds. A key read from memory, a Square from a board array or a king square
from front(), cannot be bounded, and there a throwing check is a compare, a branch and a call to the throw in every
caller. Porting Stockfish onto bit_enum_set<Square> showed what that costs: the cold calls grew do_move and its
neighbours past GCC's and Clang's inlining limits, and perft ran 4% slower than master under PGO. So the split is
std::bitset's own, set(pos) throwing beside an unchecked operator[]: insert, emplace and the ranged forms
keep the check above, while an enumerator's |=, ^=, -= and &=, and operator[] const, take the key as their
precondition, checked only by a hardened build, and each writes its one position as the storage's set, flip,
reset or one-position &= does. A toggle past a dynamic width is the insert that admits the key, and grows where
insert grows; length_error past what a heap can count is a resource limit, not a key's, and stays.
A value that is no key of a mapping closing the universe is the fourth reason, and it has one answer at every
extent. It ranks at or past the mapping's size, and a static width is that size, so a fixed set says
out_of_range for it through the check above. A growing set could grow to the position, and the key it would read
back there is none, so it asks the same question of size before its storage's own ceiling, and says
out_of_range too. The two refusals never meet on one owner: an inplace set over such a mapping has that size for
its capacity, so bad_alloc is left to an open universe's key past the capacity, and length_error to one past
what a heap can count. Every checked door a value comes in through asks it: insert, emplace, emplace_hint, the
ranged and listed forms, and the constructors that insert. A flag value of
several bits, or of none, stays bit_flag_mapping's precondition, there being no one position to rank it at.
The element-wise insert(first, last) and insert(ilist) keep what they inserted before the refused key, which
is [set]'s own behaviour when an allocation throws midway; the consecutive insert_range tier guards the
range's last position before it writes anything, so that one is all or nothing.
Erasing stays total like contains: removing what is not there is the no-op returning zero that
std::set::erase is. Inside the width it is the storage's erase, which clears unconditionally, so the count it
returns costs nothing when it is not read.
The single position, member by member, measured at -O1 -DNDEBUG -fsanitize=address against libstdc++ 14:
| a key past the width, or a step past an end | std::set<size_t> |
here |
|---|---|---|
contains, count, find, lower_bound, upper_bound, equal_range, erase(key) |
answers | answers |
insert, emplace, emplace_hint, insert(hint, x) |
grows | out_of_range at a static width, grows at a dynamic one |
| the same, with a value that is no key of a closed universe | grows | out_of_range at every width |
an enumerator's \|=, ^=, -=, &=, and operator[] |
— | precondition, checked by a hardened build |
erase(end()) |
undefined | assert(position != end()), which it already said |
erase(first, last) reversed |
aborts: a free of a pointer never allocated | assert(*first <= *last) |
++end() |
undefined | assert(m_idx < size()) |
--begin() |
answers the same key again | assert(find_first() < m_idx) |
The first two rows are the policy above, and the four below it are preconditions, on both sides. What they do
here without the asserts is worth keeping: --begin() at a two-block extent falls into the arm meant for the
lower block and answers the key it started from, so a reverse walk over it never ends; at four blocks and at a
run-time width it reads past the blocks, a stack- and a heap-buffer-overflow under ASan. The reversed erase range
does not end either, where std::set corrupts the heap and aborts.
The backward step is the one worth asserting at the iterator, because it is stronger than anything the
storage checks. exclusive_find_prev asserts any() and is_valid(n - 1), and --begin() satisfies both
while there is nothing below to find — the reverse scan's real precondition is that a set position exists below
this one, which is find_first() < m_idx.
The forward step's assert is the storage's own is_valid said one level up, and the difference is which
function a failure names. That is the rule the sequence reading keeps too
(indexing-is-a-precondition) — and the set reading's iterator needs nothing
beyond it: dereferencing end() here is the width rather than a read, so *end() is harmless where the
sequence reading's is a load.
indexing-is-a-precondition¶
The second reading answers the same question another way, and the answer is [sequence.reqmts]'s rather than
ours. Asking a set is total (asking-is-total); a sequence indexes, and out of range is out
of bounds.
at(n) is the one member that answers: std::out_of_range at every extent and through every handle -- the
static owner, the dynamic one, a view and a window alike, the window measuring n against its own size and
not the storage's. Everything else is a precondition, exactly as std::vector, std::array and std::span
have it, and operator[] beside at() is the pair the standard itself draws the line between.
A precondition is not licence to say nothing when it is violated, and at five members this reading would say
nothing without an assert of its own -- or say it a function away, which for a diagnostic is nearly the same
thing. Measured under -O1 -DNDEBUG -fsanitize=address, libstdc++ 14 without _GLIBCXX_ASSERTIONS:
| the precondition | std::vector<bool> |
here, without the assert | here |
|---|---|---|---|
front() on an empty sequence |
segfault | reads the block a floored count leaves and answers false |
assert(not empty()) |
back() on an empty sequence |
segfault | offset() + size() - 1UZ wraps: segfault |
assert(not empty()) |
erase(cend()) |
erases the last element | erases the last element | assert(position != cend()) |
insert(cend() + 3, v) |
inserts anyway: size 2, iterator at 4 | the same, to the number | assert in index_of |
erase(cbegin() + 3, cbegin() + 1) |
size grows, 4 to 6 | size grows, 4 to 6 | assert(first <= last) |
The counterpart column is why none of this is a contract change. Every row is undefined on std::vector<bool>
and undefined here, and an assert can only fire where the program was already undefined -- the one direction an
extension may take. What it buys is the diagnostic, and the rule is
that the precondition is stated at the member the caller named. Without it a debug build catches four of these
five, but one call down: front() and back() on an empty sequence reach the storage's own is_valid(n) only
once the proxy they return is read, and the two bad iterators reach first's and subspan's count <= size()
from inside rebuild. A failure that names subspan for a bad argument to erase points at the wrong function,
which is worse than a blunt one; it is the same reason operator[] says n < size() where test(n) would say it
again. The fifth, the reversed erase range, is caught nowhere below: both of its indices are inside the sequence,
so nothing there has anything to object to, and it grows a four-element sequence to six in a debug build as
readily as in a release one.
The iterator preconditions are said once, in index_of, which is the one place a caller's iterator becomes an
index and is reached by all five members that take one. Half of that precondition is the iterator's own: operator-
and operator<=> assert m_ptr == m_ptr, so an iterator into another sequence never arrives here. What is left is
the range, cbegin() <= position and position <= cend(), and past either end the subtraction below it is a
size_type that wraps or an index the rebuild then writes through.
erase(first, last) adds the one thing neither iterator says on its own -- that they are in that order -- and
erase(position) the one [sequence.reqmts] asks of the single-position form, that the position is
dereferenceable and so not cend(). What holds them honest is the sweep:
test/src/bits/bit_vector.cpp runs insert in its value, fill, iterator-pair and initializer-list shapes,
insert_range, emplace, and erase in both of its own -- at positions including cbegin() and cend(),
and over empty ranges -- each against the std::vector<bool> that models it. Every one of those positions is a
valid one, and the asserts are on underneath them.
The single position, member by member, measured at -O1 -DNDEBUG -fsanitize=address against libstdc++ 14:
| past the width | the counterpart | here |
|---|---|---|
at(n) |
out_of_range |
out_of_range, at every extent and through every handle |
operator[](n) |
heap-buffer-overflow | assert(n < size()) |
front(), back() on an empty one |
segfault | assert(not empty()) |
*end() |
reads the padding and answers with it | assert(m_idx < size()) |
it[n] past the end |
heap-buffer-overflow | the same assert, it[n] being *(it + n) |
subspan(20, 3) of eight |
a std::span of size 3, past the end |
assert(off <= size()) |
Every row is a precondition on both sides, and in every one of them this reading says so where the counterpart
does not. at(n) is the only checked door, and it is the only row where the counterpart answers too.
The reading hands out two types that name a position, and the rule above -- state it at the member the caller
named -- reaches both. The sequence iterator's operator* says m_idx < size(), because this reading's
proxy reads and writes through the storage, so a position it hands out has to be one the storage has. The
set reading's iterator needs no such guard and has none: its proxy converts to m_idx itself, so there the
position is the value and *end() is the width rather than a read. Without the sequence iterator's own
assert, *v.end() and v.begin()[100] would both report bit_block_container::test's is_valid — a private
predicate of a detail type, two levels below the expression that is wrong — where std::vector<bool> reports the
first as true and the second as a heap-buffer-overflow.
unchecked-writes-in-views¶
Reads and writes inside a view go through the subscript, not through test(), set(n) or reset(n).
The position is already in range by then, and those are the checked accessors whose throw would escape a
noexcept — which bugprone-exception-escape is right to report. Every type in the vocabulary hands out a
proxy that writes without checking.
the-proxy-recursion-trap¶
The sequence proxy writes through the storage's assign(n, value) and never through a subscript. A view that fell
back on c[n] = value for a type without set(n, value) would be a trap: were such a type's operator[] to
return our own proxy, that proxy's assignment would land back in the fallback and recurse until the stack
is gone. A named member cannot loop back into the proxy, which is one more reason the write is a member the
storage spells rather than a probe over whatever answers — and bit_block_container's operator[] yields a block,
never a bit (test-not-subscript), so a fallback would find nothing that loops back.
the-iterator-is-the-primitive¶
The set and sequence iterators are a pointer and a position, and they reach the bits through the storage alone.
Their constructors from a pointer and a position, and their references' too, are private: a user reaches a
proxy through a container and never builds one over storage it cannot see. Two kinds of caller may: the adaptor
that hands proxies out -- sequence_adaptor or set_adaptor, of which they are members -- and the proxy's twin,
since the iterator's * builds a reference and the reference's & builds an iterator. An enclosing class has
no special access to a nested one's private members, nor one nested class to another's, so each iterator
befriends its reference and its adaptor, and each reference its iterator and its adaptor.
The pointer is to the storage an owner wraps, never to the owner: bit_fixed_set's iterator holds a pointer
to its bit_block_container<std::array<B, K>, N>, which is why an owner is never itself the thing a view is
parameterized on.
Where they live, and what they are called. Each pair is a member of the adaptor that hands it out --
basic_iterator and basic_reference in both, over a bool saying whether the storage is read as const in
sequence_adaptor, and over the key in set_adaptor -- and is reached through a container's iterator and
reference typedefs and through nothing else; the-adl-firewall says why they are members.
Every container therefore has iterators of its own, an owner and its view included, as std::string and
std::string_view do. The test tree keeps a source for each pair, detail/random_access.cpp and
detail/bidirectional.cpp, rather than taking the exception detail/ is granted: unnameable is not
unobservable, and what these types do -- the concepts they model, the round trip, the writes -- is the
observable behaviour of every container's iterator. So the contract is asserted through the containers, and
these two sources assert white-box what the containers cannot say precisely. They are named after the iterator
category, which is what the two proxies differ by; the reading names the container that hands them out, and the
category names the iterator itself.
operator& on the proxy answers an iterator, which is what a proxy can offer in place of an address, and it
is what keeps a container's subscript tied to its iteration: &a[n] is a.begin() + n, so &a[n] == &a[0] + n
holds for bit_array and bit_vector in iterator arithmetic, exactly where a contiguous range spells it in
pointer arithmetic. std::vector<bool> answers this differently per implementation, and no implementation
answers it fully. libstdc++'s _Bit_reference has no operator& at all, so &v[n] is ill-formed there on a
mutable and on a const vector. libc++ does have one, but only on __bit_const_reference, where it returns
__bit_iterator<_Cp, true>; its mutable __bit_reference has none on current main, so &v[n] is ill-formed
there too and only &cv[n] on a const vector answers. Ours answers on both, measured rather than assumed.
Random access is nevertheless where the ladder stops, and it stops because of the proxy.
std::contiguous_iterator requires iter_reference_t<I> to be a real iter_value_t<I>&, which no proxy is, so
no reading here is a contiguous_range and no iterator here is a contiguous_iterator. That is asserted as a
negative, because it is the one place the bits and the blocks part company: the blocks are contiguous and
owned_bit_blocks requires precisely that
(owned-bit-storage), while the bits are not addressable at all. The
asymmetry is the reason the vehicle keeps its blocks to itself and hands out proxies above it.
The free functions stay qualified as bits::detail::shl<Block>(...) inside xstd::bits::detail itself.
Dropping the qualification would read more naturally and reintroduce exactly the hazard the nesting exists to
close: an unqualified call with an explicit template argument performs ADL, and the associated namespace of
the type in play can be std or boost (why-nested).
the-adl-firewall¶
Why. A proxy that is a specialization over the user's types carries every one of their namespaces into each
comparison it takes part in. [basic.lookup.argdep]/3 makes the namespaces of a class template
specialization's template type arguments associated with it, recursively, so such a sequence proxy over
bit_block_container<std::vector<acme::uint128>> would search acme, and a set proxy under acme::key_mapping
would too. A namespace declaring one generic comparison is enough to take over:
namespace acme {
struct uint128 { /* a Block */ };
template<class A, class B> constexpr auto operator==(A const&, B const&) noexcept -> bool { return false; }
}
r == r on a proxy over acme::uint128 then answers false: the template is an exact match for both
operands, and the built-in bool == bool needs a user-defined conversion on each. Nothing about it is
exotic. Boost.Int128 declares non-template operator==(uint128, bool) and its mirror, so over a
boost::int128::uint128 Block r == true would find that candidate beside the built-in one, each needing one
conversion, and be ambiguous.
The rule. The same paragraph associates with a class type the class itself, the class it is a member of, and its bases; only when the type itself is a class template specialization does it add that specialization's template arguments. A member of a class template specialization is not a specialization of the enclosing template, so its enclosing class's arguments never reach it. A specialization of a member class template is a specialization, but of that member template, and contributes its own arguments alone. GCC 15 and Clang 22 both read it so.
The shape. The iterator and the proxy are members of the adaptor that hands them out, so the adaptor's template arguments -- the storage, the derived container, the key mapping and the comparator -- never reach them:
template<bit_block_container_type Bits, storage Store, window W, class Derived, std::size_t E>
class sequence_adaptor
{
template<bool IsConst> class basic_reference; // & yields basic_iterator<IsConst>
template<bool IsConst> class basic_iterator; // * yields basic_reference<IsConst>
using reference = basic_reference<std::is_const_v<Bits>>;
using const_reference = basic_reference<true>;
using iterator = basic_iterator<std::is_const_v<Bits>>;
using const_iterator = basic_iterator<true>;
};
template<bit_block_container_type Bits, storage Store, class Derived, class Key, bit_index_mapping<Key> KeyMapping, class Compare>
class set_adaptor
{
template<class Value = Key> class basic_reference; // & yields basic_iterator<Value>
template<class Value = Key> class basic_iterator; // * yields basic_reference<Value>
using reference = basic_reference<>;
using iterator = basic_iterator<>;
using const_iterator = iterator;
};
The pair is closed under * and & within one adaptor. The sequence pair is a template over a bool rather
than two classes so that over a storage already const, iterator and const_iterator stay one type, and a
bool brings no namespace. The set pair is a template over the key so that the key, and only the key, is
associated; each body asserts that Value is Key, there to be associated rather than to be a second axis.
What the set iterator does need of the comparator and the key mapping -- which way a step goes, and which key a
position is -- it asks the adaptor, whose private next_position, prev_position and key_at say it once.
Why not an ADL barrier. The usual idiom, a class in a namespace of its own that the library re-exports with a using-declaration, closes off the namespace the class is declared in. It does nothing for the template arguments, which stay associated however the class is reached, and they are the whole problem here.
What is associated, and what is not. The storage -- Bits, and through it the blocks, the Block and any
allocator -- the derived container, the key mapping and the direction are not associated with a proxy or an
iterator. The value type is, deliberately, so that the proxy compares as its value does: bool for the
sequence reading, which brings no namespace, and the key for the set reading, whose namespace is searched, so
*it == *jt and *it == 3 reach a class key's own comparisons, hidden friends among them, as a real
Key const& would. Without that, a key whose operator== is a hidden friend would not compare through its
proxy at all. A reading whose value is a character type keeps that type the same way. xstd::bits::detail is
associated as well, and so is the adaptor itself, being the class the proxy is a member of: its hidden friends
take adaptors, which a proxy is not, so they join the candidates and are never viable. The proxies' own hidden
friends -- swap, iter_move, iter_swap and format_as -- are found by ADL on the proxy itself.
What it costs. No deduction reaches the adaptor's arguments through a member, so the std::formatter
specializations cannot be written as formatter<sequence_adaptor<...>::reference>. Each is a partial
specialization constrained by a detail concept instead, sequence_reference or set_reference, which
recognizes a proxy through the adaptor it names as adaptor_type: true for exactly the references some
adaptor declares. And an owner's iterator is not its view's, so code that compares the two, or an iterator of a
view over a storage with one of a view over the same storage made const, names one adaptor's iterator and
const_iterator throughout.
the-set-for-each¶
xstd::ranges::bit_for_each(s, f) on the set reading walks blocks where the iterator walks positions, and
xstd::ranges::bit_for_each(std::views::reverse(s), f) walks them the other way. The difference is not
block-parallelism -- the functor still sees every set position, one at a time. It is where the loop's state may
live.
operator++ is flat. It has to re-derive the block from (pointer, position) on every step, because an
iterator stays copyable and restartable and therefore cannot keep a partially consumed block between two
increments. A loop has somewhere to put one. So find_next loads a block, masks off what is at or below the
cursor, tests, possibly scans forward and takes a countr_zero, once per position; the walk loads once per
block and then spends two instructions per position, tzcnt for the position and blsr to drop it.
Measured against the range-for at 4.08x to 5.05x, on GCC 15 and clang 22, and the same on a two-block bitboard
carrying twenty pieces as at 2^22 with 40% density. That stability is itself the point: find_next is
inherently serial, no vectorizer engages on either side, and the answer does not move with the compiler --
unlike the sequence reading, where the same question gives answers ranging from 1.24x ahead to 11.4x ahead
depending on which one is asked (the-sequence-ladder). The figures were taken when the walk
was the containers' for_each member; the algorithm is that member's loop, moved out unchanged.
Three decisions the shape forced:
The functor's answer is ignored, as std::ranges::for_each ignores it. The member let a functor return
bool to mean "keep going", and that is the one thing that did not move with it: std::ranges::for_each is the
specification (algorithms-not-members), it calls a functor returning bool on every
element, and an algorithm that stopped would disagree with it on exactly the functors that look like predicates.
A walk that stops at an answer is find_if's and any_of's, whose set-reading forms take a predicate where this
takes a functor.
The descending walk clears the bit it just reported. w & (w - 1) drops the lowest set bit and has no
descending twin, so the walk against the order takes digits - 1 - countl_zero(w) for the position and clears
exactly that bit. The set reading iterates both ways and so does this, and the reverse is the overload on
std::views::reverse over a set of ours, held through its ref_view or as a view, rather than a second name:
for_each_reverse named a direction that std::ranges spells as a view.
It returns what std::ranges::for_each returns, the end as a borrowed_iterator_t and the functor moved
back, so a functor that counts or collects hands its state back as the standard's does.
What is not here is a fat iterator carrying the residual block. Measured, it runs 2.56x to 3.80x against the walk's 4.00x to 5.27x, so it is both slower than the walk and the only one of the two that changes a contract: an iterator that caches a block stops observing an erase that lands ahead of it, where a closed loop caching the same block is unobservable. The walk is the faster half and the safer half at once, which is rare enough to record.
void-is-a-return-type¶
Every function names its return type after the parameter list, void included: auto f() -> void, with the
-> void on its own line above the body wherever the body has lines of its own. Left to habit, the two shapes
split roughly along the library/test line, which is not a reason, so every function takes the one.
Three things were weighed and are recorded because the obvious readings of each are wrong.
clang-tidy does not ask for this and will not keep it. modernize-use-trailing-return-type fires on a
named, non-void, leading return type and rewrites that one; measured on rungs 22 and 24, it says nothing
about void f(), nothing about auto f() with a deduced return, and nothing about auto f() -> void. So
this is a convention the tooling is indifferent to, and only review keeps it -- which is an argument for
#70's deferred .clang-format, not against the
convention.
A deduced auto f() would have been the shorter road and is closed. It reads as the same idea with less
typing, but a deduced return type has to instantiate the body to be known, so any
requires { x.f(); } that would have been answered from the declaration instead instantiates and can hard
error where it should have said "no". That is not a stylistic loss; it is the mechanism every detection in the
tree is built on -- can_grow, is_writable and blittable all ask exactly that question.
Declared return types, trailing or leading, answer it from the declaration.
Lambdas keep their trailing return inline. A lambda is an expression inside a statement, so there is no
"above the body" to put anything on, and modernize-use-trailing-return-type requires the -> void there
anyway. The convention is about named functions.
an-owner-reads-as-its-storage¶
A view over an owner is a view over the storage the owner wraps, and that is the only spelling there is.
bit_set_view(s) over an xstd::bit_fixed_set<64> deduces bit_set_view<std::array<std::size_t, 1>, 64>, the
blocks and width that storage is built over, and bit_set_view<xstd::bit_fixed_set<64>> is not a spelling: Blocks is
constrained to blocks, and an owner is not blocks (one-storage).
Naming the owner through a forwarding trait is the alternative, and three things are wrong with it. A trait
specialization for an owner, relaying each of the storage's twenty entries behind its own requires, would let a
reader name the owner rather than the storage underneath it. The argument for it is readability: the deduced
spelling names an implementation detail that is not in the landscape tables.
- It makes the plain-storage deduction guide viable for an owner too, so
bit_set_view(s)ties. Constraining that guide to non-owners settles the tie, and is needed either way, an owner and its storage being two viable bindings. - It relays entries rather than forwarding a type, so a dropped one compiles and merely runs slower — without
num_blocksandblock, every block-parallel walk falls to one position at a time — and only a test that asserts the block tier explicitly turns that into a failure. - It lets the two readings mix. A trait keyed on the storage cannot tell a set view over a set owner from
bit_span<xstd::bit_fixed_set<N>>, because both wrap the same storage. That rule belongs in the view's constraint (the-readings-do-not-mix), where the owner is still in hand.
With the rule in the constraint, a forwarder buys only the spelling, and the spelling cannot survive Bits being
constrained to blocks. A reader who wants to name the type writes decltype(xstd::bit_set_view(s)), which is what
test/consumer/main.cpp does.
what-a-view-costs¶
Measured on the same backend bit_block_container in every row -- an owner, a view holding a pointer to
that storage, and a view through a forwarding wrapper around the same storage -- so the two layers price
separately. benchmark/ builds at -O3 -march=native, which is what these numbers are.
| operation | bits | pointer costs | bitset-wrapper costs |
|---|---|---|---|
| set iterate | 256 | +7.1 … +9.6% | ±1% |
| set iterate | 1024 | +8.5 … +11.4% | ±1% |
| set iterate | 4096 | +11.1 … +12.8% | ±4% |
| set iterate | 16384 | +10.3% | ±2% |
| sequence count | 256 … 16384 | ±2% | ±3% |
| sequence read | 256 … 16384 | ±1% | ±2% |
Run-to-run noise was ±0.5 to ±3%, so a figure inside a couple of percent is a zero.
A forwarding layer is free, and not merely inside the noise. Under callgrind, at 1024 bits, the view through the wrapper and the view over the raw storage retire 6764 instructions per pass each, equal to the digit, with the same 422 data reads, 1265 branches and 17 simulated mispredicts: the twenty forwarded entries inline away completely. The library keeps no such layer (an-owner-reads-as-its-storage), and the table keeps the column all the same, because it shows that the cost that remains is the pointer and not the layer.
The pointer costs about eight percent on set iteration, and the cause is one lea. Instruction counts,
differenced between two fixed iteration counts so startup cancels, at 1024 bits under GCC 15:
| variant | Ir/pass | data reads | branches | mispredicts |
|---|---|---|---|---|
| owner | 6273 | 420 | 1250 | 11 |
| owner twin | 6273 | 420 | 1250 | 11 |
| view of storage | 6764 | 422 | 1265 | 17 |
| view of bitset | 6764 | 422 | 1265 | 17 |
+491 instructions is +7.8% against +7.0% of measured time, so the gap is more work at essentially constant IPC -- not a stall, not a misprediction. And 491 over the 410 set positions in the pass is one extra instruction per step, so it is in the per-step work, not in setup. The hot block confirms it: it executes 492,000 times for 1200 passes, which is exactly 410 per pass, and the owner's copy of it is eleven instructions where the view's is twelve.
The extra one is address arithmetic. GCC reaches the owner's blocks, which sit in the frame of the function running the loop, with the address folded into the load's own operand:
shrx %rcx,0x10(%rsp,%rax,8),%rdx ; owner: base+index*8+disp, one instruction
and reaches a view's blocks, which are behind a pointer, in two:
lea (%r12,%r8,8),%r10 ; view under GCC 15
shrx %rcx,(%r10),%rdx
That fold is legal and GCC just does not take it. shrx's memory operand is a full ModRM+SIB address, so
base+index*8 is encodable with a register base exactly as it is with %rsp; clang emits precisely that:
shrx %rsi,(%r12,%rdx,8),%rdi ; view under clang 22, same operands, one instruction
Which is why the whole gap is compiler-specific. clang 22 retires 5829 instructions per pass for all three variants, equal to the digit, and measures owner 1573 ns, twin 1564, view of storage 1571, view of bitset 1548 -- a spread of ±1.6%, no gap at all.
How much of the eight percent is a view, and how much is GCC. The benchmark views a local, and clang propagates that known frame address through the view's pointer member into the addressing mode, erasing the indirection the row means to price. A view exists for bits that live somewhere else, so the honest variant holds its blocks on the heap behind an asm-laundered pointer the optimizer cannot trace:
| owner | view of a local | view of opaque blocks | |
|---|---|---|---|
| GCC 15 | 1565 ns | 1691 ns (+8.0%) | 1698 ns (+8.5%) |
| clang 22 | 1546 ns | 1549 ns (+0.2%) | 1592 ns (+2.9%) |
So a view over bits the compiler cannot trace costs about three percent, which is what the indirection is actually worth, and GCC adds five more points by not folding the address. Under GCC the cost does not depend on traceability at all -- the missed fold is charged either way.
The guidance is narrower than a property of views in general: own the bits when you iterate them hot under GCC; the abstraction itself is worth about three percent, and a view is still the right answer when the bits are someone else's, where the alternative is not a container but no reading at all.
A stand-in measures the wrong program. An asm diff of small isolated functions written to stand in for the benchmark says that the pointer is hoisted and only the prologue differs, and so strikes "extra work in the loop" off the list. Those stand-ins are not the benchmark: GCC compiles them to 15,824,004 instructions for every variant, identical to the digit, because a subject that is a local of a tiny function has its address propagated the way clang propagates it above. A stand-in optimizes away the thing under test, and the benchmark's own objects say the opposite.
The same defect sinks a harness that passes the subject to one shared timing function as a Subject const&: that
routes the owner through a pointer as well, makes every variant indirect, and reports no gap. A subject has to
be a local of the function that runs the loop, as the benchmark's variants are, or the harness measures nothing.
What is left over is small and unexplained: 491 extra instructions where the per-step lea accounts for 410,
so about 80 sit in the block-advance path and the prologue, and sequence count at 4096 bits is bimodal --
+24.3%, -3.2% and +21.8% across three runs, with the bitset column swinging the opposite way each time to land
the third variant back on the owner's time. Only the middle variant moves, and between two stable values.
Code layout does not account for it, a byte-identical twin of the owner case measuring layout at about a
percent here, so it stays recorded rather than explained. One run would have made it a finding.
Two things about the measurement itself, because each is easy to get wrong. Every subject goes through
DoNotOptimize before the loop, owners included. Without that an owner is a local the compiler folds straight
through: count() on one block measured 0.16 ns, half a cycle, which is not a faster reading but no reading at all,
while a view's pointer blocks the same folding -- so the comparison measured the folding. And the ladder runs four
blocks to 256, with no one-block rung: a single popcount is one cycle, so a one-cycle difference between two
variants reads as +100% and means nothing.
swap-goes-through-adl¶
std::ranges::swap reaches a type's own swap by ADL on a free function, and a member swap is not
found that way. When it finds none it falls back to a move-construct and two move-assignments, which is
correct and, for a storage whose moves are cheap, not obviously worse -- which is what makes a missing free
swap easy to miss.
set_adaptor and sequence_adaptor each ship a free swap beside the member, and every container swaps by
std::ranges::swap(m_bits, other.m_bits) where m_bits is a bit_block_container. With the member alone
on the storage, it would be unreachable from the containers, and a storage with an optimized swap would never
see it. Measured over a storage whose swap and moves are counted, once per reading:
| member only | member and hidden friend | |
|---|---|---|
sequence_adaptor |
0 storage swaps, 3 moves | 1 swap, 0 moves |
set_adaptor |
0 storage swaps, 3 moves | 1 swap, 0 moves |
Every type in the tree carries the same pair: a member swap that does the exchange, and a hidden
friend swap(x, y) that forwards to it. One rule, no exceptions -- the storage included, though it is not a
container and no requirement asks it for either.
The storage's member has no caller but the friend, which is true and is no reason to fold it away: the friend calling the member is the design, so the member is the primitive rather than dead weight, and deleting it would buy one fewer function at the price of the storage reading differently from the adaptors. The adaptors keep the member because a container requirement asks for it; the storage keeps it so the tree has one shape.
Hidden rather than at namespace scope, matching == and <=> -- ranges::swap finds a hidden friend by ADL
exactly as it would a namespace-scope template,
and std::is_nothrow_swappable_v is a trait over unqualified swap, so it finds one too. What it costs is
xstd::swap(a, b) spelled with the qualification, which nothing writes. Measured across the adaptors,
each entry reads 1 storage swap and 0 moves: through the member, through unqualified swap, and through
ranges::swap alike.
The storage is not asked for a swap by its concept: std::regular implies copyable,
which implies movable, which includes std::swappable, so the concept already requires as much swapping
as ranges::swap can need, and any better one arrives by ADL without being asked for.
The noexcept asks the calls the body makes, noexcept(std::ranges::swap(...)) on the width and the blocks,
and not std::is_nothrow_swappable_v<Blocks>, a trait of the std::swap family, which is a different family with
different rules (ranges::swap suppresses the generic std::swap template when looking for an ADL candidate).
The two agree for every storage shipped here, so the trait would be a latent mismatch rather than a bug, but it
is the same shape as the
is_invocable_r_v-tests-a-prvalue-and-the-call-passes-an-lvalue disagreement in
the-functor-takes-a-value, and the same fix applies: the specification names
the calls the body makes, with the body's own arguments.
a-requires-clause-names-its-arguments¶
A requires clause tests the call the body makes, with the body's own arguments, and not a literal standing in
for the argument, 0UZ say, where the body then passes a different type.
requires requires { self.storage().growing_insert(0UZ); } // is a size_t insertable?
{ self.insert(ilist.begin(), ilist.end()); } // ... a value_type is inserted
For every storage shipped here the two coincide, so no test would catch the difference. That is the danger, not
the defence: a value_type not constructible from std::size_t would be admitted by a satisfied constraint
and then fail inside the body, which is the hard error the constraint exists to prevent. A constraint that
cannot say no about the call actually made is decoration.
Where the function has the argument, the clause names it -- *position, x, *first, *ilist.begin(),
value_type(std::forward<Args>(args)...), static_cast<value_type>(*std::ranges::begin(rg)),
b.reserve(blocks_for(n)). Where there is no call site to borrow from -- can_grow,
blit_source, the for_each block guards, std::bitset's num_blocks -- the requires-expression declares
its own parameters, which is what std::declval is for at namespace scope and what C++20 gave
requires-expressions of their own:
requires (bits_type& b, std::size_t n, bool value) {
b.resize(n, value); b.push_back(value); b.pop_back(); b.clear();
}
Declaring one where the enclosing function already has that name is the same mistake one level up, and the
compilers disagree about noticing: clang's -Wshadow rejected two such parameters that GCC accepted
silently. If the function has an n or an i, the constraint uses it.
The same rule reaches concepts, where the argument is the type: see
owned-bit-storage, whose subscript requirement exists because the
class subscripts and std::ranges::contiguous_range does not promise that.
the-functor-takes-a-value¶
xstd::ranges::bit_for_each hands its functor a prvalue -- as auto(x), C++23's decay-copy in the language
(P0849R8) -- and says so with requires std::invocable<F&, range_value_t<R>>, a bool
on the sequence reading and the key on the set reading. That is one fix for one defect, spelled in two places because it is
worth catching at the interface and worth being right in the body.
It is spelled through a three-line decay_copy helper beside the walks, because MSVC 2022 does not implement
P0849R8 and the stable rung is on the matrix (the-views-are-the-adaptors). Without
that rung auto(x) would do, the paper's own spelling, which clang-tidy has nothing to say about. The other way
round it -- T{x} -- reads to clang-tidy as a cast to the type it already has, so it is not the alternative.
The defect it prevents is a walk that passes its named parameter along. A named parameter is an lvalue, so a
functor asking for bool& or size_t& binds to it, compiles, and writes to a local that the walk throws away.
The walk reads its storage through a const& and never writes back, so what looks like a mutating pass is a
silent no-op -- and on the set reading it is worse than lost, since a walk reports positions and changes none,
so there is nothing a written-back position could mean.
auto does not save this. [](auto& b) { b = true; } is an auto parameter and is exactly as silent as
[](bool&); the axis is the value category, not the spelling of the type. Nor is "make them write auto"
enforceable: a generic lambda is distinguishable from a non-generic one -- requires { &F::operator(); } is
false for the generic one, its operator() being a template -- but that test also rejects a function pointer, a
hand-written functor and std::function, all of them legitimate callers, and it asks about the functor's shape
rather than about what it does with its argument.
What the prvalue settles is a disagreement within the code: is_invocable_r_v<bool, F&, bool>, one line
above the call, tests with a prvalue, so a call with an lvalue could take one arm and make a call that differs
from it, as it does for the reference-taking functor.
The two halves earn their place separately:
- The constraint puts the rejection at the interface, where it reads "constraint not satisfied" and names
the algorithm, instead of erupting somewhere inside the walk. It is also what makes the rejection testable:
a hard error in a template body is not something
static_assert(not ...)can see, so a probe without the constraint reports everything as fine. - The prvalue is what the constraint cannot reach.
std::invocable<F&, bool>is satisfied by a functor overloaded on the value category, so the constraint cannot say which overload runs; only the call can. The tests pin it with exactly that: a functor withoperator()(T&&)andoperator()(T&)that records which one it got, on both readings and in both directions. Reverting any of the four walks to an lvalue fails the suite.
Accepted, and unchanged: by value, by auto, by const&, by auto const&, a function pointer, and a functor
returning anything, whose answer is dropped as std::ranges::for_each drops it. Writing through the sequence is the range-for's job and remains so --
for (auto r : v) r = true; writes, because the proxy is what an iterator dereferences to.
the-sequence-aggregates¶
The sequence reading counts and queries its bools through four algorithms, xstd::ranges::bit_count, xstd::ranges::bit_all_of,
xstd::ranges::bit_any_of and xstd::ranges::bit_none_of, and compares two sequences through xstd::ranges::bit_mismatch
(algorithms-not-members). Each is the std::ranges algorithm it is named after, over
a value or through std::identity, as [alg.count] and [alg.all.of] spell them: bit_count(v, b) is
std::ranges::count(v, b) and returns its signed range_difference_t, the ones popped and the zeros the size less
the ones, where std::bitset::count returns a
size_t, and bit_all_of(v) is true on an empty sequence, as std::ranges::all_of is.
They are not redundant with bit_set_view(v).size(), which already answers a block-parallel count over the
same storage. That view asks a different question -- reinterpret these bools as a set of positions and give
me its cardinality -- which happens to return the same integer as bit_count(v, true). Two readings over one storage is
the whole design, and two-readings-disagree exists to say they are not interchangeable;
making a caller change reading to count their bools is the fault the README levels at the two containers this
library replaces.
The other questions about false are identities, not further algorithms, and they hold on a window too:
std::ranges::all_of(v, std::logical_not()) == xstd::ranges::bit_none_of(v)
std::ranges::any_of(v, std::logical_not()) == not xstd::ranges::bit_all_of(v)
std::ranges::none_of(v, std::logical_not()) == xstd::ranges::bit_all_of(v)
Short-circuiting survives them: asking whether every bool is false really does stop at the first set bit, because
it is bit_none_of. So there are four helpers in bit_algorithm/detail/count.hpp -- count_true, any_true, all_true,
none_true -- and the algorithms are spelled over those, each helper choosing its tier once: the storage's own
member over a whole sequence, and a masked block at a time over a window (windows). none_true is a
helper of its own rather than not any_true, so the storage is asked in its own blocks; bit_block_container spells
count, all, any and none itself, each at the block tier.
xstd::ranges::bit_mismatch is bit_block_container::first_difference plus one countr_zero. The storage's helper
keeps its name: it scans low block to high, which is the ascending orderings' answer, where a bit
string's order would walk the other way. Calling it
mismatch on the storage would repeat the mistake an unqualified lexicographical_compare_three_way made
(two-readings-disagree). The counterpart name goes on the algorithm, which walks blocks
over whole sequences alone: a window's blocks are not its own, so two windows are compared a bool at a time.
Measured on GCC 15.2, -O3 -march=native, 20% density, best of fifteen:
| 2^16 | 2^20 | 2^24 | |
|---|---|---|---|
xstd::ranges::bit_count(v, true) |
0.1 µs | 2.0 µs | 49.5 µs |
std::count over bit_vector |
54.6 µs | 889 µs | 15072 µs |
std::count over std::vector<bool> |
45.1 µs | 702 µs | 11590 µs |
234x to 541x, and the two std::count columns are within 1.3x of each other, which settles what #121 asserted
and this measurement contradicts: libstdc++ does not specialize std::count for _Bit_iterator. A
specialization would show as two orders of magnitude, not as twenty percent. Under clang 22 the ordering
inverts -- std::count over bit_vector runs 2074 µs at 2^24 against std::vector<bool>'s 15419 -- because
our iterator's dereference is a pure function of its index and clang vectorizes it, where _Bit_iterator's
loop-carried state blocks vectorization everywhere.
Against libc++ the advantage is gone, and the row above says so only because both its columns are
libstdc++'s. libc++ does specialize: std::__count_bool walks whole words through popcount and masks the
partial word at each end, and std::find, std::equal, std::fill and vector<bool>'s own __hash_code
are written the same way. Measured on clang 18 with the library as the only variable, a second machine, same
density and best of fifteen:
| clang 18, µs | 2^16 | 2^20 | 2^24 |
|---|---|---|---|
std::count over std::vector<bool>, libc++ |
0.2 | 6.1 | 68.4 |
xstd::ranges::bit_count(v, true) |
0.3 | 3.7 | 72.6 |
std::count over std::vector<bool>, libstdc++ |
72.3 | 1181 | 19316 |
std::count over bit_vector |
129 | 2122 | 33489 |
282x between the two vector<bool> rows on identical source, and a tie at the top: 2^24 bits is 2 MiB and
both run it at about 29 GiB/s, which is the memory and not the loop. So the honest claim is narrower than the
one the first table invites -- xstd::ranges::bit_count beats std::count over a vector<bool> whose library does not
specialize it, and ties one whose library does. What the algorithm buys against libc++ is not speed but that
the block-parallel count is a spelling a caller can name rather than a library optimization they must hope
is present, over a storage that also answers the other two readings.
It also narrows the rule elsewhere in this file. A tail invariant is needed only where blockwise reads are unmasked, which is weaker than needing one wherever reads are blockwise: libstdc++ escapes it by reading element-wise, libc++ by masking at every read site, and this tree by masking at the four writes that can dirty the padding (padding).
the-sequence-for-each¶
xstd::ranges::bit_for_each(v, f) on the sequence reading is the set reading's walk (the-set-for-each)
transposed: an outer loop over blocks, an inner loop over the bits of one block, so the reload that
operator++ must perform on every step becomes the inner loop's exit test. The functor takes what this
reading's iterator dereferences to, a bool, where the set reading's takes a position. A window walks its own
positions, its blocks read through block_at at its offset.
The reason for the walk is the same and the payoff is not. Measured across GCC 15, GCC 16, clang
20 and clang 22, no iterator layout beats the current (container pointer, index) on this reading: a block
pointer plus offset ties, and a cached residual block is worse, because operator++ is flat and the reload
test becomes a per-bit branch. So whatever for_each wins is loop structure, and loop structure is exactly
what a vectorizer needs:
| GCC 15.2, 2^24 bits | for_each |
range-for | ratio |
|---|---|---|---|
n += b |
1685 µs | 13842 µs | 8.2x |
h = h * 1000003 ^ b |
18743 µs | 19154 µs | 1.02x |
The win is the vectorizer's, not the loop's. Where the body carries a loop-carried dependence there is
nothing to hoist and the two are parity; where it does not, the outer-loop-over-blocks shape lets GCC
vectorize what the flat operator++ hides. And under clang 22 the range-for already vectorizes -- 2021 µs
against for_each's 1900, 1.06x -- so on that compiler the walk buys almost nothing on this reading.
That is a narrower claim than a general speed-up, and it is the measured one. for_each is still
worth having: it is never slower, it is 8x ahead on the compiler and body shape where the range-for leaves
the most on the table, and it is the spelling that lets the library choose the loop rather than the caller.
The reverse walk is the same overload on std::views::reverse the set reading takes, blocks from the top and
each block's bits from its top, so the two readings spell a backward walk alike; over a random-access range the
range-for over std::ranges::reverse_view costs about the same, and the overload is there for the symmetry.
read-only-set-proxy¶
The set reading's proxy is read-only whatever the qualification of Bits, because a key is nothing to write
through: assigning to a position would mean moving an element, which a set has no spelling for. It earns its
keep anyway — operator& round-trips to the iterator, and the one conversion, to the key, is what every
comparison goes through. A class constructible from the key is direct-initialized from the proxy, index(*it),
whether its constructor is explicit or not; copy-initialization, index i = *it;, would take two user-defined
conversions and is not offered (see uint128-support for why). Nor does the proxy offer an
explicit conversion of its own: MSVC cannot resolve one beside such a constructor.
the-proxy-copies-the-handle¶
Both proxies declare their copy constructor, and for opposite-looking reasons that are the same reason. The
set proxy is = default beside a deleted operator=, because a reference to a key is a value: copyable,
never assignable. The sequence proxy is = default beside two operator=s that write through the handle
to the bit. That second pairing is exactly the case [-Wdeprecated-copy-with-user-provided-copy] names: a
user-provided copy assignment makes the implicit copy constructor deprecated, because the compiler can no
longer assume the two agree -- and here they genuinely do not, which is the whole point of a proxy. So the
copy constructor is said out loud rather than inherited by default.
Nothing else copy-constructs a proxy in this library, so only formatting reaches it: std::formatter's dispatch
takes the element by value, and that copy is where the deprecation would land.
the-one-adl-exception¶
The sequence iterator's iter_move and iter_swap are hidden friends found by ADL, and they stay under the
no-ADL rule because they are std::ranges' own customization protocol: ranges::sort and swap_ranges
reach a proxy only through them, and it is where vector<bool> historically fell down. The three swap
overloads on the proxy are the pre-ranges spelling of the same thing, for std::sort and everything else
still built on std::iter_swap. format_as is fmt's protocol in the same sense.
The sequence proxy borrows nothing else, from [vector.bool] or [template.bitset.general]: no flip() and no
operator~. One element is negated by v[i] = not v[i], and a whole array by the valarray reading's !.
formatting-the-proxies¶
std::format over the containers needs nothing said about the containers. Every owner and view here is a
range, so [format.range.formatter] would format each one already, except that it requires
formattable<ranges::range_reference_t<R>> and a reference of ours is a proxy. So each proxy specializes
std::formatter for itself, in its own header, and stops there: bit_set, bit_fixed_set, bit_vector,
bit_array, the views, the windows and the inplace column all follow from that, none of them mentioned.
This is the same shape the proxies already had for fmt, in fmt's spelling. format_as is fmt's generic
per-type hook: define it for one type and every range over that type formats, which is why the proxies carry
it and no container does. std::formatter is the standard's hook for the same job. So each library gets one
hook per proxy -- a hidden friend for fmt, a specialization for the standard -- and in both the containers
follow for free. Nothing here is a special case for formatting; it is the general mechanism used twice.
The two hooks share one definition. The std::formatter calls format_as, unqualified so ADL finds the
proxy's own hidden friend, rather than reaching the value a second way of its own. That direction is the one
#20 argued for to WG21 on
P3070R0: one format_as in the type's own namespace serves
fmt and the standard both, where a formatter specialization serves one library and has to be written again
for the other. The standard has not taken that hook for general use -- neither libc++ nor the MSVC STL defines
one today -- so the specialization is needed, and it does not restate the value. A formatter that cast to
size_t or bool through the conversion operator while format_as read the member would land on the same
value with nothing making it: a change to one would leave fmt::format and std::format disagreeing about the
same object, with no compile error and no test to catch it, since the proxy tests check format_as and the
format test checks std::format. format_as is the one place that says what a proxy prints as.
fmt is not a dependency of this repository, and format_as stays anyway. The library builds and tests with
nothing but the standard's std::format. The hook is not a build dependency: it is a hidden friend of a
header-only proxy, costing a consumer who never formats nothing at all, and it is what a consumer who does
format with fmt reaches. Deleting it would take fmt interop away from them to remove a line that our own
std::formatter calls regardless. So the two-hook shape above is the shape, and this repository simply does not
install the second library to check the second hook.
The readings then separate themselves. [format.range.fmtkind] picks range_format::set for a range with a
key_type and range_format::sequence otherwise, so the set reading prints {1, 3, 5} and the sequence
reading [false, true, false, false] -- the same split format_as arrives at for fmt, reached here through
the standard's own machinery rather than by our choosing
(two-readings-disagree).
Each specialization derives from std::formatter<size_t> or std::formatter<bool> instead of writing a
parse, which is what keeps the whole spec: a width and a fill on a single proxy, and the nested spec a range
formatter forwards, so {::#x} over the set reading and {::d} over the sequence reading reach the
underlying formatter intact.
Why this lives with the proxy and not in a header of its own. A separate xstd/bits/format.hpp outside the
umbrella would keep <format> off the path of a consumer who does not format, and that reason does not hold:
sequence_adaptor includes <format> already, to build the file:line:column: messages its exceptions carry, so
that reading pays for it whatever the umbrella does. Measured, a separate header saves the sequence reading 181
preprocessed lines -- and costs a user the knowledge that the header exists. Only the set reading would save
anything real, about 11.8k lines, set_adaptor being the one adaptor that does not format its own errors.
Against that: hashing and formatting are both the customization points a user-defined type owes the standard
library, and std::hash has always been specialized inside the adaptor headers. Putting std::formatter
beside format_as in the proxy's own header makes the two protocols for one type live in one file, and makes
the two customization points consistent with each other. The standard library itself cannot do this -- its
formatter<pair> lives in <format>, because <format> is allowed to know about <utility> and <utility>
must not depend on <format> -- but the arrow only points that way for types the standard owns. For ours it
cannot: <format> will never know about a proxy of ours, so the proxy is the only place the specialization
can go without a separate header to remember. Nothing here waits on P3070R0: what a formatter needs is the
proxy's formattability, and that is ours to give. #20 records the other half of the
argument, which this library is the worked example of: a proxy nested inside its container cannot be named by
either hook, because the template arguments will not deduce through
container<T, A>::proxy_reference. A hidden format_as needs no deduction, being found by ADL on the proxy
itself, and the formatter is a partial specialization constrained by a concept that recognizes the proxy
through the adaptor it names (the-adl-firewall): that is how the proxies here stay members
of their adaptors and format all the same.
total-lookups-on-the-container¶
Every bit_fixed_set lookup is total over key_type, because std::set's is: a key outside [0, N) names no
element, so it answers absent rather than reaching the bit. bit_block_container asserts is_valid on
every position it accepts and offers no total spelling of any of these — that is the layering working, not a gap
in it. The precondition is the sequence's; the guard is the container's.
One of those guards stops a write rather than a read: without it an out-of-range key clears a bit in whatever follows the blocks.
Two findings from running the suite against the unguarded header are worth keeping, because they are why it
sweeps every width rather than one convenient one. No single width exposed all six operations and no single
key did either — at N = 8 over uint8_t every one of them was clean for key == N, so a narrow
single-block set proves nothing on its own. And erase was an out-of-bounds write, while upper_bound
failed on its returned value rather than on memory at all: find_next's ++n wrapped before it could test
the bound, so it answered with a real element where end() was due. That second one is why this stays a gate
on the jobs that build without sanitizers.
the-sieve¶
The sieve lives in examples/include/opt/set/, and the path is the point: it is the library's worked example
and its bench, which makes it a subject rather than library code. Under include/, the file set's
BASE_DIRS, CMake would put it on the build interface: #include <opt/set/sieve.hpp> would compile for an
add_subdirectory or FetchContent consumer and fail for a find_package one, the header never being installed,
and for the first kind its names would be reachable library names. So the names are in opt::, and the
configure-time file-set check covers all of include/, so a stray directory is caught rather than shipped
(the-interface-line). It speaks one
vocabulary: an ordered set of integers. It runs over std::set, std::flat_set, bit_fixed_set or
bit_set; the candidates are iota(2, n) converted to the set, and a sift is erase. Nothing
bitset-shaped takes part.
There is no second sieve running the same shape over std::bitset or boost::dynamic_bitset through
bit_set_view. Two sieves would be two vocabularies for one algorithm, and what one would demonstrate -- that the
view reconciles set(pos)/reset(pos) with insert/erase -- is what test/src/bits/bit_set/bit_set_view.cpp asserts
directly, without a sieve in the way. An example earns its place by showing something no test does.
The bench is dynamic containers only, so it compares like with like: std::flat_set, std::set and
bit_set, one of each representation -- sorted vector, node-based, dense bitmap. A bit_fixed_set<N>
sifting a universe it was sized for is not measuring what a growing set is, so it stays in the test and off
the bench.
Two ladders cross. The bound doubles from 2^10 to 2^20 rather than stepping decades, because bit_set
changes block count on exactly those boundaries: a doubling walks whole blocks, and a cache knee reads as a
knee instead of smeared across a decade. The second ladder is Block, ours alone -- std::set and
std::flat_set have none to choose. At a given n the footprint is the same count of bits whatever the
block, so that axis is not about memory: it varies the block count against the cost per block, and since the
sift is a strided write (index-walks) and near width-indifferent while the scans are not, a
block effect should show up as a divergence between the three benches rather than as one number.
The ladder is what makes the shape visible, and the shape is not a constant factor. std::flat_set is the
fastest of the three at the bottom rung -- 0.10 ms against std::set's 0.17 -- and by 2^16 it is 215 ms
against 7 ms, because erase on a sorted vector is linear and the sieve does about n log log n of them.
That is quadratic, and no amount of contiguity buys it back. A single measurement anywhere on that curve
would have supported whichever conclusion the author already held.
Which is why std::flat_set stops at 2^16 while the other two run to 2^20: the ceiling is a measurement
decision before it is a budget one. Past that rung each doubling costs five times the last and establishes
nothing the slope has not already shown -- carried to 2^20 it takes 107 seconds for a single iteration,
against std::set's 0.94 and bit_set's 0.0056, and spends six and a half minutes of every Release ctest
run to re-derive a line visible four rungs earlier. Those three numbers are a one-off measurement at 2^20;
with the ceiling in place the bench does not produce the first of them.
Under ctest a bench is a smoke test -- that it runs, not what it costs -- so the test invocation passes
--benchmark_min_time=1x. Timing the full ladder in CI would put minutes of std::set at 2^20 into every
run for a number a shared runner cannot make meaningful anyway. A manual run takes the defaults.
The test keeps the static types alongside the dynamic ones, bit_fixed_set<N> with std::set and
std::flat_set, since the sieve is an example before it is a bench, and it keeps the degenerate widths: a
two-candidate sieve runs sift_primes1 to exhaustion, and a three-candidate one returns from filter_twins
before it has a triple.
the-unbounded-sieves¶
The sieve above needs its bound before it sifts anything: generate_candidates materializes every candidate
below n first, which is what makes its space O(n) and what makes "give me the next prime" a question it
cannot answer. Two variants remove the bound, and they remove it in different directions.
The incremental sieve (O'Neill, The Genuine Sieve of Eratosthenes, JFP 19(1), 2009) has no candidate
array at all. It keeps one entry per prime found so far -- the next composite that prime will strike, and which
prime strikes it -- so its space is O(pi(n)) and there is no n: incremental_sieve::next() generates
forever. That is the whole of its case, because it is slower, and measurably: 11.3 ms against
sift_primes1's 0.394 ms at 2^16, about 29 times. A map lookup per candidate is a large constant beside
a strided write, and O'Neill's own point is that the naive "sieve" that trial-divides is not the sieve at all.
It stops at 2^16 on the bench for the same reason std::flat_set does: the constant is the finding, and four
more rungs would only re-derive it.
The segmented sieve is the one that pays. Base primes below sqrt(n) once, then a single window walked
over the rest, so peak memory is O(sqrt(n) + W) whatever n is. Window is a template parameter carrying
its own extent, which makes bit_fixed_set<W> the natural argument: a compile-time width that allocates
nothing in the loop, refilled as ~Window() and struck and read once per segment.
It is faster than the bounded sieve, not merely thriftier: 4.05 ms against 7.01 ms at 2^20, about
1.7 times, and ahead at every rung. Asymptotically the two do the same work; the difference is that a
32768-bit window sits in L1 for its whole segment while a 2^20-bit sieve is a megabit walked with a stride.
Less memory and less time is not the usual direction for that trade to run, which is why it is recorded.
Both are held to the bounded sieve by the test rather than described as equivalent -- every bound including
the degenerate ones, and two window widths, so a window shorter than its tail is exercised beside one that
swallows it. The bounds stop at N because one container under test is N bits wide, and a fixed width is a
capacity (width-is-capacity).
generate_candidates is total in n. iota(2, n) is a precondition violation below 2, which asking the sieve for
the primes under nothing would reach -- a question with an answer, none, rather than a contract to break. The two
unbounded sieves compute their own inner bounds, so a guard there is worth more than a comment.
the-sequence-ladder¶
The bit benches ask different questions and so run different ladders, which is the point rather than an inconsistency.
Each bench is one pairing with one variable: bit_vector against std::vector<bool>, a set against
std::set. Same reading, same storage, ours against theirs -- so a row measures the implementation and nothing
else.
benchmark/src/sequence/access.cpp is the endgame-database question: a dense flat array
indexed by a ranked position, where a lookup costs a cache miss and nothing else, so its ladder runs 8 KiB to
32 MiB and reports latency per random read rather than bytes per second.
What they have found so far, on GCC 15.2, -O3 -march=native, x86-64:
- A random bit read costs the same in
bit_vectoras instd::vector<bool>-- 3.0 ns in cache, about 5.8 ns at 32 MiB for both, and construction is parity too. Representation does not matter to a lookup; only footprint does. For a database that is the useful negative result: what buys a lookup is fewer bits per position, not a better container. std::countis slow over both, and not because libstdc++ counts words. It reads about 1.2× to 1.3× slower overbit_vectorthan overstd::vector<bool>on GCC, which looks like a specialization ofstd::countforstd::vector<bool>::iteratorand is not one: a word-at-a-time count is two orders of magnitude ahead, not twenty percent, which is whatxstd::ranges::bit_countmeasures at (the-sequence-aggregates). The gap is the generic path over two different proxy iterators, and under clang it runs the other way, ours being the vectorizable one. The sequence ladder's fixtures are the expensive part of actestsmoke run: a 32 MiB fixture is filled a bit at a time, and the whole file costs about eleven seconds where the other three cost five between them. That is proportionate, and it is checked rather than assumed (the-sieve records what happens when it is not).
Platform and tooling, continued¶
uint128-support¶
A Block is any xstd::unsigned_integer, and three families of them are 128 bits wide: the compiler's own
unsigned __int128 on GCC and Clang; the Microsoft STL's std::_Unsigned128, which is what xstd::uint128
names on every MSVC-ABI target, clang-cl included; and the two third-party classes xstd adapts,
absl::uint128 and boost::int128::uint128. Only the first is a scalar. The other three are classes, and
that difference is the whole of this section.
bits::detail::intrin forwards countl_zero, countr_zero and popcount to xstd::countl_zero and friends,
which are <bit>'s domain plus one overload per integer class, reading the blocks each type already holds.
Forwarding straight to <bit> would implement the seam against std::unsigned_integral — a closed concept
no class can join — while it is constrained on an open one, so every 128-bit integer class would satisfy the
interface and then fail inside the body. The seam is where that body is chosen, and the forwarding is what makes
the other three families usable.
Three consequences follow, none of them obvious from the forwarding change alone.
The calls are qualified, so they bind where they are written. xstd::popcount(block) is a dependent call
by a qualified name, and ADL does not apply to qualified names, so its candidates are the overloads visible at
intrinsic.hpp — not at the instantiation. An adapter included afterwards declares its overload too late to be
one. A translation unit reaching for an integer-class Block therefore includes that adapter first; the test
tree does it in test/block_types.hpp, above every container header. The same rule decides where
test::block_basis can be spelled, which is why it sits in that header rather than in one of its own: a
separate header could not be relied on to sort below the adapters.
A Block being a class breaks two assumptions that a scalar hid. A one-block predicate returning lhs & rhs
into a bool copy-initializes, and so needs an implicit conversion; an integer class offers only an explicit
operator bool. So block_intersects and block_includes compare against a zero Block, while not and !=
reach bool by a contextual conversion, which an explicit operator satisfies.
And the proxies. Both of them convert to their value_type and to nothing else — bool for the sequence
proxy, the key for the set proxy — which is the shape
std::vector<bool>::reference has on libstdc++, libc++ and the MSVC STL alike. Neither declares an
operator== or an operator<=>: every comparison is the built-in one, or the key's own, reached through that
one conversion, so it serves two proxies over different Blocks exactly as it serves two over the same one.
Neither proxy carries a second, templated implicit conversion, to any class type implicitly constructible from the
value_type, because any such class with a non-template operator== in a namespace associated with the proxy — its
Block's, its key's, its key mapping's — gives every comparison a second, equally good reading: convert both sides to
the value_type, or convert both sides to that class. Two user-defined conversions of equal rank tie, and the tie
costs equality_comparable and with it std::ranges::equal. The 128-bit integer classes are one instance, a Block
naming its own namespace; excluding xstd::integer and adding comparisons exact in both operands would patch that
instance and leave every other one open, a user's strong type in a key-mapping namespace among them. A set's
comparator is no way in: it reaches the proxy only as a direction. What going without it costs is copy-initializing
a class from a proxy, C c = *it;, which needs two user-defined conversions; direct-initialization, C c(*it);,
needs one and still works.
Of the namespaces that list names, only the key's is associated with a proxy
(the-adl-firewall), so the Block's contributes no candidate: Boost.Int128's non-template
operator==(uint128, bool) and its mirror are not found for a sequence proxy over a boost::int128::uint128
Block, so there r == true and r == 1 compile and answer as they do over every other Block.
Two facts, two flags, because one flag would conflate them. TEST_HAS_UINT128 names the compiler's 128-bit
builtin: a scalar, and a std::unsigned_integral. It feeds block_types, which every suite grades over, and
test/src/bits/block/type_traits.cpp, which asserts exactly those std traits of each block — both right to
assume a builtin. TEST_HAS_MSVC_INT128 names what an MSVC-ABI target has instead, std::_Unsigned128 under
the same xstd::uint128 spelling: a usable Block, but a class, so not is_integral, not is_unsigned, and not
something <bit> will take.
One flag covering both would put a class-typed Block into block_types, and so into every suite at once, which
breaks sixteen MSVC targets — the std_set comparisons among them, whose helpers assume a Block is a std
integral and whose per-type cost is superlinear. So the three integer classes sit together in
wide_block_types, feeding only the two suites that pay a static_assert or one linear pass per type. MSVC's is
named beside Abseil's and Boost's, which is what it behaves like, rather than beside the builtin whose spelling
it shares.
The assert beside each flag is an implication, that where the flag is on the basis is really there. An
equality with std::unsigned_integral would be the wrong question in both directions: false for
every integer class that works as a Block, and true in dialects where <bit> still declines the type. The
converse is not worth asserting either — a basis a flag declines to use costs coverage, not correctness.
Abseil's and Boost's are optional: test/ext_int128.hpp detects each by __has_include, so a build without
them drops it from the Block lists rather than failing. MSVC's needs no dependency at all. All three earn their
place by being the types that catch a container assuming a Block is a scalar — every defect above was invisible
to every builtin.
exception-escape-nolints¶
The primitives in test/include/test/sequence/primitives.hpp that hold a BOOST_CHECK_THROW carry
NOLINT(bugprone-exception-escape). Each guards on the position and calls a member the standard specifies as
throwing outside the width — at — checking in the other arm that it does throw.
The check reads the callee's signature and cannot read the guard, so it reports every instantiation. The claim being made is that a primitive answering about a position inside the width never throws, and it is what would fail the suite loudly were the guard ever wrong.
clang-tidy-false-positives¶
Four findings are suppressed because the checker cannot see what makes them right:
bugprone-signed-bitwiseondetail/bits::shland::shr, whose count is cast toint. The two checks that govern this leave no third option, and both were measured:absl::uint128declares a single shift,operator<<(uint128, int), so an unsigned count reaches it by a signedness-changing conversion and-Wsign-conversionrejects it, while an int count is a signed operand of a bitwise operator andbugprone-signed-bitwiserejects that. The type's own operator decides which is right, and the count is a bit position within one block, so the signedness the check objects to cannot be reached.misc-redundant-expressionon a reflexivity check, which cannot be written without naming the object twice.bugprone-std-namespace-modificationaround eachnamespace stdblock, which holds only what[namespace.std]/2allows: a specialization of a standard library template for a program-defined type.hash,tuple_size,tuple_element,formatterand the rest are written alike, each block bracketed byNOLINTBEGINandNOLINTENDoutside its braces, with the clause as the reason.modernize-avoid-c-style-castonsequence_adaptor'sis_static_width_owner, where it points at theStoreinowns(Store)and offers to make it astatic_cast. There is no cast on that line.ownsisconstexpr auto owns(storage) -> boolinownership.hpp, the only entity that name denotes, andStoreis a non-type template parameter rather than a type. The sameowns(Store)is written on sixteen other lines here and none of them is flagged; what is particular is the namespace-scope variable template, whose initializer stays value-dependent until instantiation. clang-tidy 23 alone emits it -- 22 and 24-SVN carry every other finding for the same translation unit and not this one -- so it is a release's bug rather than a reading of the code, and the suppression can go once the ladder is past 23.
An eighth is the reverse case, and the one to be careful with: the check is right about the language and
wrong about the compilers. readability-redundant-typename on clang-tidy 22 asks for the typename to go
from std::same_as<typename std::remove_const_t<Bits>::block_type, Block> in blit_source's partial
specialization. P0634 made typename optional only in listed contexts, and a template argument is not one of
them — GCC 14 rejects the elision outright, type/value mismatch at argument 1. An alias-declaration is
on the list, so the block type is named through one and the template argument is a simple-template-id that
needs no typename and trips no check. The same pattern already names allocator_of in
bit_block_container's test. Measured, because "clang-tidy suggested it" is not evidence that it compiles.
The same check, from the other side, is simply right. On the set reading's
checklist it asked for the typename to go from five parameter-declarations -- the
typename C::value_type const* first and ... last of set_size_t and set_size_t_allocator, and the
[](typename C::key_type) that erase_if is handed -- and a parameter-declaration IS on P0634's list, which
is why the parameters beside them already omitted it. It flagged those five and left
std::initializer_list<typename C::value_type> and std::same_as<typename C::size_type> alone on the very
same lines: the rule above, drawn by the checker itself. Template argument, the keyword stays; parameter
declaration, the keyword goes. The ones it left are not an oversight and are not to be "finished".
A ninth had a fix rather than a suppression. modernize-use-nullptr reads the 0 in (a <=> b) < 0 as a
null pointer constant, which is the same false positive -Wno-zero-as-null-pointer-constant already covers on
the compiler side. Every site in the test sources says std::is_lt, std::is_gt or std::is_eq instead --
the standard's own names for those three questions, which are clearer than the comparison against a literal
and leave the check on to catch a real one. Do not spell them back.
A tenth is the one to be most careful with, because the check is right about the spelling and wrong about what
the code has to do. modernize-type-traits asks for std::tuple_size_v<C> in place of
std::tuple_size<C>::value, in array_tuple_element's guard and again in array_bool's compound
requirement. A checklist is asked of types that FAIL it -- that is the whole of what a checklist is for
(concepts-are-tested-on-ready-made-types) -- and the two spellings
fail differently. tuple_size<C>::value is a nested name, so for a C with no tuple_size at all the
substitution fails in the immediate context and the constraint answers false. tuple_size_v is a variable
template whose initializer instantiates OUTSIDE it, and the same C is a hard error no requires-expression
can catch: not array_tuple_element<C> stops compiling and reads incomplete type std::tuple_size<C> used
in nested name specifier. Measured on GCC 15 and 16 both, against a type with tuple_size and no
tuple_element and a type with neither, before either spelling was kept.
The tuple_element half of that same check IS taken, and the asymmetry is the point. tuple_element_t is an
alias template and substituting into one is transparent, so its failure stays in the immediate context and the
requirement still answers false. The concept therefore reads typename std::tuple_element_t<0, C>;, keeping
the typename -- a type-requirement needs it, and an alias-template specialization is a type-name under
[expr.prim.req.type], which is the half of the fix-it that would otherwise have left a bare type standing
where only an expression may. Half of one check's advice was worth taking and half of it was not, and nothing
short of running both tells you which half.
clang-crashes-on-a-foreign-bulk-source¶
Asking whether a bulk operator accepts a view over a foreign storage -- ours &= bit_span(a_std_bitset), in
a requires-expression or written out -- crashes clang 18 and clang 20 alike with an internal error, and does
so without the windowed operators as well, so the trigger is the whole view's &= seeing a foreign
sequence_adaptor as its argument. gcc rejects the expression as it should. The expression is ill-formed
either way, since bulk on a view takes a source of the destination's own block type, so nothing in the library
or the tests spells it; the crash is recorded here so nobody
adds the assertion that would.
the-coverage-gate¶
The Coverage job enforces 100% of lines and branches rather than reporting them, and the benchmark tree is not
built there. The second half is not about build time: gcovr is told to exclude benchmark/.*, and that
exclusion does less than it looks like it does. It drops a benchmark as a SOURCE, but a benchmark is not where
the counted code lives -- it is a translation unit that INSTANTIATES the library's templates, and those
instantiations belong to the headers under include/, which is exactly what the report keeps.
What makes that fatal rather than untidy is that the job configures Debug, and benchmark/CMakeLists.txt
registers a benchmark as a test only when the build type is not Debug. So under coverage the benchmarks
compile and never run. Every branch of an instantiation that no test translation unit also makes is therefore
uncovered by construction, and no amount of work on the benchmark can cover it, because the benchmark does not
execute.
A Block-width benchmark shows it. benchmark/src/set/blocks.cpp instantiates the two-block arm of the scan for
three 128-bit carriers in one translation unit, a combination no single test unit makes; compiled under coverage,
it raises the per-line branch totals of that arm from 2, 6 and 2 to 4, 11 and 4, nine branches covered by nothing.
A benchmark shape that reaches the missing arm changes the numbers by exactly zero -- which is the proof that the
benchmark never runs, and the reason the gate is answered at the CMake level.
So XSTD_BITS_BUILD_BENCHMARKS gates the tree, the Coverage workflow passes it OFF through cpp-ci's
cmake_args, and the benchmark/.* exclusion stays as a second line that costs nothing. A benchmark measures
the library rather than being part of it, which is the stated reason for excluding it; not compiling it
into the measurement is that reason carried through.
a hundred per cent measures the source's shape too¶
Two kinds of gap the gate reports are not about tests at all, and neither can be closed by writing one.
A message whose three code units are spelled as three static_casts on three lines gets a counter of its own for
each of the first two from gcov, which attaches the std::format call to the third, so two lines that every test
of that message runs read as never executed -- ##### against a line whose neighbours both ran, in every
instantiation, every time. Writing the three casts on one line closes it: nothing excused, no test changed, and
the same message printed.
The other is the defaulted comparison's unreachable base branch, which is above.
What the two have in common is that the number moves without the program changing. Below a hundred, coverage measures the tests. At exactly a hundred it also measures the source's shape -- where an argument list wraps, where a declaration wraps, and whether the line an exclusion pattern is looking for happens to be the line gcov anchored the counter to. That is a real cost of the gate and it is worth paying, because a gate that reports instead of enforcing gets read as noise. But it has to be read for what it is: in both, "uncovered" means "gcov counted this differently than you would have", and going looking for the missing test is going looking for a test that cannot exist.
Measurements and citations behind the code¶
Each of these stands behind a one-line comment in a source file, and holds what that line has no room for, here, where length costs nothing.
std::count over a bit container measures the standard library, not the container¶
benchmark/src/sequence/access.cpp's bm_sequential_count row is a sweep asked through a generic
algorithm. At 65536 bits, 40% set, medians of seven runs, in nanoseconds:
| compiler and library | vector<bool> |
bit_vector |
ratio |
|---|---|---|---|
| g++-14, libstdc++ | 43690 | 52930 | 1.21x |
| clang++-20, libstdc++ | 83680 | 19232 | 0.23x |
| clang++-20, libc++ | 73 | 19146 | 262.27x |
Same two containers, same bits, and the answer runs from four times faster to two hundred and sixty
times slower. libc++ specializes std::count for its vector<bool> iterator — __count_bool in
<__algorithm/count.h>, one popcount per word — and sweeps the lot in 73ns. libstdc++ specializes
fill for that iterator and not count, so there both sides walk bit by bit through a proxy and the
remaining difference is codegen: clang turns our indexed read into something four times quicker than
it manages for theirs, gcc does not.
So the row is not a comparison of containers and not a gap to close. There is no portable way to make
std::count count words for a container defined outside the standard library: libc++ reaches its own
iterator by overloading inside its own namespace, and neither std::count nor std::ranges::count
offers a customization point a user-defined bit container could hook. What the row says is how much a
sweep costs when it is asked through a generic algorithm.
bm_sequential_bit_count asks the same question through xstd::ranges::bit_count instead: one popcount per
block, 91ns, 73ns and 74ns in those same three configurations. It does not care which library or which
compiler, because the loop is ours either way. Level with libc++'s specialized count at 1.01x, and
some five hundred times quicker than what libstdc++ offers for the same question. std::vector<bool>
has no bit_count to put beside it, which is why that rung is ours alone.
Why the bidirectional steps guard on a zero width¶
set_adaptor's next_position and prev_position, the set iterator's two steps, guard on is_zero_width
rather than asking the storage. The
exclusive scans take a position as a precondition and a zero width has none to give, so they assert
there.
Each step also states its own precondition beside the storage's. Forward, that this is not end(),
which is what the scan's is_valid comes to. Backward is the one worth having, because it is
stronger than anything below it: exclusive_find_prev asserts any() and is_valid(n - 1), and
--begin() passes both while there is nothing below to find. Measured under NDEBUG, without the
guard: at a two-block extent it falls into the arm meant for the lower block and answers the highest
position there, which is the key it started from, so a reverse walk never ends; at four blocks and at
a run-time width it reads past the blocks.
What [set]'s synopsis leaves out for a packed set¶
The [set] and [container.requirements] clauses under test/src/spec/ assert [set]'s synopsis
declaration by declaration, with std::set<std::size_t> as the model. Three families are left out, each for a reason the packing
gives:
node_type,extract,insert(node_type&&)andmerge: there is no node. A position is a bit in a block, so there is nothing to unlink and hand over, and nothing to relink.- the
template<class K>heterogeneous overloads: they participate only whereCompare::is_transparentis valid, andkey_compareisstd::less<key_type>orstd::greater<key_type>here as it is on thestd::setmodels. Neither side has them, so asking would hold the model to a line the model does not answer either. The owners declared withstd::less<>orstd::greater<>do have them, and are checked againststd::setunder the same transparent comparator outside the clauses. insert_rangeand the from_range constructors:[set.cons]'s C++23 lines, kept apart so the model can be held to them where its standard library has them (__cpp_lib_containers_ranges).
pointer and const_pointer are dropped from the [associative.reqmts] typedefs for the same
reason as the nodes: packed bits have no address.
AddressSanitizer aborts where the allocator throws¶
test/include/test/sanitizer.hpp guards the rows that assert a width past what the blocks can hold
reaches the allocator. AddressSanitizer answers an allocation it will not serve by aborting, where
the C++ allocator answers with std::bad_alloc, so on a sanitized build the process is gone before
the catch. Measured on this tree: the report is allocation-size-too-big, and
allocator_may_return_null=1 only renames it to out-of-memory, because the throwing operator new
calls ReportOutOfMemory on a null return rather than throwing.
Every other leg answers those rows, which is measured too: the nine failures that led to the guard were all sanitized builds or a discarded temporary an optimizer elided, and the msvc and mingw legs, which are neither, never failed on them at all.
Declare anything a guarded block needs inside it. The clang legs compile with -Weverything
-Werror, so a variable named outside a block that is the only thing using it is an unused-variable
error on exactly the legs the guard is for.
Why the byte-exchange question is a template¶
test/include/test/bit_exchange.hpp asks whether a type constructs from (xstd::from_blocks, b), and converts
to and from B through xstd::bit_convert. There is no trait for those, so they are concepts.
Being a template is not incidental. A bare requires-expression over concrete types puts a non-dependent requirement in the immediate context, where GCC reports an unsatisfied constraint as a hard error rather than as false — which is precisely what an assertion of the form "this is NOT admitted" must not be.
[array.tuple] does not divert std::format¶
A std::tuple_size specialization is what makes a type tuple-like, and it does not divert
std::format. [format.tuple]/1 provides the tuple formatter "for each of pair and tuple", naming
the two class templates rather than admitting tuple-like types, and [format.range.fmtkind] never
asks tuple_size_v<R> — it asks R::key_type, and tuple_size_v of the reference type for the map
case alone. std::array is the proof by example: tuple-like, a range, and it prints as a range. The
bracket assertions in test/src/bits/detail/format.cpp are what pins this.
The try-doors return what the draft spells¶
P3981R0 changed try_push_back and try_emplace_back to return optional<reference> once P2988R12's
optional<T&> was adopted; libstdc++ 16 still returns the pointer P0843R14 gave them. The checklist
asks the model for the name, and TheTryDoorsReturnTheOptionalReferenceTheDraftSpells asks the
packing for the signature the draft spells.
There is no try_append_range, on purpose. P0843R14 gave inplace_vector a third try-door that
appended as many elements as fit and returned an iterator to the first one left over. P3981R0 proposed
to return a borrowed_subrange_t instead, and the LEWG discussion of that change led to P4022R0
(Revzin, Wakely, Kamiński, February 2026), which removes the member from C++26 altogether. Its two
reasons are the ones any packing would inherit. A partial insertion is neither a success nor a failure,
so the try_ name promises a contract the function does not keep. And a returned subrange converts
to bool the opposite way round from the optional<reference> of the other two doors: truthy means
something was left over, not that the call succeeded. The paper defers the design to C++29.
The current draft has no try_append_range, so the inplace owners have none either. libstdc++ 16
still ships P0843R14's version, and the suite asks it of no implementation, the model included.
When C++29 settles what the member means, it is added here and tested in the same change.
Why a checklist spells tuple_size<C>::value¶
modernize-type-traits asks for tuple_size_v<C>, and a concept asked of types without tuple_size
cannot take it. A checklist is asked of types that fail it — that is the whole of what it is for — and
the two spellings fail differently.
tuple_size<C>::value is a nested name, so for a C with no tuple_size at all the substitution
fails in the immediate context and the constraint answers false. tuple_size_v is a variable
template whose initializer instantiates outside the immediate context, and the same C is a hard
error no requires-expression can catch. Measured rather than assumed: the _v spelling turns
not array_tuple_element<C> into "incomplete type std::tuple_size<C> used in nested name
specifier".
The tuple_element half has no such problem and the rewrite is taken — an alias template substitutes
transparently, so its failure stays in the immediate context. The [array.tuple] clause puts tuple_size
only to types that have it, so it takes std::tuple_size<T> whole, as the base it must derive from, and
needs neither spelling.
[array.tuple]'s element half also only exists for a non-empty array: tuple_element<I, array<T, N>>
Mandates I < N, so element zero is a question that cannot be put to a width of nought — on the
packing or on std::array itself.
Why the byte exchange is named rather than spelled as a conversion¶
from_blocks and xstd::bit_convert take a field of bits in and hand one out, at the one extent where the
question has a single answer: a static width is a capacity under the set reading and the other side's
own width both, so position n here is bit n there. Nothing truncates, nothing grows, nothing
throws, and the round trip is the identity in both directions.
They are named rather than spelled as a conversion, which is the one thing explicit could not
buy. A contiguous range of unsigned integers already means something at this reading: from_range
reads it as a range of keys. So the same argument had two meanings a tag apart, and both compiled:
bit_fixed_set<256>(std::from_range, blocks) // {0, 5} -- the values are keys
bit_fixed_set<256>(blocks) // {0, 2} -- the values are blocks
explicit guards against a conversion nobody asked for. It does nothing about a reader misreading
one that was asked for, and that is the failure available here. A name does: from_blocks says which
reading of the argument is meant, at the call site, where the reader is. std::bitset spells its own
exit to_ullong for the same reason, Boost spells this pair from_block_range and to_block_range,
and the way out is xstd::bit_convert, one free function for every pair of widths.
They are named by a concept rather than by a type. std::bitset appears nowhere in them, which is
the point: what these two admit is an unsigned integer or a sequence of them, whose layout the
language and the sequence state between them, or a field of bits read on std::bit_cast's terms —
trivially copyable, on a little-endian target, with room for N bits. So std::bitset<N> rides in on
the same rule as unsigned long long, and a big-endian target or a type too small for N is not
admitted: a call that fails to compile rather than one quietly wrong.
The integer family is the one a set reader can still misread, and the name is what answers it:
bit_fixed_set<32>(xstd::from_blocks, 5u) is the set of positions the value five has, {0, 2}, and not the
set {5}.
What each storage says for a key it cannot hold¶
Every other member of the set reading is total over key_type: contains, count, find,
lower_bound, upper_bound, equal_range and erase(key) all answer for a key past the width
rather than refuse the question. The two that write cannot — there is nowhere to put it — and the
three storages differ because the reasons differ:
| storage | for a key past the width |
|---|---|
| dynamic extent | grows to admit it, and past max_size() says std::length_error |
| inplace extent | grows within its capacity, and past it the blocks say std::bad_alloc |
| static extent | says std::out_of_range, as std::bitset<N>::set does for a position past N |
A domain, a capacity and a representable size — three different limits, and none of them silent. The
dynamic width's ceiling is asked at this reading rather than at the storage, which has none of its
own, so the set reading keeps length_error where boost::dynamic_bitset answers bad_alloc.
The static owner's equality is written out¶
A static owner's equality is its one member's: every instance carries the same width, so the arms
have nothing to choose between. It is written out rather than defaulted because a defaulted
comparison compares the bases first, and the base is allocator_base_type, an empty class whose own
defaulted operator== can only answer true. That call is a branch no input can send the other way,
and an unreachable branch is a hole in a coverage gate that admits no test. Saying the member outright
is the same comparison with nothing dead in it.
The sequence reading's byte exchange, and why it declines a window¶
Byte j holds the positions [8j, 8j + 8) least significant bit first, so a fixed width over the
same positions agrees byte for byte with any other and the exchange is a copy rather than a walk.
It is named rather than spelled as a conversion for the reason the set reading gives: a sequence of
unsigned integers is one argument with two readings. This reading cannot hit the from_range
collision itself — its from_range wants can_grow, and anything that can grow has a dynamic extent,
which turns the exchange off — but one door with two spellings across two readings would be worse
than either spelling alone.
The vocabulary is the thing to read twice at this reading: bit_array<32>(xstd::from_blocks, 5u) is a packed
array of bool — true, false, true, then twenty-nine more false — and not the set {0, 2} that the
same bits spell one reading over.
Not through the storage's bytes on a window, which is the whole of why is_window is asked. A window
is a bit offset and a size of its own into storage it does not span: its position zero is not the
storage's, so its bytes are not the storage's bytes and reading them would hand back the wrong ones. A width
test does not catch it, since a window over a static container reports the container's extent rather than
its own size, so bit_convert asks is_windowed and reads a window from its offset, a block at a time,
as a run-time width. A view that is not a window spans the whole container, so its bytes are that
container's and it converts through them.
Two places where the coverage gate decides the layout¶
front() and back() are both preconditions in [sequence.reqmts], and back()'s is the one that
subtracts: on an empty sequence offset() + size() - 1UZ wraps, and the reference handed back names a
position no storage has. Each assert is spelled over four lines rather than one, because gcovr
excludes an assert by a pattern anchored at the start of a line.
The static owner's defaulted operator== is kept on a single line for the mirror-image reason:
gcovr's --exclude-unreachable-branches matches the line carrying = default;, and gcov anchors a
defaulted comparison's branches at the declaration's first line. Split across lines — which
clang-format will do to any such declaration long enough to wrap — the exclusion stops matching and
the dead base comparison fails the 100% branch gate.
modernize-avoid-c-style-cast on owns(Store)¶
clang-tidy 23 points at the Store in owns(Store) and offers to rewrite it as a static_cast, having
read the call as a C-style cast of a parenthesized type. There is no cast on that line.
owns is a function — [[nodiscard]] constexpr auto owns(storage) -> bool, in ownership.hpp — and it is
the only entity that name denotes, so owns(Store) can only be a call, Store being a non-type template
parameter rather than a type. The same owns(Store) is written at sixteen other
sites in this library and none of them is flagged; what is particular about this one is the
namespace-scope variable template, whose initializer is value-dependent until instantiation.
It is suppressed rather than respelled: writing Store == storage::owned instead would inline the one
function that exists so nobody has to.
The storage's own measurements¶
blocks_for is total over every size_t¶
How many blocks a run-time width needs, none at width zero. It is said as boost's calc_num_blocks says
it — divide, then round up by the remainder — because that cannot overflow, where
align_up(n, bits_per_block) adds first and wraps for the 63 widths above max_width, rounding them
to zero blocks that a one-block floor would then turn into one. A guard against that wrap is a guard against a
spelling; this spelling has nothing to guard. It is public because that totality is the claim, and a
static_assert is the only way to make it without asking an allocator for two exabytes.
Equality over the shared prefix¶
ranges::equal over the shared prefix, not over the two block ranges: on two sized ranges it compares
size() first and answers false without looking at an element, which is the one case this asks about.
Taking the prefix as an iterator pair keeps the answer and gets the algorithm, which lowers to a
memcmp on trivially comparable contiguous blocks where all_of over a zip stays an element loop —
2.15us to 1.29us over 4700 blocks. The other two block walks cannot follow: bit_includes and
bit_intersects do bitwise work per block and have no such algorithm.
The saturating sum, and whose ceiling it is¶
Base positions and count more, saturated at the top of size_t rather than wrapped: the one addition
every growth is spelled through. A wrapped sum is small, so it passes the ceiling it was meant to fail
and then sizes the blocks for far fewer positions than the operation goes on to write; a saturated one
fails that ceiling, which is what an unrepresentable width should do.
The ceiling itself is not the storage's, because the counterparts disagree about it. std::vector
throws length_error for a size it cannot represent, and the sequence reading says so. The set
reading refuses a key past the widest it could grow to. boost::dynamic_bitset has no ceiling at all
— a width it cannot hold reaches the allocator and answers bad_alloc.
Two max_size answers sit over the same blocks: the storage's, clamped to the whole blocks a size_t
counts, and std::vector<bool>'s, clamped further still, a random access range's positions being counted
by a difference_type.
The block span, and what an assertion per block costs¶
operator[] as a range rather than one block at a time. The write side carries the subscript's write-side
contract once for the range; the read side carries nothing and exists one build short of the write
side. operator[] asserts its index, and an assertion per block is a loop the vectoriser leaves alone.
A Release build never sees it — a loop over the blocks reaches the memcpy floor there by itself —
but an assert-on build pays 3.4x for a bounds check on an index the caller just produced in order.
erase_unused asks the value what the other arm asks the compiler¶
At a static width the if constexpr settles it. At a run-time width, whether the last block has a
tail to erase is a property of a width the type is not given until it runs, so the arm has to ask the
value. Without the test the mask runs on every call at every width, and where the width is an exact
multiple of the block it is a read-modify-write that changes nothing: flip at 128 bits measures
7.8ns without it against 2.3ns with, and boost, which has the same if, measures
2.3ns.
Why the byte exchange is shifts, and where it is a copy¶
Byte j holds the positions [8j, 8j + 8), least significant bit first, which is what every
contiguous bit container lays them out as whatever its block width. Said in shifts and not a
memcpy, for three reasons at once: the answer does not depend on the order a block stores its own
bytes in, both directions stay constexpr where a memcpy is not, and the arithmetic is the same on
every block width, so two widths over the same positions agree byte for byte.
On a little-endian target the byte a position lands in does not depend on the block width, so
there the shifts and a straight copy of those bytes are the same answer and only one of them is a byte
at a time. Over the eight kilobytes of 2^16 positions, measured in one process so the ratio is the
machine's own: 9.70us by shifts against 0.07us by memcpy, a factor of about a hundred and
forty-five. The shifts answer the two cases a copy cannot — a constant expression, where memcpy does
not exist, and a big-endian target, where those bytes are not these blocks.
The one unreachable line¶
is_valid's empty case is called only from an assert, and a zero-width bit_block_container
has no member that reaches one. It is not removable either: MSVC's /W4 rejects a bare n < N as
always false (C4296). It carries a trailing GCOVR_EXCL_LINE, which drops that one line from the
denominator rather than counting it as reached.
Growing with ones takes the tail before the blocks grow¶
The tail above size() in the last block is clear by the invariant, and becomes the first new bits.
Which block and which bits is read off the old width, so it is taken before the blocks grow; the
write itself comes after, because m_blocks.resize is what refuses a count these blocks cannot hold,
and a refused growth that had already dirtied the tail would leave the storage with a width it no
longer matches.
Measured on blocks that hold three: refused at a width of 25, the next resize(20) came back with
every bit above 9 set and count() at 8 where 1 was set.
Why blocks_for's totality is asserted at compile time¶
The claim is that blocks_for is total: the widths that would wrap ask for more blocks than the
blocks will ever hold. Asserting it by growing to such a width instead asks std::allocator for
2^61 bytes, which is not a question two of this tree's CI legs will answer — a sanitized build
aborts on a request that size rather than reporting std::bad_alloc, and an optimizer may drop
the new/delete pair of an unused temporary altogether, so the request is never made and nothing
is thrown. Both were measured on exactly these assertions.
A static_assert is stronger besides: it names the block count rather than inferring it from an
exception.
The widths in question are the sixty-three above max_width, where n + bits_per_block - 1 overflows
to a sum below bits_per_block, the division rounds it to zero blocks, and the floor turns that
into one — a container claiming SIZE_MAX positions in eight bits. Dividing first, each of them
asks for one block more than the widest whole number of them, which is a count no allocator will
serve. That is also where every saturated sum arrives, the two composing.
sizeof is always a multiple of alignof¶
The width slot is a size_t, or the blocks' alignment where that is wider, and the whole is then
rounded up to the class's own alignment. That last step is not slack in the layout test: a sizeof
is always a multiple of an alignof, so the sum alone names sizes no class can have. Blocks of four
bytes under a size_t width sum to twelve, and twelve is not a size a type aligned to eight can be;
sixteen is, and sixteen is what the class already was. Written without the round-up, the assertion
asks the inplace column for the impossible, which only a leg compiling that column over such blocks reports.
Design choices for a bitset data structure¶
"A
bitsetcan be seen as either an array of bits or a set of integers. [...] Common usage suggests that dynamic-lengthbitsetsare seldom needed."Chuck Allison, ISO/WG21/N0075, November 25, 1991
The above quote is from the first C++ Standard Committee proposal on what would eventually become std::bitset<N>. The quote highlights two design choices to be made for a bitset data structure:
- a sequence of
boolversus an ordered set ofint; - fixed-size versus variable-size storage.
Thirty years of use have added a value to each axis. The first choice has a third answer that the quote itself takes for granted — a bitset that offers both readings on purpose, which is what std::bitset and boost::dynamic_bitset actually are, and which this library declines (interop-not-a-bitset). And the second is not one choice but two, because size and capacity need not move together: fixing both gives std::bitset, letting both vary gives std::vector<bool>, and the pairing the quote had no word for is a dynamic size over static capacity, which allocates nothing and yet resizes, and which C++26's std::inplace_vector finally makes expressible. Both tables below are laid out on that one axis, so they read cell for cell.
A bitset should also optimize for both space (using contiguous storage) and time (using CPU-intrinsics for data-parallelism) wherever possible.
The current bit landscape¶
The landscape is three readings crossed with three storages, so it is a three-by-three, and the C++ Standard Library and Boost between them fill six of its nine cells:
| static size and capacity | dynamic size, static capacity | dynamic size and capacity | |
|---|---|---|---|
ordered set of std::size_t |
— | — | std::set<std::size_t> std::flat_set<std::size_t> |
sequence of bool |
std::array<bool, N> |
std::inplace_vector<bool, N> |
std::vector<bool> |
bitset |
std::bitset<N> |
— | boost::dynamic_bitset<> |
Three cells are empty and three more are filled by something of the wrong representation, which are two different complaints:
std::setandstd::flat_setare sparse: a whole element per actual element, where a dense set spends a single bit per potential one.std::array<bool, N>andstd::inplace_vector<bool, N>are unpacked: a whole byte perbool, eight times what the bits need.std::vector<bool>is the one standard sequence that packs, and it is the one everybody regrets.
Notes:
- Both
std::bitsetandboost::dynamic_bitsetare not clear about the interface they provide. E.g. both offer a hybrid of sequence-like random element access (throughoperator[]) as well as primitives for set-like bidirectional iteration (using non-Standard GCC extensions_Find_firstand_Find_nextin the case ofstd::bitset). That ambiguity is why they have a row of their own here rather than appearing in one of the two above it: abitsetis a width of bits that has not said how it is to be read. - It has been known for over two decades that providing a variable-size sequence of
boolthrough specializingstd::vector<bool>was an unfortunate design choice. - For ordered sets, there is a further design choice whether to optimize for dense sets or for sparse sets. Dense sets require a single bit per potential element, whereas sparse sets require a whole element per actual element. The break-even is therefore the element's width: at a 64-bit
std::size_t, if less (more) than 1 in 64 elements (1.5625%) are actually present, a dense representation will be less (more) compact than a sparse one — 1 in 32 (3.125%) if the sparse set stores 32-bit integers instead. std::inplace_vectoris C++26 and itsboolis not a specialization: P0843 declined to repeatvector<bool>, so that cell is occupied but unpacked.- Only
boost::dynamic_bitsetallows storage configuration through itsBlocktemplate parameter (defaulted tounsigned long). - The
Block,AllocatorandCompareparameters are omitted from both tables. They configure a storage; they do not place a container in the landscape, and spelling them out obscured the one axis the columns are measuring.
A reimagined bit landscape¶
The aforementioned issues can be resolved by implementing a single-purpose container for each cell of the design space. This library fills every cell of the first two rows, every one of them dense and packed, leaves the bitset row to the standard and Boost, and adds a row that the other table has no answer to at all. Every name below is in namespace xstd, so only the foreign ones carry a qualifier:
| static size and capacity | dynamic size, static capacity | dynamic size and capacity | |
|---|---|---|---|
ordered set of std::size_t |
bit_fixed_set<N> |
bit_inplace_set<N> |
bit_set |
sequence of bool |
bit_array<N> |
bit_inplace_vector<N> |
bit_vector |
| a reading of blocks | bit_set_view / bit_span over std::array<Block, K> |
over std::inplace_vector<Block, K> |
over std::vector<Block> |
The columns are the three storages the one underlying vehicle is parameterized on — std::array fixes size and capacity, std::inplace_vector varies size within a fixed capacity, std::vector varies both — so a cell is a reading crossed with a storage, and nothing else. The outer two columns are the ones the current landscape already has; the middle is the pairing it never named.
The first two rows are containers, one per cell, and the third is not a container at all. Blocks are a width of bits that has not said how it is to be read, and this row is how you say which reading you meant, which is the whole of what retrofitting means. You do not have to adopt a container to get a reading; you point a view at the bits you already have. There is no bitset row: std::bitset<N> and boost::dynamic_bitset<> keep their cells of the table above, and a bitset crosses into either reading by copying its blocks (interop-not-a-bitset).
Notes:
- Each container is clear about the interface it provides: sequences are random access containers and ordered sets are bidirectional containers.
- The
bitsetrow is gone rather than reproduced.std::bitsetandboost::dynamic_bitsetare faulted above for being unclear about which interface they offer; the answer here is to make choosing a reading explicit at the call site and to keep no hybrid of our own.xstd::bit_convert<xstd::bit_fixed_set<N>>(bs)andxstd::bit_convert<xstd::bit_array<N>>(bs)read astd::bitset<N>as a set and as a sequence, and a run-time width is planned to cross throughxstd::bit_convert. - A view over an owner is a view over the storage that owner wraps:
xstd::bit_set_view(s)over abit_fixed_set<N>deducesxstd::bit_set_view<std::array<std::size_t, K>, N>, the blocks and width the set is stored in, anddecltypeis how you name the result. The deduction guide for a plain storage is constrained to non-owners, so an owner and the storage inside it do not tie. - The variable-size sequence of
boolis namedxstd::bit_vectorand decoupled from the generalstd::vectorclass template. - All containers use a dense (single bit per element) representation. Variable-size sparse sets can be provided by
flat_set, either in Boost or in C++ 23. - The names above are the short ones, which fix
Blocktostd::size_tand so take only the width, or nothing at all in the dynamic column where there is no width to give. Each has abasic_form that leaves the block open, and for a set the key before it:xstd::basic_bit_fixed_set<Key, Block, N>,xstd::basic_bit_array<Block, N>and their inplace siblings, and down the dynamic columnxstd::basic_bit_set<Key, Blocks>andxstd::basic_bit_vector<Blocks>, which leave the whole storage open. Soxstd::bit_setis an alias, not a template, andxstd::basic_bit_set<std::size_t, std::vector<std::uint8_t>>is how a block is chosen. - Every static-width name takes
xstd::bit_align, a type transformation rounding its width up to whole blocks so that no block carries an unused tail:xstd::bit_align<xstd::bit_array<120>>isxstd::bit_array<128>. The inplace names take it too, rounding their capacity. That costs nothing in storage at a width already spanning whole blocks, and removes the tail-restoring mask fromfill,flipand the left shift. - Every static-width name also takes
xstd::bit_leastandxstd::bit_fast, which keep the width and store it in the smallest or the fastest block that holds it, asstd::uint_least8_tandstd::uint_fast8_tdo:xstd::bit_least<xstd::bit_fixed_set<9>>is two bytes, astd::uint16_t, and past 64 bits it is severalstd::uint64_t.xstd::bit_enum_set<E>isbit_leastover the enumeration's fixed set. Least then align,bit_align<bit_least<X>>, is the compact form with no unused tail (block-and-width-transformations).
The middle column is what allocates nothing and yet carries a run-time width. Its blocks are a std::inplace_vector where the standard library provides one (__cpp_lib_inplace_vector) and a boost::container::static_vector elsewhere, so those two names exist everywhere; only over std::inplace_vector are they usable in a constant expression, which XSTD_BITS_HAS_CONSTEXPR_INPLACE says.
Every cell is cheap; the cells come in four tiers. One bit_block_container over three storages is what
makes the fixed and inplace cells nearly free here: each is the same adaptor over std::array or
std::inplace_vector blocks, a header and its tests rather than a design of its own. That is a reason to provide
them, not to standardize them all at once, should the committee prefer to add the cells in steps:
- Tier 1, a bit equivalent for each existing standard header:
bit_array,bit_inplace_vectorandbit_vectorbesidestd::array,std::inplace_vectorandstd::vector<bool>,bit_setbesidestd::set, andbit_valarraybesidestd::valarray. - Tier 2, where
std::bitset<N>code migrates:bit_fixed_settakes a bitset used as a set of positions (migration.md), andbit_fixed_valarraythe bitwise operators of one combined whole (migration.md). - Tier 3, enumerations and flags:
bit_enum_setandbit_flag_setoverbit_key_mappingandbit_flag_mapping, the fixed set keyed by an enumeration or a bitmask type (flag-types). - Tier 4, the remaining storages:
bit_inplace_setandbit_inplace_valarray, and the small-buffer columns inext/boost/, which have neither a standard counterpart nor bitset code to receive. Or, in their place, the storage made pluggable (#434): aBlocksparameter on eachbasic_form under a public concept, asstd::flat_settakes aKeyContainer, with every column one default of it. Then a Tier 4 name is an alias over a standardbasic_form, so the committee may decline to name it and lose nothing: a user writes the alias.
No tier depends on a later one, and each later cell is one more storage under the same adaptor.
Ownership is deliberately not a fourth column. A view is not a fourth storage: it takes the shape of whatever it views, which is why the third row spans the same three columns as the two above it rather than standing beside them. xstd::bit_subspan is the one that stays out of the table, because it narrows a sequence to a window rather than choosing a reading. All of them are described under retrofitting below.
Requirements for set-like behaviour¶
Looking at the above code, the following four ingredients are necessary to implement the Sieve of Eratosthenes:
- Bidirectional iterators
beginandendin theset's own namespace (to work with range-forand the<ranges>library); - Constructors taking a pair of iterators or a range (in order for
std::ranges::toto construct aset); - A nested type
key_type(in order forstd::formatto use{}delimiters: [format.range.fmtkind] picksrange_format::setfor a range that has one); - A member function
eraseto remove elements (for other applications: the rest of aset's interface).
xstd::bit_fixed_set<N> implements all four of the above requirements. Note that Visual C++ support is finicky at the moment because its <ranges> implementation cannot (yet) handle the xstd::bit_fixed_set<N> proxy iterators and proxy references correctly.
Choosing a reading over blocks¶
Blocks are neither a set nor a sequence: they have no begin of their own that reads bits, because begin is one
name and there are two readings. xstd::bit_set_view and xstd::bit_span are how you say which you meant.
auto blocks = std::array<std::uint64_t, 2>();
auto const s = xstd::bit_set_view(blocks); // the set reading of those bits
s.insert(42); // sets bit 42 of blocks[0]
assert(s.contains(42));
assert(std::format("{}", s) == "{42}"); // formats as a set
The view supplies what the blocks lack: bidirectional iterators, a nested key_type,
insert/erase/contains, and the set predicates. xstd::bit_span does the same for the sequence reading,
and xstd::bit_subspan for a window into one. A view reads the blocks themselves, so it reaches the
block-parallel paths rather than reading a position at a time. A std::bitset<N> hands out no blocks, so it is
read by copying instead: xstd::bit_convert into an owner of the reading you mean.
This is why ownership is not a fourth column of the table above: a view is not a fourth kind of container, it is the same readings pointed at storage someone else owns.
Printing¶
The snippets above use std::format, and std::print works the same way, with nothing to include beyond <format> and nothing to switch on:
std::print("{}\n", primes); // {2, 3, 5, 7, 11, ...} the set reading, in braces
std::print("{}\n", flags); // [false, true, ...] the sequence reading, in brackets
std::print("{::#x}\n", primes);
Each proxy reference carries its own std::formatter, so a container that hands the proxy out brings the formatter with it. Nothing is specialized for a container: every one of them is already a range, so [format.range.formatter] formats it once its reference is formattable. The braces-versus-brackets split is the standard's, not ours — [format.range.fmtkind] picks range_format::set for a range with a key_type — so each reading prints in its own vocabulary without being told to. Each proxy also keeps a hidden-friend format_as, which is fmt's own per-type hook, so a consumer who formats with fmt gets the same output without this library depending on fmt to build or to test. The two hooks cannot drift: the std::formatter reads the value by calling format_as, rather than reaching for it a second way of its own.
Data-parallelism¶
The filter_twins above walks the primes one at a time, because that is all std::set can do. A dense container can answer the same question a block at a time, without iterating at all. A prime is a twin exactly when it has a neighbour two away, so shifting the whole set by two in each direction and intersecting gives every twin in a handful of instructions per block:
template<class X>
auto filter_twins_parallel(X const& primes)
{
return primes & (primes << 2 | primes >> 2);
}
That is the same set the loop produces, and on a bit_fixed_set<128> it is four shifts, two ors and an and over two blocks — no iterator, no branch per element, no comparison. The elementwise form remains the one in examples/include/opt/set/sieve.hpp, because it is the form std::set and std::flat_set can also run and the benchmark needs all three on the same algorithm.
The shifts read the way they do because of the bit layout: element 0 is the least significant bit of the first block, so << moves toward larger elements. What it does not do is make the set order the bitstring order — under this layout those are two different walks over the same blocks, and the FAQ below draws both.
which has as output:
{2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97}
{3, 5, 11, 17, 29, 41, 59, 71}
Sequence of bits¶
What blocks lack for the set reading to be built on top of them is iterators. xstd::bit_set_view supplies them: bidirectional begin/end (and cbegin/cend/rbegin/rend/crbegin/crend) plus a nested key_type, so blocks format like a set out of the box — as the snippet under choosing a reading shows. It reads the storage a block at a time rather than a position at a time, which is what makes the scan block-parallel.
Documentation¶
The interface for the class template xstd::bit_fixed_set<N> is the coherent union of the following building blocks:
- An almost drop-in implementation of the full interface of
std::set<int>. - An almost complete translation of the
std::bitset<N>member functions to thestd::set<int>naming convention. - The single-pass and short-circuiting set predicates from
boost::dynamic_bitset, as the free algorithmsxstd::ranges::bit_includes,xstd::ranges::bit_intersectsandxstd::ranges::bit_disjoint. - The bitwise operators from
std::bitset<N>andboost::dynamic_bitsetreimagined as composable and data-parallel set algorithms.
The full interface of xstd::bit_fixed_set is constexpr.
1 An almost drop-in replacement for std::set<int>¶
xstd::bit_fixed_set<N> is an ordered set of integers over a static width, providing conceptually the same functionality as std::set<int, std::less<int>, Allocator>, where Allocator statically allocates memory to store N integers. In particular, xstd::bit_fixed_set<N> has:
- No customized key comparison:
xstd::bit_fixed_setusesstd::less<int>as its fixed comparator (accessible through its nested typeskey_compareandvalue_compare). In particular, thexstd::bit_fixed_setconstructors do not take a comparator argument. - No allocators:
xstd::bit_fixed_setis a set of non-negative integers over a static width and does not dynamically allocate memory. In particular,xstd::bit_fixed_setdoes not provide aget_allocator()member function and its constructors do not take an allocator argument. Its allocating counterpartxstd::bit_setdoes provide both — the allocator follows the storage column, not the set reading. -
No splicing:
xstd::bit_fixed_setis not a node-based container, and does not provide the splicing operations as defined in p0083r3. In particular,xstd::bit_fixed_setdoes not provide the nested typesnode_typeandinsert_return_type, theextract()ormerge()member functions, or theinsert()overloads taking a node handle. -
No container exchange:
std::flat_sethands its underlying container out withextract() &&and takes one back withreplace(container_type&&), which is how you build one cheaply and how you get the sorted vector back out.xstd::bit_fixed_sethas neither name, and has the capability twice over — see thefrom_blocks/bit_convertbullet below, and the comparison in design.md.
Minor semantic differences between common functionality in xstd::bit_fixed_set<N> and std::set<int> are:
- the
xstd::bit_fixed_setmember functionmax_sizeisconstexpr, and at a static width its value is a constant expression usable whereverNis. It is a member rather than astaticmember function becausestd::set's is a member: each reading takes the shape its own counterpart spells, which is why the sequence reading's middle column has astaticone instead —[inplace.vector.capacity]spells all four ofcapacity,max_size,reserveandshrink_to_fitstatic there, andxstd::bit_inplace_vector<N>::capacity()answers without an object accordingly (design.md#max-size-is-the-bits). So for the set readings.max_size()is a constant expression anddecltype(s)::max_size()does not compile. - the
xstd::bit_fixed_setiterators are proxy iterators, and taking their address yields proxy references. The difference should be undetectable. See the FAQ at the end of this document. - every key at once is
~xstd::bit_fixed_set<N>(), the complement of the empty set, wherestd::bitsethasset(). -
xstd::bit_fixed_set<N>exchanges bits with any field ofNbits through a named pair, the tagged constructorbit_fixed_set<N>(xstd::from_blocks, b)andxstd::bit_convert<B>(s), which also crosses with anything else that has bit storage, not through an untagged constructor or a conversion operator. What they admit is named by a concept rather than by a type: an unsigned integer or a sequence of them, whose layout the language and the sequence state between them, or a field of bits thatbits::detail::bit_layoutreads onstd::bit_cast's terms, trivially copyable on a little-endian target with room forNbits — sostd::bitset<N>rides in on the same rule asunsigned long long, and a big-endian target or a type too small forNfails to compile rather than converting quietly. The widths are the sameNand a static width is a capacity under this reading, so positionnhere is bitnthere: nothing truncates, nothing grows, nothing throws, and the round trip is the identity. It isconstexprat every width, and a copy rather than a walk over positions.A name rather than a conversion, because the integer family is the one a set reader can still misread, and only a name answers it at the call site, where the reader is:
bit_fixed_set<32>(xstd::from_blocks, 5u)is the set of positions the value five has,{0, 2}, not the set{5}(design.md#the-bytes-they-agree-on).from_blocksis a constructor on an owner;bit_convertalso reads a set view, which spans a whole container and so has that container's bytes. The run-time-widthxstd::bit_sethas nofrom_blocks, astd::bitsetnaming oneNthat a growing set has no single value for; it crosses throughbit_convertalone, which carries the width along.
With these caveats in mind, all static-width, defaulted comparing, non-allocating, non-splicing std::set<int> code in the wild should continue to work out-of-the-box with xstd::bit_fixed_set<N>.
2 An almost complete translation of std::bitset<N>¶
Almost all existing std::bitset<N> code has a direct translation (i.e. achievable through search-and-replace) to an equivalent xstd::bit_fixed_set<N> expression, with the same and familiar semantics as std::set<int> or boost::flat_set<int>.
std::bitset<N> |
xstd::bit_fixed_set<N> |
Notes |
|---|---|---|
bs.set() |
bs = ~decltype(bs)() |
the complement of the empty set |
bs.set(n) |
bs.add(n) bs.insert(n) |
out_of_range past N, where std::bitset throws it too |
bs.set(n, v) bs[n] = v |
v ? bs.add(n) : bs.pop(n) |
out_of_range past N on the insert; the erase is total |
bs.reset() |
bs.clear() |
returns void as std::set<int>, not *this as std::bitset<N> |
bs.reset(n) |
bs.pop(n) bs.erase(n) |
total over the key: erasing what is not there is the no-op returning zero |
bs.flip() |
bs = ~bs |
not an operator of std::set<int> |
bs.flip(n) |
bs.erase(n) if bs.contains(n), else bs.insert(n) |
out_of_range past N, as the insert it is |
bs.count() |
bs.size() |
|
bs.size() |
bs.max_size() |
constexpr; a constant expression at a static width |
bs.test(n) bs[n] |
bs.contains(n) |
total over the key: a position past N is one the set does not hold |
bs.all() |
bs.size() == bs.max_size() |
|
bs.any() |
not bs.empty() |
|
bs.none() |
bs.empty() |
The semantic differences between xstd::bit_fixed_set<N> and std::bitset<N> are:
xstd::bit_fixed_set<N>answersmax_size()as aconstexprmember, wherestd::bitset<N>answers the same question withsize();xstd::bit_fixed_set<N>splits its members by what[set]can promise. Asking is total:contains,count,find,lower_bound,upper_bound,equal_rangeanderase(key)all answer for a key outside[0, N)— it is a key the set does not hold, which is an answer and not a precondition violation, exactly asstd::set::findreturnsend()for any key it does not hold. Writing is not:inserthas nowhere to put such a key, and throwsout_of_rangeasstd::bitset<N>does for a position pastN. Leaving it undefined for a performance benefit does not survive measurement: on the sieve atN = 2^16, best of twenty-five, the guard costs nothing — 188.0µs against 188.1µs, and 75.1µs against 75.1µs over 65536 inserts — because the comparison is against a compile-time constant and never taken.
Functionality from std::bitset<N> that is not in xstd::bit_fixed_set<N>:
- No string constructors and no
to_string: a bit string is a string reading's vocabulary, planned asbit_string, andstd::bitset's until then. The set reading declines it as it declines the rest of that vocabulary, and crossing costs one call either way:xstd::bit_convert<std::bitset<N>>(s).to_string(), andxstd::bit_convert<xstd::bit_fixed_set<N>>(std::bitset<N>(str))back. What a set prints as itself is{2, 3, 5}, which is printing above. - No integer constructor and no integer conversion operator: here what is missing is the language's unnamed doors and not the capability. The byte exchange does both under a name,
xstd::bit_fixed_set<32>(xstd::from_blocks, 5u)andxstd::bit_convert<unsigned>(s)— and it is named for exactly the reason a constructor would be the wrong spelling:bit_fixed_set<32>(xstd::from_blocks, 5u)is the set of positions the value five has,{0, 2}, and not the set{5}. A constructor cannot say which of those it meant; a name can. - No I/O streaming operators:
operator<<andoperator>>are[bitset.operators]'s, and go withto_string. A set formats instead, in its own vocabulary.
Formatting a set needs nothing beyond the standard library: std::format and std::print take it in its own vocabulary, as printing above shows. A consumer who formats with {fmt} instead gets the same output through the proxy's hidden-friend format_as, and pays for that hook without this library depending on fmt either to build or to test. Hashing is not on that list: std::hash<xstd::bit_fixed_set<N>> is specialized, over Boost.Hash2, and every value this library compares it also hashes, so a == b implies hash(a) == hash(b) under every reading (design.md#the-hashing-invariant). The hash_append hook is there beside it, for a caller wanting an algorithm other than the defaulted fnv1a_64.
3 Set predicates from boost::dynamic_bitset¶
The set predicates is_subset_of, is_proper_subset_of and intersects from boost::dynamic_bitset have identical semantics in xstd::bit_fixed_set, spelled through the free algorithms xstd::ranges::bit_includes and xstd::ranges::bit_intersects, named after std::ranges::includes and a nonempty std::ranges::set_intersection and taking their arguments in that order. Note that these set predicates are not present in std::bitset. Efficient emulation of these set predicates for std::bitset is not possible using single-pass and short-circuiting semantics.
xstd::bit_fixed_set<N> |
boost::dynamic_bitset<> |
std::bitset<N> |
|---|---|---|
xstd::ranges::bit_includes(b, a) |
a.is_subset_of(b) |
(a & ~b).none() |
xstd::ranges::bit_includes(b, a) and not xstd::ranges::bit_includes(a, b) |
a.is_proper_subset_of(b) |
(a & ~b).none() and a != b |
xstd::ranges::bit_intersects(a, b) |
a.intersects(b) |
(a & b).any() |
4 The bitwise operators from std::bitset and boost::dynamic_bitset reimagined as set algorithms¶
The bitwise operators (&=, |=, ^=, -=, ~, &, |, ^, -) from std::bitset and boost::dynamic_bitset are present in xstd::bit_fixed_set with identical syntax and identical semantics. Note that the bitwise difference operators (-= and -) from boost::dynamic_bitset are not present in std::bitset. The operator- can be emulated for std::bitset using the identity a - b == a & ~b.
The bitwise-shift operators (<<=, >>=, <<, >>) from std::bitset and boost::dynamic_bitset are present in xstd::bit_fixed_set with identical syntax and identical semantics: a shift by N or more empties the set, as it resets a std::bitset<N>, where a native unsigned integer has undefined behaviour. The guard costs nothing at a constant distance, which is how move generators shift a board: the compiler proves n < N and the code is the unguarded shift. At a run-time distance it is one compare and a conditional move on a single block, and on two or more blocks the known bound lets the compiler simplify the splice, so the guarded shift is the shorter code.
With the exception of operator~, the non-member bitwise operators can be reimagined as composable and data-parallel versions of the set algorithms on sorted ranges. In C++23, the set algorithms are not (yet) composable, but the range-v3 library contains lazy views for them.
xstd::bit_fixed_set<N> |
std::set<int> with the range-v3 set algorithm views |
|---|---|
xstd::ranges::bit_includes(b, a) |
std::ranges::includes(b, a) |
a | b, xstd::ranges::bit_set_union(a, b) |
ranges::views::set_union(a, b) | std::ranges::to<std::set>() |
a & b, xstd::ranges::bit_set_intersection(a, b) |
ranges::views::set_intersection(a, b) | std::ranges::to<std::set>() |
a - b, xstd::ranges::bit_set_difference(a, b) |
ranges::views::set_difference(a, b) | std::ranges::to<std::set>() |
a ^ b, xstd::ranges::bit_set_symmetric_difference(a, b) |
ranges::views::set_symmetric_difference(a, b) | std::ranges::to<std::set>() |
The bitwise shift operators of xstd::bit_fixed_set<N> can be reimagined as set transformations that add or subtract a non-negative constant to all set elements, followed by filtering out elements that would fall outside the range [0, N). This can also be formulated in a composable way for std::set<int>, albeit without the data-parallelism that xstd::bit_fixed_set<N> provides.
| xstd::bit_fixed_set<N> | std::set<int> |
|---|---|
auto b = a << n;
|
auto b = a
| std::views::transform([=](auto x) { return x + n; })
| std::views::filter ([=](auto x) { return x < N; })
| std::ranges::to<std::set>()
;
|
auto b = a >> n;
|
auto b = a
| std::views::transform([=](auto x) { return x - n; })
| std::views::filter ([=](auto x) { return 0 <= x; })
| std::ranges::to<std::set>()
;
|
Frequently Asked Questions¶
Iterators¶
Q: How can you iterate over individual bits? I thought a byte was the unit of addressing?
A: Using proxy iterators, which hold a pointer and an offset.
Q: What happens if you dereference a proxy iterator?
A: You get a proxy reference: ref == *it.
Q: What happens if you take the address of a proxy reference?
A: You get a proxy iterator: it == &ref.
Q: How do you get any value out of a proxy reference?
A: They implicitly convert to int.
Q: How can proxy references work if C++ does not allow overloading of operator.?
A: Indeed, proxy references break the equivalence between functions calls like ref.mem_fn() and it->mem_fn().
Q: How do you work around this?
A: int is not a class-type and does not have member functions, so this situation never occurs.
Q: Aren't there too many implicit conversions when assigning a proxy reference to an implicitly int-constructible class?
A: Yes, copy-initialization C c = *it; takes two, which is one too many; direct-initialization C c(*it); takes one and works. A proxy reference converts only to its value type, as std::vector<bool>::reference does, because a conversion to every class constructible from that type made comparisons ambiguous wherever such a class declares its own operator==.
Q: So iterating over an xstd::bit_fixed_set is really fool-proof?
A: Yes, xstd::bit_fixed_set iterators are easy to use correctly and hard to use incorrectly.
Bit-layout¶
Q: How is xstd::bit_fixed_set implemented?
A: bit_fixed_set uses a std::array of unsigned integers, so its storage goes wherever the object does. That is true of the static-width column only: the inplace column holds a std::inplace_vector or a boost::container::static_vector inline, and the dynamic column a std::vector. All three are the same storage vehicle over a different container, which is an implementation detail rather than a name you reach for.
Q: How is a set value mapped onto the array's bit layout?
A: Position n is bit n % W of block n / W, for a block of W bits. So the least significant bit of the first array block maps onto set value 0, and the most significant bit of the last array block onto set value N - 1. That is the conventional layout: boost::dynamic_bitset and the mainstream std::bitset and std::vector<bool> implementations all lay their bits out the same way.
Q: I'm visually oriented, can you draw a diagram?
A: Sure, it looks like this for basic_bit_fixed_set<std::size_t, std::uint8_t, 16>, each block drawn most significant bit first:
| block | 0 | 1 |
|---|---|---|
| offset | 76543210 | 76543210 |
| value | 76543210 | FEDCBA98 |
Q: Why that layout, and not the mirrored one?
A: Because it is very nearly forced. It is not a convention this library follows for the sake of following one, and there are four reasons, of which the first is not really negotiable at all.
A one-block container must be its integer. std::bitset mandates the round trip bitset<N>(v).to_ullong() == v, and the language fixes the value side of it: bit n of an unsigned integer is 2^n. So position i of an integer read through from_blocks is (val >> i) & 1, bit_convert back to the integer is its inverse, and at one block the stored block is the integer, bit for bit, with no work at all. Mirror the layout and the standard's own round trip becomes a bit reversal in each direction — which is why design.md admits the integer family with nothing to prove, where a foreign field of bits is trusted on std::bit_cast's terms, and only on a little-endian target (design.md#the-bytes-they-agree-on).
The scan primitive is the layout. A forward step is bits_per_block * i + countr_zero(m_blocks[i]) — ctz and nothing else, because position n is bit n. A mask is unit << offset, one instruction. Mirror it and each grows a correction: digits - 1 - clz for the step, highbit >> offset for the mask, and a << that lowers to shr. That cost is visible here rather than hypothetical — the reverse scan pays exactly that subtraction, last_bit() - countl_zero(...), and mirroring would move it onto find_first/find_next, which is the set reading's common path.
Growth appends, so the numbering must append too. The dynamic column is a std::vector of blocks and push_back is resize(size() + 1, value), so a new position lands at bit size() % digits of the last block: the used region of that block grows upward from its low bits, the unused tail stays above it, and nothing already stored moves. That tail-above-the-used-bits invariant is what every block-wise comparison then leans on, a block reading the same as its positions with nothing above the width.
And the exchange is a copy only because everyone agrees. std::bitset, std::vector<bool> and boost::dynamic_bitset are all LSB-first, so a field of the same width agrees byte for byte and converts by memcpy rather than by reversing the bits of every byte. The per-byte path is measured at 9.70µs against 0.07µs over 2^16 positions, about a hundred and forty-five times; a mirrored layout would pay at least that on every conversion, in both directions, forever.
Q: Does that make little-endian mandatory too?
A: For this library's own containers, no. Positions are computed by shifts on block values, and b[j] >> k is the same number on either byte order, so a big-endian target is correct and merely takes the shift path where a little-endian one copies.
For interop, yes, and both doors close at once. The block-range family names std::endian::native == std::endian::little in its own constraint, and so does the field-of-bits family, because std::bit_cast hands back the object representation rather than the value — where bit n of a word lands at the far end of it. So a big-endian build keeps every container and loses every conversion from a field of bits it did not lay out itself.
Q: Then how does set comparison stay block-parallel, if the set order is not the blocks' integer order?
A: It is two walks over the one layout, one per reading, each a block at a time rather than a position at a time:
| reading | walk | the rule it applies |
|---|---|---|
| ordered set | the blocks ascending, through first_difference |
whoever holds the lowest differing position is less — unless the other holds nothing above it, in which case that other is a prefix, and a prefix is less |
| sequence | the same primitive, ascending | whoever holds the lowest differing position is greater, position 0 being the sequence's first element |
Q: So a set's order is not its bitstring's order?
A: No, and that is why there are two functions rather than one (design.md#two-readings-disagree). The bit string orders the blocks from the top down, as std::bitset and boost::dynamic_bitset mean <; the two readings here both walk up from position zero and share first_difference, which keeps its own name on the storage rather than being called mismatch there.
Q: Didn't the first bitset proposal argue for the other layout?
A: It did, and this library does not follow it:
"bit-0 is the leftmost, just like char-0 is the leftmost in character strings. [...] This makes converting from and to unsigned integers a little counter-intuitive, but the string-ness (or "array-ness") is the foundation of this abstraction.
Chuck Allison, ISO/WG21/N0128, May 26, 1992
Bit-0 leftmost buys one thing: the bitstring order and the array order agree, which is worth having when a bitset is one container. Here it is two readings over one storage and they disagree about order whatever the layout, so that agreement was never on offer — where the copy the conventional layout buys is.
Storage type¶
Q: What storage type does xstd::bit_fixed_set use?
A: By default, xstd::bit_fixed_set uses an array of std::size_t integers.
Q: Can I customize the storage type?
A: Yes. The alias carrying the default is template<std::size_t N> using bit_fixed_set = basic_bit_fixed_set<std::size_t, std::size_t, N>; the underlying template<class Key, xstd::unsigned_integer Block, std::size_t N, bit_index_mapping<Key> KeyMapping = bit_key_mapping<Key>, class Compare = std::less<Key>> basic_bit_fixed_set requires the key and the block explicitly. Every cell of the table follows that pattern: a short name that fixes Block, and a set's Key, to std::size_t, and a basic_ name that does not.
Q: Can the block be chosen to fit the width?
A: Yes. xstd::bit_least<xstd::bit_fixed_set<N>> is basic_bit_fixed_set<std::size_t, least_block_t<N>, N>, one std::uint8_t, std::uint16_t, std::uint32_t or std::uint64_t up to 64 bits and several std::uint64_t past them, and xstd::bit_least takes any fixed-width owner, its key, mapping and direction kept. xstd::bit_fast picks <cstdint>'s fastest block instead, and xstd::bit_align rounds the width up to whole blocks; bit_align<bit_least<X>> does both, in that order.
Q: What other storage types can be used as template argument for Block?
A: Any type modelling the Standard Library unsigned_integral concept, which includes (for GCC and Clang) xstd::uint128.
Q: Does the xstd::bit_fixed_set implementation optimize for the case of a small number of blocks of storage?
A: Yes, there are three special cases for 0, 1 and 2 blocks of storage, as well as the general case of 3 or more blocks.
Future directions¶
The containers, the views, the valarray reading and the algorithms over them are the library as it stands; the string reading and, beyond it, regular expressions over bits are the next layer. Each entry here is designed and not built: what it would add, what it waits on, the decisions already taken that it has to respect, and what the analysis behind it concluded. Each names the open issue that tracks the direction, where the work and its discussion continue; an entry leaves this section for the one describing the code when it lands.
the-string-reading¶
std::bitset speaks three vocabularies, and the string is the one with no reading yet
(#195). What a string adds over a sequence is not order,
since both compare lexicographically, but search and concatenation: find, substr, starts_with, append
and +, which prefix codes, tries, CIDR prefixes and k-mers ask of a run of bits. It builds on the sequence
reading's algorithms, xstd::ranges::bit_find among them (algorithms-not-members), and calls
its word-parallel search as an algorithm rather than owning it. The decisions already taken:
- Five names, one per column:
bit_fixed_string<N>,bit_inplace_string<N>,bit_small_string<N>,bit_stringand the viewbit_string_view, on onestring_adaptorwith no window parameter. A borrowed string is always windowed, becauseremove_prefixandremove_suffixchange the view in place; an owned one never is. The iterator and reference are member templates of the adaptor over the character type, as the other readings nest theirs (the-adl-firewall), and an owner and its view have distinct iterator types. - Character 0 is position 0, the least significant bit of block 0, which is N0128's orientation and the
storage's one layout. Strings of different lengths share position 0, so
starts_withand<=>stay block-wise, andpush_backandappendwork at the high end as a sequence's do. The cost is at the interop boundary: a string converted from astd::bitsetreads reversed against itsto_string(), which the declarations of the string types and ofxstd::bit_convertstate. - The elements are symbols, not bits. The class template takes the element first, as the set owners take
their key, then the block, then the defaulted traits and allocator:
basic_bit_string<CharT, Block, Traits, Allocator>, withbit_stringasbasic_bit_string<char, std::size_t>.bit_char_traits<char>listssymbols = "01"in rank order and nothing else; the library derivessize,K = std::bit_width(size - 1)bits per character,from_index(r) = symbols[r]andto_index(c), the protocolsized_bit_index_mappingnames for keys (the-public-names). Symbols are distinct, checked bystatic_assert, and need not ascend, so".#"makes.rank 0. The reference converts to the symbol, so abit_stringyields'0'and'1'withstd::string's pitfalls included (if (s[0])is always true), andbit_vectorstays the reading whose elements are bits;bit_convertrelabels one as the other in O(1).bit_char_traitsis notstd::char_traits:[char.traits.require]works through pointers to stored characters one at a time, which would rule out the block algorithms. A user's alphabet is a user's traits; the DNA alphabet,symbols = "ACGT", is the K = 2 test case and appears only in tests and examples. <=>isstd::string's order. The first difference is found a block at a time, then the two symbols there are compared: the ranks directly where the symbols ascend, the sign flipped for two descending symbols, and one lookup in a table of at most 2^K entries otherwise. Display symbols are a run-time argument toto_string,text,copyand the formatter,s.to_string('.', '#')or"{:/.#}"; element symbols are part of the type, so a maze never silently equals a code word.- Characters are validated where they enter. Every constructor,
assign,append,insertandreplacetaking characters throwsstd::invalid_argumentoutside the symbols, asstd::bitsetdoes, and is explicit, asstd::bitset's string constructor is. Parsing packs eight characters per step, the mirror of a block-wiseto_string(), andbit_string(t).to_string() == tfor every validt. bit_fixed_string<N>follows P3094'sstd::basic_fixed_string, still in LEWG for C++29 and audited against its latest revision: structural, the length-preserving half of the interface, staticintegral_constantsize members (constant-sizes),+givingN + M, andbit_convertto and frombit_array<N>. The search members and the default constructor, all'0', are recorded as departures with reasons."0110"_bsis abit_stringand"0110"_bsvabit_string_viewof static extent 4 into the template parameter object, both validated at compile time, and abit_fixed_stringtemplate parameter makesxstd::bit_search<"1011">(text)a compile-time pattern without a suffix.[string.capacity]decides capacity in every column, not the sequence row's inplace rules: the inplace string throwsstd::length_errorpastN, asboost::static_string<N>does, and has notry_orunchecked_doors.+on two inplace strings returnsbit_inplace_string<N + M>, as Boost, Folly and P3094 all do, and never throws;+=andappendkeep the capacity. A smaller capacity converts implicitly and a larger one explicitly, checked. The small and dynamic strings keepstd::string's+.- Declined:
c_stranddata, a bit having no address and two symbols no terminator;resize_and_overwrite;[string.conversions];[char.traits]; and shifts and bitwise operators, which[strings]does not have.copy(char*, n, pos)is kept, since it writes text through the symbols into the caller's buffer, and it is the way out tofolly::FixedString,boost::static_stringandchar[]alike. - Search is an algorithm over the sequence reading,
xstd::bit_searchafterstd::ranges::search, sobit_spanandbit_vectorreach it too, and the string'sfind,rfindandcontainscall it. For a pattern of up to 64 characters, one mask per block asks the question for all 64 start positions at once,M &= P[i] ? Ti : ~Tiover funnel-shifted text, which is O(n·m/64) andconstexpr; a longer pattern confirms the first 64 characters' candidates block by block. A worst-case guarantee (Two-Way) and SIMD wait for a measurement that asks for them. The oracle isstd::string::findover the same text in'0'and'1'; the baselines arestd::string::find,std::searchoverstd::vector<bool>, the Boyer-Moore searchers, glibcmemmem, StringZilla and CTRE'sctre::search. - Testing has one oracle. Each case is written once, as characters:
"0110"sis thestd::stringand"0110"_bsthe bit string, every operation runs on both, and nothing translates symbols in between. The audits are[string.view]first and then[basic.string], about 42 leaf subclauses between them, with the declined clauses recorded as such (the-audit). The counterpart isstd::stringrather thanstd::basic_string, whosecomparegoes throughtraits::comparea character at a time.
The opening section plans string_reading_tag as a refinement of sequence_reading_tag, so that every view and
algorithm taking a sequence takes a string. A sequence's elements are bools and a string's are symbols, so the
first header settles whether a sequence algorithm reads a string's bits or the refinement stands aside. A
multi-bit alphabet, DNA's "ACGT" at two bits, is the same template at K > 1, addressing its storage as a
sequence of K-bit elements does. The work runs in this order: bit_char_traits; the adaptor with
bit_string, bit_string_view and the formatters; bit_fixed_string and the two literals; the inplace and small
strings; search, staged as above; compile-time patterns; the audits; and the string row in the README's tables.
Prior art for the inplace and small strings is in prior-art-by-column.
bit-mdspan¶
A bit matrix, std::mdspan over packed bits, adds a dimension to the views row rather than a reading
(#467): bitboards, adjacency matrices, parity-check matrices,
cellular automata and binary images are its workloads. [mdspan.accessor.reqmts] lets an accessor's reference
be other than element_type& and its data_handle_type other than element_type*, so the accessor is the
sequence reading's proxy and iterator: access(p, i) is *(p + i) and offset(p, i) is p + i. The constraint
on reference, a common reference with bool&, is the one std::indirectly_readable already imposes on the
iterator, so the accessor carries no new proof obligation.
The layout is the alignment argument one dimension up. layout_right unpadded starts row i at bit i · cols,
so unless cols is a whole number of blocks every row straddles blocks and every row-wise operation takes the
funnel-shift path; layout_right_padded (C++26) starts each row on a block boundary for at most a block less one
bit per row, which a 64-square board does not pay at all. Padded, a row is a contiguous run of whole blocks, and
so a bit_span over the same storage: the sequence reading and the algorithms apply row-wise as they stand, and
the matrix algorithms worth having (Warshall's closure, Gaussian elimination over GF(2), neighbour counts by
shifted rows) are or, xor and and of two rows. A column is a strided run of single bits, one test per row, and
no packing changes that, so an interface should not present a column as a row's peer.
Open before the first header: whether row(i) returns a rank-1 mdspan through submdspan, a bit_span, or
both; which extents come first, a static 8 × 8 board or a dynamic matrix; whether [mdspan.copy]'s algorithms
go through the accessor or assume element_type*; and which ladders ship the padded layouts. There is no owning
counterpart, since the working draft has no mdarray: the view sits over an owner someone else holds, as the
views row already does. GF(2) linear algebra on top of it is
#404.
k-bit-elements¶
An element wider than one bit is a parameter of a reading, not a reading of its own
(#197). A generic packed adaptor would commit to no
vocabulary and would bring back the layer of synthesized scans that one-storage deleted, and its two
motivating cases land on different readings anyway: an endgame table of unknown, loss, draw and win is a
sequence, and a DNA string over ACGT is a string. At one bit per element the sequence row is bit_vector; at
eight bits per byte-sized element it is std::vector<unsigned char>, byte for byte on a little-endian target.
std::vector<bool> is the one-bit point of that axis, concealed inside vector<T>; declared as a point on it, it
is just a container. sdsl::int_vector<t_width> ships the sequence row,
and seqan3::bitpacked_sequence
derives the width from the alphabet, which is the refinement to take. Neither has a string reading or a second
reading over the same bits.
The decisions already taken: the width comes from the element's index mapping, bit_width(size - 1), so it needs
no template parameter, and the letter K stays a count of blocks; each element is stored least significant bit
first; the proxy converts to the element type as the bool proxy converts to bool; <=> exists only where the
mapping's order is a total value order, so the endgame table, whose unknown makes it partial, has == alone; and
the bitwise operators drop out by constraint above one bit. The set reading takes no width, since a dense set is
one bit per potential element, and the valarray reading stays bool.
Open before the first header: whether the element and its mapping join the existing owners' parameter lists,
defaulted to bool, or name a separate family, since basic_bit_vector<Blocks, N> changing touches every
alias, every rebind and bit_rebind and bit_resize; whether a width that does not divide the block straddles
two blocks, as sdsl::int_vector<9> does, or pads each block, with power-of-two widths first because they do
neither; and the one kernel that does not exist yet, a SWAR field compare for count and find over fields. The
string row waits for the-string-reading.
selectable-small-storage¶
The small column's storage is boost::container::small_vector, and absl::InlinedVector and llvm::SmallVector
keep up to N elements inline the same way (#264). The
owners are named by what the user gets, so bit_small_set and bit_small_vector would keep their names whichever
implementation backs them. The inplace column picks its storage from what the standard library provides, so every
translation unit agrees without configuration; several small-vector libraries can be installed at once, so here
the choice is the user's, and it has to hold across the whole program.
The design: one CMake cache variable, XSTD_BITS_SMALL_VECTOR, takes boost, absl or llvm, defaults to
boost, and becomes a compile definition on the xstd::bits INTERFACE target, so every target linking the
library compiles with the same choice. Only the selected implementation's namespace is declared, inline, as
xstd::with_boost, with_absl or with_llvm, so users write xstd::bit_small_set and the mangled name carries
the implementation: two libraries built with different settings fail to link rather than violate the ODR. The
namespaces are not named boost, absl or llvm, which would hide ::boost inside xstd. Boost.Container stays
required in every case, for the inplace fallback and the dynamic_bitset adapter. The container as a template
parameter, std::flat_set's shape, is not the alternative: owned-bit-storage records why
each owner fixes its storage by its name.
Open before the first header: llvm::SmallVector's growth path is defined in LLVMSupport, a link dependency on
a header-only library's INTERFACE target, so whether with_llvm is worth carrying or Boost and Abseil ship first;
llvm::SmallVector takes no allocator, so its owners have no allocator_type, as the fixed and inplace owners
have none; whether absl::InlinedVector and llvm::SmallVector satisfy the owned and resizable storage concepts
as they stand; and each storage's swap, moves and == deduce different exception specifications, which the
tests assert per implementation. CI runs one leg per implementation, with vcpkg features for the two new ones.
a-deterministic-perf-gate¶
Coverage is gated at 100% of lines and branches and time at nothing: the benchmarks run under ctest as smoke
tests (the-sieve), so a change that halved a container's speed would pass every check
(#185). Wall clock cannot be the gate. Over the full
200-row ladder, pinned to one CPU with nine interleaved repetitions on a freshly booted machine, the median
coefficient of variation is 10.7%, the 90th percentile 17.2%, and five rows of 200 come in under 5%; filtering
for ratios that clear two sigma of the rows' combined variation leaves almost nothing. Hosted runners are worse:
two to four shared vCPUs, no PMU, and a CPU model that changes between runs. Three gaps of 1.15x to 1.3x that the
ladder reported did not survive one-side-per-process isolation, and one changed sign when the same code was linked
into another binary.
The gate is instruction counts under callgrind. The same binary on the same input retires the same count to
the instruction, which supports a 1% threshold with no false positives. Each comparison builds the merge base and
the head in one job and compares the ratio; wall clock stays informational, reported with its variation beside
every median, as "no result" above a threshold, with /proc/cpuinfo recorded and no comparison across CPU models.
A count says that something changed and not what it costs: three instructions that stall can be 9% of a count
and 240% of the time, so a claimed win is confirmed on real hardware, and absolute timings need a self-hosted
runner with isolcpus, SMT and turbo off. what-a-view-costs is the method applied once.
The harness has four traps on record: valgrind 3.22 raises SIGILL on the AVX-512 vpternlogq that
-march=native emits, so the gated binary builds at -march=x86-64-v3; a harness must assert that it ran, by
valgrind's exit status and by counts that scale with N, and measuring at N and kN and differencing cancels
startup exactly; GCC folds a naive loop into a closed form, so the operation sits between asm volatile sinks on
both sides; and code layout moves wall clock between binaries, which is one more reason to compare ratios inside
one. The results would publish as a generated file naming machine, compiler, standard library, resolved -march
and date, for the README and this file to cite instead of figures whose provenance is a sentence. Whether the gate
lives here or in a separate repository that pins the library by SHA and builds two SHAs per job is open.
spec-tests-over-archetypes¶
The spec suite checks values thoroughly and checks that ill-formed uses are rejected, each
static_assert(not requires { … }) beside the clause that makes the use ill-formed
(#273). Three things the standard libraries' own suites cover are still to come:
- Archetypes. The range constructors,
insert(first, last),assign(first, last),from_rangeandinsert_rangeover single-pass input iterators, forward iterators and sized sentinels, handing in exactly what the clause asks and no more; the allocator-aware owners over a minimal allocator, with only the members[allocator.requirements]makes mandatory, and over a fancy-pointer allocator, besidestd::allocatorandstd::pmr::polymorphic_allocator; and keys and values used for no more than[associative.reqmts]and[sequence.reqmts]ask.test::minimal_blocksis the storage's archetype already (owned-bit-storage). - A constant-evaluation driver. The
XSTD_CONSTEXPR_CHECKfamily turns one expression into astatic_assertand a run-time check that cannot drift apart. It runs over the deterministic part of each input builder, edge cases and width-gated enumerations, while the seeded random part stays at run time; its_IFforms gate thestatic_asserton the columns usable in a constant expression, the fixed owners, the dynamic ones through a transient allocation, and the inplace ones whereXSTD_BITS_HAS_CONSTEXPR_INPLACEis defined.std::vector<bool>andstd::arrayareconstexprand serve as models there;std::setandstd::flat_setare not, so the set reading asserts each clause's postconditions directly. Enumerations count against-fconstexpr-stepsand-fconstexpr-ops-limit, so this driver may need a lower width cap than the run-time one. requires (I < N)onbit_array'stuple_element. The adaptor's two specializations carry it, while the twobasic_bit_arrayforwards to them do not, so an index out of range is a hard error a requires-expression cannot observe, and[array.tuple]/1stays a declined row of the audit. With the constraint on the forwards it is answered.
the-next-audit¶
The type-matrix table, test/src/bits/type_matrix.cpp, answers whether every operation compiles, or is refused on
purpose, for every owner and view over every block (#377).
Three parts remain:
- Fast-path assertions for the dispatch predicates that have none:
plane_is_its_stringanddense_planes, which send a range of planes to the hash in oneupdate, andstructural_blocks_v, which picks the public base that makes an owner structural. Each gets astatic_assertover the types meant to take the path and the types meant not to, asblit_source,block_copies_as_bytesandadopts_fromhave, so a path silently not taken fails a build rather than only a benchmark. - A doc-drift check: a script, run in CI beside the include-order checks, that every backticked identifier in
README.mdand this file still names something underinclude/or in the standard. - The
doc/audit/inventories re-run against the current owners, the inplace ones (a capacity of nought,no_blocks) and the small ones (rebindable and resizable) first.
a-module-interface¶
The library is consumed by inclusion, and import xstd.bits; does not exist
(#194). Header-only is a real choice, one find_package, no
build order, no module cache to invalidate, and the direction is allowed to conclude that a module is not worth
having. The cheap form is the only one to try first: a single xstd.bits interface unit that includes the
existing headers in its global module fragment and exports the names, leaving every header where it is,
#include working as before, and the change revertible. Partitions mirroring the header tree wait on a
measurement of the cheap form.
It waits on three things. Which of the seven ladders can build and consume a module interface at all, which is
the least uniform corner of the toolchains; a measured clean-build cost for a translation unit that includes the
umbrella, so that a speed-up is a number; and xstd-ints and xstd-misc, which this library includes and which
record the same direction, so the three would move together rather than one importing two that are still
included. A consumer path that imports would be a fourth leg of consumption.yml, and the include-what-you-use
comments, written about inclusion, do not map onto exported names mechanically.
hardened-preconditions¶
A release build that links a hardened standard library -- libstdc++ with _GLIBCXX_ASSERTIONS, which several
distributions build with, libc++ in any hardening mode but none, or the MSVC STL with _MSVC_STL_HARDENING -- traps
on std::vector<bool>::operator[] past the end. NDEBUG would turn every assert here off in the same build, so
moving to bit_vector would quietly trade a trap for undefined behaviour
(#427). detail/assert.hpp closes that gap with one macro.
XSTD_BITS_ASSERT(cond) is assert(cond) wherever assert is on, and where NDEBUG is defined but the standard
library is hardened it is still checked, ending in std::abort() as libstdc++'s assertions do, without the
message. XSTD_BITS_HARDENED is the switch, detected from the three libraries' own macros, and a definition of
it on the command line wins in either direction.
The macro guards only what the hardened libraries guard on their own containers, so that the move costs nothing
and buys nothing beyond parity: operator[], front(), back() and pop_back(); erase at end() and a
reversed erase range; the iterator a caller passes to insert or erase, checked once in index_of; a view's
first, last and subspan; a valarray's operator[]; and a set's erase(end()). A set's keys need nothing,
being checked by an exception in every build. The rest stay assert: they are the storage's own invariants, which
no caller can break through the public interface, and checking them in release would charge every bit operation
for the library's bugs rather than the caller's.
test.hardened.precondition builds one program as a hardened release, whatever the build type, and runs it once
per guarded member, each run breaking that member's precondition. A SIGABRT handler turns the abort into the
pass, through std::_Exit, because CTest's WILL_FAIL inverts an exit code but not a crash. Two CI legs build the
whole suite as a release against a hardened library, libstdc++ with _GLIBCXX_ASSERTIONS and libc++ in its fast
mode, so the macro is also exercised where the library, not the command line, turned it on. The mechanism is an
interim one: under P2900 the same predicate is a pre, and hardening is the enforce semantic
(preconditions-as-contracts).
preconditions-as-contracts¶
The precondition doctrine is the storage's cheapest contract, widened by the caller that needs more
(the-cheapest-contract, total-versus-precondition), and its
mechanism is about ninety asserts, nearly half of them in detail/bit_block_container.hpp and most of the rest
in the sequence and set adaptors (#193). An assert states
that something held, not that a caller owed it, which is the one distinction the doctrine turns on, and NDEBUG
is one switch over a translation unit where the doctrine assigns costs per layer. A P2900 precondition says it on
the declaration, where a caller reads it, and its evaluation semantics are chosen per build.
It waits on P2900's availability across the ladders, measured with dates, and may well conclude "not yet". Three
questions are to be decided before any syntax: whether a partial migration, two vocabularies for one doctrine,
is worse than none, and so whether a mechanism that degrades to assert is wanted; the C++23 floor, since the
library compiles as C++23 and contracts in the interface move it; and what the checked build is for, legibility
alone if contracts are only ever ignored in release and enforced in tests. The pilot is one file,
bit_valarray/detail/valarray_proxy.hpp with four asserts, reviewed for whether it reads better, and the coverage gate needs an
exclusion for a contract annotation as it has one for an assert at the start of a line
(an-assert-begins-its-line).
the-bit-infrastructure-thesis¶
std::bitset was proposed in N0075, November 1991, three years before the STL entered the draft, so it was
designed before containers, iterators and algorithms had a vocabulary
(#192). It has no iterators and is no range, no
value_type, no allocator, no block parameter and no resize; its set reading exists only as the vendor
extensions _Find_first and _Find_next; and test throws where operator[] is undefined. The committee
patches it a paper at a time, P2417R2's constexpr, P2697R1's basic_string_view constructor, LWG 4294's charT
constraints, which is why the yardstick here is the working draft.
The thesis is bit infrastructure, containers and algorithms, in the vocabulary of the current draft, without a
performance compromise. Each clause is a kind of work: conformance, each container held to its counterpart's
clauses paragraph by paragraph (the-audit); algorithms over the containers, under their standard
names with a bit_ prefix (algorithms-not-members); and performance a gate can resolve
(a-deterministic-perf-gate), with preconditions-as-contracts
and a-module-interface as what "modern" asks besides. std::bitset itself is not
reproduced: its three vocabularies are the set, sequence and string readings, std::bitset's bitwise operators
on a row of bools are the valarray reading's, and a bitset crosses into any of them through xstd::bit_convert
(interop-not-a-bitset). Done means every owner and view held to its counterpart's full
clause, algorithms that agree with std::ranges on every input, and a number a reader can cite from a method that
tells a regression from noise.
bit-regex¶
Matching beyond fixed substrings, 1(0|11)*0 for a codeword shape or "a run of at least five ones", has no
standard counterpart worth tracking (#328). Over two
symbols a deterministic automaton can consume 8 or 16 text bits per step through a 256- or 65,536-entry table per
state, built at compile time after CTRE's model, the pattern a template argument. No maintained library offers
regular expressions over bits, and the demand that exists takes other forms: field-oriented matching (Erlang's
binary patterns, Kaitai Struct, BPF filters), SystemVerilog sequences, table-driven prefix-code decoders written
by hand per format, and biological motifs over packed nucleotides. So the likely value is narrower than a regex
engine, a constexpr automaton builder for prefix-code decoding or Erlang-style field matching, and which, if
either, is for a concrete user to decide. It needs the string reading's compile-time patterns and block-mask
search (the-string-reading), and its multi-bit alphabets for motifs over packed
nucleotides.
the-downstream-contract¶
This library, with xstd-ints and xstd-misc, is the foundation tabula and dctl are to be built on (#119). What their code shows, read rather than specified:
- The static row at two words. tabula's
valid_size, the bits a board needs after minimizing its padded embedding over the dihedral group and trimming it, is theNof a fixed set, and fifteen of the nineteen named boards land on exactly two 64-bit words. A position isPplanes ofNbits, between⌈log2 K⌉andKplanes forKsquare values, so each two-word operation runsPtimes per node of a search.NandPare compile-time constants, which is why the static column carries the workload, andconstexpris a requirement rather than a convenience: board masks are tables built at compile time. - A migration target. dctl's
core/board/mask.hppholds a board asxstd::bit_set_aligned<int, Board::bits()>, the ancestor ofbit_align<bit_fixed_set<N>>, and whatever that offered (max_size(),add(), the compile-time tables) the fixed set has to offer at least. - The sequence row at endgame-database scale. dctl's
egdb/build.hppstores results asstd::vector<int>, where a win-draw-loss database needs two bits a position; a slice is 10^9 bits and more, so the costs are a cache miss per random read, a sequential scan and a count over gigabytes, which the-sequence-ladder's access bench asks, and the allocator becomes the way to huge pages or a file-backed slice. A sequence of two-bit elements is the representation it points to.
Bit planes and a per-square array are transposes of one another: the move generator reads a plane, and a board
renderer, a FEN parser or a database index reads a square across the planes, which is a gather, the read side
of the valarray reading's mask_array (the-valarray-reading). All of this is
reconstructed from the two repositories; an architecture document covering the layer stack, the design axes and
the invariants governs, and this entry is reconciled against it when it is published. The valid_size count and the two file references are observations
of code and stand either way.
prior-art-by-column¶
The directions above draw on one inventory of third-party containers, classified by behaviour rather than by
name: fixed, an exact size at compile time; inplace, a size up to a compile-time capacity with no heap;
small, an inline buffer of N and then the heap; run-time-fixed, a size chosen once at construction; and
dynamic, a heap that grows. The block choice is
compared in block-and-width-transformations and the enum and flag sets in
flag-types. Every signature was read from the header at the linked commit; these are references for
design comparison, not test oracles.
Bit containers with a selectable or surprising column: llvm::BitVector is small despite its name, its words in a
SmallVector of six inline words on a 64-bit target, and llvm::SmallBitVector keeps at most 57 bits inline;
llvm::PackedVector<T, BitNum, BitVectorTy> packs BitNum bits per element over a selectable bit vector;
eastl::bitvector<Allocator, Element, Container> takes its container and eastl::fixed_bitvector is small;
boost::dynamic_bitset<Block, AllocatorOrContainer> takes a container since 1.90; roaring::Roaring and
bm::bvector are compressed dynamic sets with rank and select; and sdsl::bit_vector is int_vector<1>.
| fixed | signature | notes | source |
|---|---|---|---|
base::fixed_flat_set (Chromium) |
template <class Key, size_t N, class Compare = std::less<>> using fixed_flat_set = base::flat_set<Key, Compare, std::array<const Key, N>> |
immutable and sorted, exactly N, built by consteval MakeFixedFlatSet |
fixed_flat_set.h |
frozen::set |
template <class Key, std::size_t N, class Compare = std::less<Key>> class set |
immutable constexpr sorted set |
set.h |
ctll::fixed_string (CTRE) |
template <size_t N> struct fixed_string { char32_t content[N]; size_t real_size; } |
a template-argument string; real_size, at most N, is set at construction |
fixed_string.hpp |
Poco::Array |
template <class T, std::size_t N> class Array |
a std::array look-alike |
Array.h |
| inplace | signature | past the capacity | source |
|---|---|---|---|
boost::container::static_vector |
template <typename T, std::size_t Capacity, class Options = void> class static_vector |
boost::container::bad_alloc by default, which is std::bad_alloc only under BOOST_CONTAINER_USE_STD_EXCEPTIONS; with throw_on_overflow<false>, an assert and otherwise undefined |
static_vector.hpp, options.hpp |
boost::static_strings::basic_static_string |
template <std::size_t N, typename CharT, typename Traits = std::char_traits<CharT>> class basic_static_string |
std::length_error, and std::out_of_range for a bad position |
static_string.hpp |
folly::BasicFixedString |
template <class Char, std::size_t N> class BasicFixedString |
std::out_of_range, a compile error in constant evaluation |
FixedString.h |
folly::small_vector with policy_in_situ_only<true> |
template <class Value, std::size_t RequestedMaxInline = 1, class Policy = void> class small_vector |
std::length_error |
small_vector.h |
eastl::fixed_vector, fixed_string, fixed_set with bEnableOverflow = false |
as in the small table | EASTL_ASSERT in debug builds, otherwise undefined or an allocation exception |
as below |
etl::vector |
template <typename T, const size_t MAX_SIZE_> class vector |
etl::vector_full with ETL exceptions on, otherwise the error handler |
vector.h, error_handler.h |
etl::string |
template <size_t MAX_SIZE_> class string |
truncates; optionally flags it or raises etl::string_truncation |
string.h, basic_string.h |
etl::flat_set |
template <typename T, const size_t MAX_SIZE_, typename TCompare = etl::less<T>> class flat_set |
ETL's error policy | flat_set.h |
itlib::static_vector |
template <typename T, size_t Capacity> class static_vector |
std::out_of_range by default, configurable |
static_vector.hpp |
FixedVector (Godot) |
template <class T, uint32_t CAPACITY> class FixedVector |
logs an error and leaves the vector unchanged | fixed_vector.h |
seastar::circular_buffer_fixed_capacity |
template <typename T, size_t Capacity> class circular_buffer_fixed_capacity |
a ring buffer; unverified | circular_buffer_fixed_capacity.hh |
| small | signature | notes | source |
|---|---|---|---|
llvm::SmallVector |
template <typename T, unsigned N = CalculateSmallVectorDefaultInlinedElements<T>::value> class SmallVector |
the default N aims for sizeof <= 64 |
SmallVector.h |
llvm::SmallString |
template <unsigned InternalLen> class SmallString : public SmallVector<char, InternalLen> |
SmallString.h | |
llvm::SmallSet |
template <typename T, unsigned N, typename C = std::less<T>> class SmallSet |
a linear SmallVector, then switches structure to std::set |
SmallSet.h |
llvm::SmallPtrSet, llvm::SmallDenseSet |
template <class PtrType, unsigned SmallSize>, template <typename ValueT, unsigned InlineBuckets = 4, typename ValueInfoT> |
a linear array or inline buckets, then a heap hash table | SmallPtrSet.h, DenseSet.h |
absl::InlinedVector |
template <typename T, size_t N, typename A = std::allocator<T>> class InlinedVector |
N > 0 enforced |
inlined_vector.h |
folly::small_vector |
template <class Value, std::size_t RequestedMaxInline = 1, class Policy = void> class small_vector |
std::length_error past max_size(); inplace under a policy, above |
small_vector.h |
folly::basic_fbstring |
template <typename E, class T = std::char_traits<E>, class A = std::allocator<E>, class Storage = fbstring_core<E>> class basic_fbstring |
23 char in place on a 64-bit target, then a medium and a reference-counted large form |
FBString.h |
boost::container::small_vector |
template <class T, std::size_t N, class Allocator = void, class Options = void> class small_vector |
small_vector.hpp | |
boost::container::basic_string |
template <class CharT, class Traits = std::char_traits<CharT>, class Allocator = void, class Options = void> class basic_string |
inline capacity set by string_options<inline_chars<K>>, clamped to 127; the first release shipping it unverified |
string.hpp |
eastl::fixed_vector, fixed_string, fixed_set |
template <typename T, size_t nodeCount, bool bEnableOverflow = true, typename OverflowAllocator> class fixed_vector, and alike |
bEnableOverflow switches small and inplace with one flag; fixed_set is a red-black tree over a node pool |
fixed_vector.h, fixed_string.h, fixed_set.h |
QVarLengthArray |
template <class T, qsizetype Prealloc> class QVarLengthArray |
Prealloc defaults to 256 |
qvarlengtharray.h |
mozilla::Vector |
template <typename T, size_t MinInlineCapacity = 0, class AllocPolicy = MallocAllocPolicy> class Vector |
out of memory is append returning false, not an exception |
Vector.h |
gch::small_vector |
template <typename T, unsigned InlineCapacity, typename Allocator = std::allocator<T>> class small_vector |
std::length_error past the maximum size |
small_vector.hpp |
itlib::small_vector |
template <typename T, size_t StaticCapacity = 16, size_t RevertToStaticBelow = 0, class Alloc = std::allocator<T>> class small_vector |
returns to the inline buffer below RevertToStaticBelow |
small_vector.hpp |
fmt::basic_memory_buffer |
template <typename T, size_t SIZE = inline_buffer_size, typename Allocator> class basic_memory_buffer |
inline_buffer_size = 500 |
format.h |
hpx::detail::small_vector |
template <typename T, std::size_t MinInlineCapacity, typename Allocator = std::allocator<T>> class small_vector |
MinInlineCapacity <= 127, the allocator unused |
small_vector.hpp |
TInlineAllocator, TFixedAllocator (Unreal) |
TArray<T, TInlineAllocator<N>> |
from the public documentation only; the declarations unverified, TFixedAllocator believed inplace |
TInlineAllocator |
| run-time-fixed, and set adaptors over a container | signature | column | source |
|---|---|---|---|
absl::FixedArray |
template <typename T, size_t N = kFixedArrayUseDefault, typename A = std::allocator<T>> class FixedArray |
run-time-fixed: inline up to N, or 256 / sizeof(T) by default, else the heap |
fixed_array.h |
folly::sorted_vector_set, small_sorted_vector_set |
template <class T, class Compare, class Allocator, class GrowthPolicy, class Container = std::vector<T, Allocator>> class sorted_vector_set |
follows Container; the alias is small, or inplace under a policy |
sorted_vector_types.h |
boost::container::flat_set, small_flat_set |
template <class Key, class Compare, class AllocatorOrContainer> class flat_set |
follows the container; no static_flat_set alias, so the inplace one is spelled out |
flat_set.hpp, container_fwd.hpp |
base::flat_set (Chromium) |
template <class Key, class Compare = std::less<>, class Container = std::vector<Key>> using flat_set |
dynamic by default; fixed_flat_set is its std::array form |
flat_set.h |
itlib::flat_set |
template <typename Key, typename Compare, typename Container = std::vector<Key>> class flat_set |
follows Container |
flat_set.hpp |
Not containers: frozen::basic_string is a constexpr view, comparable to std::string_view; POCO's Foundation
has no small, inplace or bit container; Bitsery serializes and defines none; Williams' enable_bitmask_operators
declares operator templates in the global namespace, which ADL does not search for an enumeration in any other
namespace, so an operator in the calling namespace hides them; Godot's BitField<T> is a typed flag word.
Names that collide. "Fixed" means inplace or small as often as it means an exact size: eastl::fixed_vector,
fixed_string and fixed_set are small by default and inplace without overflow, and eastl::fixed_bitvector is
small; absl::FixedArray is run-time-fixed; folly::FixedString and Godot's FixedVector are inplace;
ctll::fixed_string<N> stores at most N; only Chromium's fixed_flat_set is fixed in this library's sense.
"Static" means inplace in boost::container::static_vector, boost::static_string and itlib::static_vector.
Boost.Container uses "inplace" only for the inplace_alignment option. llvm::BitVector has no "small" in its
name and is small all the same, through its SmallVector; llvm::SmallBitVector is small at a fixed 57 bits;
llvm::SmallSet is small by structure rather than storage; and folly::small_vector turns inplace under a policy.
Hence names here take the standard's word where it has one, inplace from std::inplace_vector, and say what
the user gets where it has none, fixed (the-public-names).
SDSL, the encoding axis. SDSL (xxsds/sdsl-lite, the maintained fork of Simon Gog's original) is a family of types over bit vectors that varies a different axis: this library varies the interface and the storage over mutable containers held to the standard's clauses, and SDSL varies the encoding and the indexes attached to it, over sequences mostly built once and then queried.
| layer | types |
|---|---|
| packed integers | int_vector<t_width>, t_width == 0 choosing the width at run time; bit_vector is int_vector<1> (int_vector.hpp) |
| compressed bit vectors | rrr_vector (H0), sd_vector (Elias-Fano, for sparse sets), hyb_vector, bit_vector_il (rrr_vector.hpp, sd_vector.hpp) |
| rank and select | rank_support_v, rank_support_v5, select_support_mcl, separate objects over a constant bit vector (rank_support_v.hpp, select_support_mcl.hpp) |
| variable-length integers | enc_vector (Elias γ and δ), dac_vector, vlc_vector (enc_vector.hpp) |
| built on top | wavelet trees, compressed suffix arrays and trees, range-minimum queries (wt_int.hpp, csa_wt.hpp) |
int_vector<1> has this library's sequence layout and int_vector<K> that of a sequence of K-bit elements,
least significant bit first, and seqan3::bitpacked_sequence stores one, so exchanging blocks with either is a
matter of reaching their storage, not of converting. A rank_support refers to a constant bit vector and is
invalidated by any change, which is the model for a rank directory over a set's blocks: a view valid while the
set is unmodified, as an iterator is, rather than a member. Compressed
encodings, wavelet trees, compressed suffix structures and FM-indexes are SDSL's territory, and users who need
them are pointed there.