Design notes¶
Purpose¶
xstd is a header-only collection of small C++ standard-library extensions. There is no single proposal behind it: it collects facilities suggested by existing practice that can be implemented portably, with a standard-library interface. The baseline is C++23, and may move to C++26 once that is common across the tested toolchains. Consumers need no third-party dependencies.
Principles¶
- Prefer
constexpr. Value-oriented functions are usable during constant evaluation unless the standard library operation they delegate to prevents it. - Generalize integer support. The standard integral concepts and traits are
closed over built-in types; xstd's integer concepts also accept paired
integer-class types, based on their behavior and
xstd::numeric_limits. - Make semantics explicit. The four division functions name their rounding
convention rather than hiding it behind
/and%. - Stay modular and dependency-free. Linking
xstd::intsadds include paths and the C++23 requirement, but no runtime library or transitive package.
API shape¶
Integer types¶
P3701R0 supplies the integer,
signed_integer, and unsigned_integer vocabulary and its built-in arithmetic
boundary. The public concept is cv-transparent and accepts standard and extended
integer types except bool, char, wchar_t, char8_t, char16_t, and char32_t.
signed char and unsigned char remain integers, so int8_t and uint8_t
remain supported where they are aliases of those types.
Unlike the paper's closed standard-library domain, xstd also admits types that
model integer_class, and reaches the built-ins through that concept too: since
it admits them, a std::integral disjunct would only restate what it already
grants, and bool is excluded by failing it rather than by name. The rest of
the boundary is drawn the same way, by behaviour rather than by a list. Every
accepted type must have valid make_signed_t and make_unsigned_t
transformations, which rejects an unpaired integer-class type; those
transformations must compose back, each settled by signedness alone; and the
type must be one of its own pair. A character type is not -
make_signed_t<char> is signed char, a third type beside it - which is what
excludes the five of them without naming any. The refinements use xstd's open
signedness traits, preserving concept subsumption across built-in, extended,
bit-precise, and paired class integers.
Character conversion delegates char and a standard integer type to
std::to_chars when it is no wider than uint128, which is where both standard
libraries stop; every other integer takes xstd's own digit generation, and the
other character types are refused.
integer_class follows the requirement order in [iterator.concept.winc], but is
an open structural concept rather than an implementation-defined set. It admits
integer types of every width instead of requiring them to be wider than every
integral type, and it deliberately checks only same-type arithmetic rather than
mixed-mode operations or common types. Its range metadata comes from the open
xstd::numeric_limits, which delegates to the standard trait for every non-array
object type.
It does not exclude the built-in integers. The subclause's own note says an
integer-class type need not be a class type, so excluding them was xstd's
addition rather than the standard's, and it made the requirements unaskable of
the types they were modelled on instead of merely false for them. The closure
requirements in /7.3 and /7.6 are therefore stated against promoted_t rather
than T: identity for a class type, and for a built-in whatever [conv.prom]
yields, which is what the same requirement means once promotion is in scope.
Asked that way every standard integer satisfies the concept except bool, whose
++ and -- C++17 removed. promoted_t asks the language — decltype(+a) —
rather than modelling the rules, and only where the language decides: a
non-integral type is its own, so a user-defined operator+ cannot redefine its
promoted type into compliance.
That widening moves one boundary rather than removing it. is_signed,
is_unsigned, make_signed and make_unsigned each pair a standard-library
specialization with an integer_class one, and the two were disjoint only
because the concept excluded built-ins. Each now says not std::integral where
it means it — the "where std stops" line belongs to the trait that draws it, not
to the concept.
When the compiler defines __BITINT_MAXWIDTH__, as Clang does in C++,
<xstd/ints/cstdint.hpp> names the native types as bit_int<N> and bit_uint<N>.
They are aliases, not wrappers: their ABI, conversions, promotions, and operators
remain the compiler's. xstd specializes
its open limits and transformation traits for them, which makes paired widths
model integer even before the standard library recognizes the extension. The
specializations sit in the headers that declare numeric_limits, make_signed and
make_unsigned, not beside the aliases: _BitInt is a keyword, so a program can ask
about one having included nothing else, and a translation unit that asked before the
aliases' header would otherwise keep a different answer from one that asked after.
They state every member the width decides, digits10 included, which libstdc++ leaves
at zero and libc++ takes from sizeof; it is floor(digits * log10(2)) in 64.64
fixed point, exact at every width Clang offers. unsigned _BitInt(1) is its own
unsigned counterpart and has no signed one.
They reach it as integer_class on either standard library, though only libc++
makes _BitInt integral: that choice moves which arm promoted_t takes, and
both name _BitInt itself, since it is exempt from promotion. The public
domain currently begins at two because Clang has not yet implemented WG14 N3747's
signed _BitInt(1).
The <bit> counterparts follow the same split. Where the standard library's <bit> takes a
type, xstd forwards to it. Every other unsigned_integer — an integer class, or an
unsigned _BitInt(N) on libstdc++, whose <bit> declines it — is answered by one template per function
that works a std::size_t block at a time, the block being std::size_t because
[iterator.concept.winc] promises every integer-class type an explicit conversion to and from
it, and each block goes to the standard function, which knows its own instructions. The
128-bit classes get no overloads of their own: the template reaches them through their
operators, and being a template declared beside the forwarding overload, it is the same
overload set in every translation unit whatever order the headers come in, so bit_width,
written on countl_zero, never sees a different countl_zero for the same type. For
countl_zero, countr_zero and popcount on unsigned _BitInt(N), a more specialized
overload deduces N and calls the compiler's type-generic builtin, passing N as the count
for zero so that the builtin is total. It is constrained on the standard call being
ill-formed rather than on the library in use, so it steps aside on its own the day a
standard library learns the type. The builtins are younger than the type: Clang 19 added
them, where _BitInt reached C++ in Clang 14, so that overload sits behind __has_builtin,
and the block template answers where it is absent. byteswap alone also takes signed types,
as std::byteswap does, swapping through the unsigned counterpart, and asks for a whole
number of bytes rather than for the absence of padding bits that std::byteswap mandates,
since it swaps the value and not the object representation.
The four permutations from P3104R6 — bit_reverse,
bit_repeat, bit_compress and bit_expand — have no <bit> to defer to yet, so each is
one template over unsigned_integer beside an overload forwarding to the standard function,
declared once __cpp_lib_bitops reaches the working draft's 202607L and constrained, like
the counters, on the standard call being well-formed. bit_compress and bit_expand are not
forwarded to libstdc++ where xstd has fast BMI2: GCC trunk's implementation is Hacker's Delight's
parallel suffix for every type, about 28 ns a call on a Broadwell Xeon against 0.6 ns for
PEXT, so xstd keeps its kernels, and the Toolchain Canary's nightly libstdcxx_pext job fails once
GCC trunk starts emitting either instruction. The template works a block at a time.
A block is mirrored, compressed or expanded by one of two families of kernels on a single
std::size_t, each family in a directory of its own under bit/detail/ with one header per
kernel:
detail::intrinsicholdsblock_reverse, which is Clang's__builtin_bitreverse32and__builtin_bitreverse64whereXSTD_INTS_HAS_BUILTIN_BITREVERSEsays the compiler has them, and like them a constant expression. Elsewhere it is one instruction where the target has one, each detected by a header of its own asbmi2.hppdetects BMI2: ACLE's__rbitand__rbitll, oneRBIT, whereXSTD_INTS_HAS_RBITsays the target is AArch64 with<arm_acle.h>, which brings GCC on AArch64 the instruction Clang reaches through its builtin; or, whereXSTD_INTS_HAS_GFNIsays the target is x86-64 with__GFNI__, oneGF2P8AFFINEQBagainst the matrix0x8040201008040201, which mirrors the bits of every byte, and a byte swap. llvm-mca puts that at 1.4 cycles a block on Ice Lake against 9.0 for GCC 15's code for the portable kernel, asbenchmark/mcarecords; Clang emits the same two instructions for the portable kernel unasked, with the same matrix. MSVC exposes noRBITintrinsic and defines no__GFNI__, so it keeps the portable kernel. It also holdsblock_compressandblock_expand, which are_pext_u64and_pdep_u64, andblock_select, which isstd::countr_zero(_pdep_u64(1 << k, x)), all three declared only whereXSTD_INTS_HAS_FAST_BMI2says the target makes them fast: x86-64 with__BMI2__, or MSVC with/arch:AVX2, which implies BMI2 but never defines the macro. A target ofbdver4,znver1orznver2is excluded even though it enables BMI2, sincePEXTandPDEPare microcoded there, as uops.info's measurements ofPEXT (R64, R64, R64)andPDEP (R64, R64, R64)on Zen+ and Zen 2 show against Zen 3 and Haswell.detail::portableholds theconstexprkernels, which a constant expression always takes and a run-time call takes wherever the intrinsics are absent.block_reverse_by_swapsmirrors the bytes bystd::byteswap, then each byte's nibbles, pairs and single bits by three masked swaps.block_compress_by_rankloops over the selected bits, placing each at its rank among the mask's ones by onepopcount;block_expand_by_walkloops over the mask's one-bits, depositing the next bit of the value by a mask rather than a branch;block_expand_by_selectloops over the value's one-bits instead, placing each on the mask's corresponding one byblock_select_by_broadword, which suits a sparse value over a wide mask.block_select_by_broadwordis Vigna's broadword select, from "Broadword Implementation of Rank/Select Queries" (WEA 2008): three shift-and-mask steps count the ones of every byte, one multiply sums those counts into a prefix count per byte, one parallel compare againstkcopied into every byte, counted by apopcount, finds the byte holding thek-th one, and a 2 KiB table indexed by that byte and the rank left over gives the bit within it. It never loops and never branches on the value.
block_reverse, block_compress, block_expand and block_select, each in a header of its own, pick between the two:
block_reverse takes the builtin wherever it is declared, in a constant expression too, and RBIT or GFNI outside a constant expression
where those are; the other three take the intrinsic outside a constant
expression where it is declared, the portable kernel otherwise. benchmark/ times each
kernel and the alternatives it beat, in cycles per independent call on a 64-bit block of stated
density, the harness that feeds each call included. benchmark/results/ keeps
each run with the machine, compiler, flags and commit it names, and
CONTRIBUTING.md gives the command; besides this container's
run it holds the Benchmark workflow's on an Emerald Rapids
and a Neoverse N2 runner. The measurements give each kernel's cost and those of the
alternatives it beat, from the Cascade Lake run.
Tests check every portable kernel against a
bit-at-a-time oracle at compile time and at run time, and the run-time block kernels against
the portable ones, so a build with BMI2 checks the instructions too. Agreement cannot show which
kernel ran, so codegen tests compile the public functions to assembly for each target that
selects a kernel and check the instructions that come out, and that they are absent where the
target is excluded. Beyond what tests can sample, Z3 proves each portable kernel equal to its
specification for every input at 32 and 64 bits: PEXT and PDEP as the Intel SDM's pseudocode
gives them, RBIT as the Arm ARM's ASL does, and select by its defining property, each shift
within the width and the select's precondition met at every call.
Branch-free is a claim about control flow; whether a kernel's control flow or memory addresses
depend on secret data was checked separately, per kernel and per secret argument
(test/constant_time/, results). The measurements give, per kernel and secret argument, what
Valgrind's memcheck reports: only the byte reversal, the walk over a public mask, and PEXT and PDEP are free of
secret-dependent branches and addresses.
Wider types compose the blocks: reversal sends each mirrored block to the mirror of its offset,
compression packs each block's result above the bits the lower blocks selected, expansion
draws each block's bits from where the lower blocks stopped, and selection skips whole blocks,
subtracting each one's population from k, until the k-th one lies in the current block,
where the block kernel finds it and the block's offset is added. Shift counts are unsigned
for a built-in type, so no count is signed, and int for a class type, the one amount every
integer class in the test matrix takes without a narrowing conversion.
P3793R2's shl and shr are forwarded the same way, once __cpp_lib_bitops reaches the
202606L that libstdc++ gives them in C++29 mode, one below the value P3104 asks for. Their
template takes any two integer types and reduces every count to three cases: a magnitude at
or past the width gives zero, or minus one for a negative value shifted right; any other
magnitude is a single shift. The magnitude is the count's unsigned_abs, so the most negative
count of a signed type needs no negation, and it is compared with the width in whichever of
the two types holds the other, so a 256-bit width is not truncated into an unsigned char
count. A left shift goes through the unsigned counterpart, which keeps a signed value from
overflowing, and the result converts back modulo the width, as C++20 defines the conversion.
bit_select(x, k) is xstd's own, beside the four: it is not a standard function and no part of
P3104, so it has no <bit> overload to forward to and is one template over unsigned_integer.
It answers the position of the k-th one bit of x, counting from zero at the least
significant end, which makes it the inverse of rank: popcount(x & ((T{1} << bit_select(x, k)) - 1)) == k
for every k below popcount(x). Its signature reads as <bit>'s do: x taken as the other
functions take it, k an int as bit_repeat's length and rotl's count are, and an int
result as countr_zero returns. Its one-block kernel under BMI2 rests on a property of PDEP:
it deposits the k-th bit of its source on the k-th one of its mask, so expanding the lone bit
1 << k through x leaves exactly that one, whose position TZCNT reads off, two
instructions with no loop and no table. TZCNT is BMI1's, which every processor with BMI2 has
but -mbmi2 alone does not enable: there countr_zero falls back to BSF, which leaves zero
undefined, so a zero test joins it, though the precondition leaves the deposit nonzero. A type
no wider than a block calls the block kernel directly. Its contract is narrow, as bit_repeat's is, so it is not noexcept.
bit_mask¶
[bitmask.types] gives a bitmask type three implementations: an enumeration
that overloads the operators, an integer type, or a std::bitset. The
standard's own are mostly the first kind (std::filesystem::perms,
std::launch, std::chars_format, the regex_constants flags), and the
standard names the requirement without a concept to check it. bit_mask asks
for what /2 writes out: regularity, the complement, and the three logical
operators in both their compound and their binary form. Nothing arithmetic,
nothing about range, no conversions, no increment, no ordering, and no shifts:
[bitmask.types] has none, and an enumeration overloading the rest need not add
them.
The shape is also std::bitset's, which generalized exactly these operators
from the built-in integers and added only the shifts; it has no +, no <,
and no conversion to a number.
The compound forms need only be valid. /4 defines setting a value as
evaluating X |= Y and clearing it as X &= ~Y, and says nothing of what
either returns; /2's X& is one way the type "can be written", not a
requirement. Asking for it would turn away libstdc++'s ios_base flag types,
whose compound assignments return const X&, which the standard names as
bitmask types.
Two omissions are load-bearing rather than incidental, and one boundary is.
No strong ordering is asked for because std::bitset has no operator<
at all. Requiring an order would exclude the very type the concept is shaped
after, so a bit container's order stays its wrapper's business.
-= is not asked for either, and for one reason beyond being arithmetic: on an unsigned integer it is subtraction, and on a container of
bits the same spelling reads as set difference. A structural concept cannot
tell those apart, so it declines to ask for the operator whose meaning it
could not pin down.
The domain is narrowed to a field of bits, by a law rather than a list:
unsigned_integer<T> or not (signed_integer<T> or std::integral<T>)
An integer must be an unsigned one; a type that is no kind of integer is
judged by its operators alone. The second disjunct is what admits
std::bitset and the bit containers, which are fields of bits without being
numbers at all, and the test pins that std::bitset models the concept.
Some narrowing is needed because the operators alone admit too much. bool,
the five character types and every signed built-in answer every requirement
stated here, through integral promotion, so no structural clause among the
operators can reach them. integer_class keeps bool out via ++ and --, which are not bitwise and
are not asked for, and keeps the character types out via the pair law, which is
arithmetic's law and says nothing about holding bits.
This is the line <bit> already draws. Measured across all sixteen
built-in candidates, the types this concept admits are exactly the types
std::popcount, std::countr_zero, std::rotl and std::bit_width accept
— the standard unsigned integer types and nothing else. bool, the character
types and every signed built-in are refused there, so a concept for a
fixed-width field of bits refuses them here. A case in the test asserts that
agreement type by type rather than describing it.
What each half turns away. std::integral catches bool, whose conversion
back from the promoted int is a nonzero test rather than a reduction —
~true is true, where a one-bit field gives false, and &=, ^= and
|= come out right only because the nonzero test and mod 2 agree on {0, 1}.
Both front ends say so independently: ~b is -Wbool-operation on GCC and
Clang alike, and GCC diagnoses it even inside an unevaluated requires-expression, which is why the test pins the
refusal by its result rather than by writing the expressions out. The same
predicate catches the character types and the signed built-ins, whose >> is
arithmetic — it preserves the sign rather than moving the bits. char and
wchar_t are only implementation-definedly signed; int is signed always,
so if the one disqualifies then the other does a fortiori.
signed_integer catches that same thing among integer classes, which
std::integral does not see. Without it absl::int128 would be admitted
while long long was refused; the exact-width signed list in the test reaches
both, so the asymmetry is pinned where those types exist.
unsigned char stays admitted, which is what keeps uint8_t — a block type
the bits library actually instantiates — on the right side. signed char does
not, and int8_t goes with it.
Two alternatives were considered. Spelling the six out by name works, but
stops one step short of its own argument: it excludes char for
maybe sign-extending while admitting int, which certainly does. And a
nested requirement can state modularity — requires T(~T(~T{})) == T{} is
well-formed and does reject bool — but it cannot quantify, since a
requires-expression's own parameters are not constant expressions and naming
one there is a hard error rather than a substitution failure; it drags
constexpr into the concept, so std::bitset would model it under C++23 and
hard-error under C++20; and it wrongly admits char, which is genuinely
modular.
promoted_t does the work it does in integer_class, for the same reason:
~ on an unsigned char yields int, so stating the closure against T
would reject every narrow built-in. The concept is cv-transparent by the same
idiom too — every requirement is stated of the cv-stripped T, so a const,
volatile or const volatile spelling models it exactly when the bare type
does.
Conditional noexcept¶
All integer operations are constexpr. Those with a wide contract have a conditional noexcept,
on whether the element type's own operations carry the specifier, because
[iterator.concept.winc] does not ask for noexcept anywhere — the word does not
occur in the subclause. Requiring it would narrow the extension point to
integer-class types whose authors wrote it: absl::uint128 is two uint64_t
halves with no way to throw and not one noexcept in its header, and was turned
away. Dropping it would go too far the other way, since std::abs and std::div
are noexcept as both major implementations ship them. Conditioning keeps
both, and refuses neither. xstd::to_chars is unconditional in the other
direction, having no noexcept at all, because std::to_chars has none either.
The predicate is named for what it can see — a type's declarations, not its
behavior. "Integral" rather than "arithmetic", because an arithmetic category would also include floating point and this answers false for every
floating-point type; "operators", because it ranges over a type's operators
rather than its category. It is public because it is those exception
specifications, seven in <xstd/ints/cstdlib/> and sixteen in <xstd/ints/bit/>, and a
caller asking whether one throws over their own type is asking exactly this.
Its operations are every requirement integer_class states over const
operands, and no more. That boundary is not this library's invention:
absl::uint128 declares each of them constexpr and not one of its mutating
operators, so the const half is what an integer-class type in the field treats
as its value surface. It is also the only half these functions can reach, each
taking and returning a value. Within it the coverage is total rather than
itemized — a list drawn from what the bodies contain has to be revised whenever a
body changes, and being over-cautious about an operation a function never
performs costs nothing, where missing one it does perform is a wrong
noexcept(true).
None of this would be here if the language could deduce it. noexcept(auto) was
proposed against exactly this shape of problem —
N3207
(2010), still open as
P3166R0
(2024) — and C++23 has no such thing. The manual noexcept(noexcept(e)) reaches
only a single-expression function, which these are not.
A = default function needs no stand-in and gets neither specifier: defaulted on
its first declaration it is implicitly constexpr if the implicit declaration
would be, and its exception specification is computed. So div_result's equality
writes only [[nodiscard]], the one of the three with no implicit form. Neither
is restated for symmetry, because the two fail differently: a constexpr that
cannot hold is refused where a constant
expression needs it, while
P1286R2
(C++20) removed the rule that deleted such a defaulted function, so an over-broad
noexcept buys a std::terminate in place of a diagnostic.
The 128-bit aliases¶
They are spelled int128 and uint128, without the _t that <cstdint>'s
exact-width names carry. C reserves typedef names beginning with int or uint
and ending in _t for future additions to <stdint.h>. The reservation does not
reach into a user-defined namespace, but <xstd/ints/cstdint.hpp> exists precisely
because the standard header lacks the type, so the day it gains one is the day
the two spellings collide. Boost.Int128 renamed the same way during its review.
Their two associations live in <xstd/ints/cstdint/int128.hpp>, beside the aliases
and not inside the traits; <xstd/ints/cstdint.hpp> is the umbrella that exports
them. The header that introduces a pair registers it, so whoever can name the
type has the specializations in scope and cannot reach the type through a
translation unit that would answer differently. It also leaves make_signed and
make_unsigned knowing about no type in particular. What the aliases cannot do
is demonstrate the widening on their own, being specialized for by a header of
this library's; the exact-width test lists therefore also carry Boost.Int128 and
absl::int128, whose pairs no library of ours defines. Both dependencies are
optional; see CONTRIBUTING.md.
The two are not interchangeable, which is why both are here. Boost annotates its
operations and Abseil annotates none, so they land on opposite sides of
nothrow_const_operators. They differ once more: absl::int128's operator/
and operator% are constexpr only where a 128-bit intrinsic backs them, and
out-of-line otherwise. That is the one place these tests assert something about
the target rather than the type. The whole-surface battery in
test/src/ints/cstdlib.cpp runs over it everywhere, constant-evaluated where the
intrinsic is there and checked at run time where it is not; its place in the
exact-width lists is conditional on ABSL_HAVE_INTRINSIC_INT128 instead, those
cases constant-evaluating everything. Only the four divisions object at all, and
those are exactly what the battery covers.
External pairs¶
Abseil and Boost.Int128 each introduce an integer-class pair, and neither can
name xstd::make_signed. Their associations are the two lines nobody can
derive, identical for every consumer, so <xstd/ints/ext/absl/int128.hpp> and
<xstd/ints/ext/boost/int128.hpp> ship them rather than leaving each user to write
the same specializations. Because they are explicit specializations, shipping
them later would have been the breaking change, not shipping them now: a user
who had written their own would meet a redefinition.
The layout is Boost.Hana's boost/hana/ext/, one directory per adapted library
and one header per upstream header, contents in this library's namespace rather
than the adapted one's. The prefix is load-bearing where Hana's is: these
headers are installed, and an unprefixed top-level ext/ would put a generic
directory in a shared include namespace, where two libraries that both did it
would collide silently on the first -I. Nothing under include/xstd/ includes
them and no umbrella sits above <xstd/ints/ext/absl.hpp> and <xstd/ints/ext/boost.hpp>,
so an adapted library is asked for by name and <xstd/ints/cstdint.hpp> never drags
in Abseil. Each hard-includes the library it adapts, leaving the __has_include
probe to the consumer — which the test suite writes for itself, having no
fixture headers for these two and reaching them through the shipped adapter the
way anyone else would.
Those includes are exported, so one is all a consumer writes: a translation unit
including <xstd/ints/ext/absl/int128.hpp> has absl::int128 and absl::uint128
with it, and include-cleaner over that unit asks for nothing further, under a
configuration that inherits nothing from this repository.
Each adapter also carries IWYU pragma: always_keep, both the pair headers and
the two umbrellas, which do not inherit it. A header included for its
specializations is used in a way no use of a name can show, and without
the pragma a translation unit that includes one to register the pair and then
names nothing from it is told the include is unused.
That is the whole of it for Abseil, which declares both types in the header it
advertises and carries IWYU pragma: export on its own two includes. Boost
declares this pair in detail/int128_imp.hpp and detail/uint128_imp.hpp and
re-exports them from no public header, so the chain stops one hop short and a
consumer naming boost::int128::uint128 is told nothing provides it. Nothing
this library ships can close that. An export pragma attaches to an include
directive, so reaching the declaration means naming Boost's detail/ — a
liability, since upstream may rename or split either file and a pinned CI would
not see it. always_keep answers the half that is ours, and the half that is
left is a true statement about the consumer's own code.
So the answer is the linter's configuration rather than a header:
misc-include-cleaner is not asked about boost/.* at all, in the root
.clang-tidy and so in the test tree that inherits it. This is not a
Boost.Int128 workaround. No Boost library ships IWYU pragmas — Boost.Hana has
none across 450 headers — and Boost.Test's macros expand through private
implementation headers for the same reason, which is why the test tree carried
this option long before these adapters existed. Any project including any Boost
library and linting its own sources needs the same line, and no library can
supply it on their behalf: the option belongs to the linter, and CMake carries
no usage requirement that could propagate one.
Hashing¶
Boost.Hash2 serializes an integer through byte writers that stop at 8 bytes, and
selects that path by std::is_integral, which libstdc++ 15 and older grant
__int128 only outside __STRICT_ANSI__. Neither the Microsoft STL's std::_Signed128 nor a
fundamental type such as __int128 or _BitInt(N) has a namespace in which a
tag_invoke hook could be found by ADL, so no customization reaches them.
<xstd/ints/ext/boost/hash2.hpp> therefore supplies a function to call rather than a
hook to find: xstd::hash_append_int(h, f, v) feeds any xstd integer to Hash2's
existing writers, which is what keeps every flavor and constant evaluation working.
The message is the value, never the object. A _BitInt(24) occupies four bytes
one of which is padding with no specified contents, so hashing its storage would
break "equal values hash equal"; the value is read instead. Up to 64 bits it is
converted to the narrowest exact-width standard type of its signedness that holds
it, and handed to hash_append; past 64 bits it is split into 64-bit words, low
word first, as many as its width needs. Both steps sign-extend a signed value and
zero-extend an unsigned one. Two consequences follow, and the tests pin both.
Under little_endian_flavor the message is the value's little-endian bytes, so
every 128-bit type (native, the Microsoft STL's, Boost.Int128's, Abseil's and
_BitInt(128)) gives one digest. And types of one width agree whatever their
representation: bit_int<32> hashes as int32_t, bit_int<64> as int64_t, and
bit_int<100> as an int128 of the same value.
Extending every bit-precise width to a whole 64-bit word was rejected: it would
have hashed bit_int<32> as eight bytes and int32_t as four, and libc++, which
makes _BitInt integral, already gives Hash2 the four. Under a big-endian flavor
the words stay low first and only the bytes within each word reverse, which is
how xstd-bits has always hashed its 128-bit blocks.
Traits and concepts¶
The type utilities intentionally remain narrow:
is_signedandis_unsignedare open counterparts of the standard traits.make_signedandmake_unsignedare their open, user-specializable transformations.promoted_tnames the type of+afor an integral type, and leaves every other type as it is.nothrow_const_operatorsanswers whether the conditionalnoexceptholds.
A concept spelling is provided when the standard library has an analogous
concept; otherwise the trait is the interface. nothrow_const_operators,
like integer_class, bit_mask, alignable and nothrow_alignable, has no standard trait
to mirror and so no trait beside it, and it is spelled the way a caller writes it: inside a
noexcept.
Which decides where the is goes, and the subclause is no guide there. Its
concept is is-integer-like — the only concept in the library or the standard
whose name starts with is, because it is exposition-only and answers to nobody.
The convention outside it is the opposite and is worth following: is marks a
trait, as in std::is_integral, and a concept goes bare, as in std::integral.
So the concept here is integer, bare, rather than an is_integer
transliterating a hyphenated name that was never meant to be public; where a trait
does stand beside a concept, as is_signed does beside signed_integer, the
is is what marks which is which.
Character conversion¶
xstd::to_chars delegates char and every standard integer type directly to
the standard library, so callers get its tuned implementation. Those are the types
[charconv.to.chars] declares an overload for. wchar_t, char8_t,
char16_t and char32_t are refused rather than forwarded: std::to_chars would
take each only by integral promotion and write the code unit as a number, which no
caller converting a character type means. For an xstd
integer that std::to_chars does not cover, the digits are produced here to
the same specification, bases 2 through 36 and value_too_large included. The
default base is written as the literal 10, as [charconv.to.chars] writes it, so the two
signatures read alike; readability-magic-numbers is suppressed a line at a time
rather than taught to ignore 10 throughout.
The digits come off the unsigned counterpart of I, the only type in which the
magnitude of min() is representable, and everything after that is unsigned
arithmetic. Staying in I would work, % yielding a remainder of the value's
own sign that a digit table read backwards absorbs, but it pays for the sign at
every digit rather than once and forces both loops to test rest / radix == 0
where an unsigned magnitude tests rest >= radix — one division per digit rather
than two, and for an integer-class type a division is a call. libstdc++ makes the
same reduction in __to_chars_i, and the loop shape here is its
__detail::__to_chars.
Neither loop counts characters. The first walks a pointer forward from where the
most significant digit goes, and a step onto last is the short buffer; the
second walks it back down, writing as it goes. The buffer's end is the walk's own
bound rather than a count, which keeps the comparison between two pointers and
leaves no signed-to-unsigned mismatch to answer; the one place a length is asked
for at all is the check ahead of the walk, which spells it std::distance. The
sign is written last, the walk stepping back into the one position it reserved
and no digit claimed; writing it first is what obliges libstdc++ to leave a '-'
in a buffer it then reports as too small. That the walk cannot start before there is
room for the first digit and the sign is why value_too_large has two returns,
both covered per type since gcov records them per instantiation.
The overloads state the split literally, through one exposition-only concept
both name. The delegating overload accepts char and every integer that is no
wider than uint128 and that a std::to_chars call takes; the fallback accepts
every other integer. The call is detected inside that concept; it stays an
answer rather than an error because I keeps the expression dependent until the
constraint is checked. Between the two
templates, a deleted bool overload mirrors the standard explicitly rather than
leaving the rejection as an incidental result of their constraints.
Their order in the header is reading order: the standard's own domain, then the
deleted bool, then the fallback for what it does not cover. It once could not
be. gcov names only the
first group of functions sharing a start line in a file, and gcovr keyed its
cross-translation-unit merge on those names,
so an unnamed group merged with nothing; the digits body contains lines no other
translation unit can reach — the short-buffer returns — so it had to be the named
one, and ordered the other way the file measured 98% and the coverage gate
failed. The same sensitivity was why the digits body kept its sign and magnitude
as two plain declarations. Passing --merge-lines to gcovr merges a template's
instantiations by line rather than by name, which dissolved that constraint and
with it the reason the digits overload came first.
Two places the standard library does not cover are worth separating. Integer-class
types are never covered, to_chars being specified over the built-in integer
types. The built-in 128-bit types are covered everywhere but one place:
libstdc++ 15 in the strict dialect, with CMAKE_CXX_EXTENSIONS OFF, counts
neither __int128 nor unsigned __int128 as std::integral and declares no
std::to_chars for them. There they take xstd's own digits. libstdc++ 16 and 17
and libc++ take both in either dialect, and forward. The fallback's constraint
needs no special case for this: a type that is not integral never reaches the
std::to_chars call the constraint asks about.
<format> does not follow <charconv> here. std::formattable<__int128, char>
holds on libstdc++ 15, 16 and 17 and on libc++, in both dialects, so the one
library and dialect that leaves the type out of <charconv> still formats it.
<ostream> writes it in neither.
bool is outside both conversion constraints.
Formatting¶
<xstd/ints/format.hpp> formats a div_result as (quotient, remainder), through three partial
specializations of std::formatter. div_result is not tuple-like, but std::tie makes a
tuple of its two members, so where the standard library can format a tuple of the element
type the specialization to use inherits
std::formatter<std::tuple<I const&, I const&>> and hands it std::tie — the
standard's own rendering, with no intermediate string.
That delegation can be missing in two ways that do not coincide: the element type
may not be formattable, which is Abseil's int128 and the Microsoft STL's 128-bit
classes, or tuple formatting itself may be missing, P2286 having reached libstdc++
only in GCC 15. Where only the element type is missing, the second specialization
renders each member through xstd::to_chars and hands the two digit strings to a
tuple formatter of an exposition-only type, so the spec grammar stays the tuple's:
{:n} and {:m} mean the same for div_result<absl::int128> as for
div_result<int>, and a format string written for one element type compiles for the
other. Where tuple formatting itself is missing, the third renders the same text into
an inherited string formatter, so div_result formats on every implementation and
for either signedness — for every element type xstd::to_chars itself covers, which
is to say every one with an unsigned counterpart.
The choice is left to partial ordering, on the same footing as to_chars's
overloads: each specialization's constraints are a superset of the next one's, the
tuple-of-digits one adding that the library formats tuples, and the tuple-of-I one
adding that it formats I. All three produce (quotient, remainder). Which one runs
shows only in the spec grammar, which is the tuple's wherever the library formats
tuples, and the string's on a library that formats none.
xstd specializes std::formatter only for div_result, which is program-defined. The
128-bit types are not xstd's to specialize for: they are built-ins or
standard-library types, which [namespace.std]/2 does not cover. There is no
stream inserter either — a built-in has no associated namespace for ADL, and a
standard-library type's is one no program may add to. Boost.Test, the only
consumer that needed it, asks through print_log_value, which the tests
specialize directly.
Alignment¶
align_up and align_down round a value to a power-of-two alignment, spelled as
Boost.Align, LLVM's alignTo/alignDown and the kernel's ALIGN macros all
spell it: value first, alignment second. is_aligned asks whether rounding would
change anything.
All three are one operation: alignment is addition modulo a power of two.
align_down(v, a) is v - (v mod a), align_up(v, a) is align_down(v + a - 1,
a). The mask is not a different algorithm but a cheaper spelling of the
remainder, licensed by v % 2^N == v & (2^N - 1) — which holds only for an
unsigned type of radix 2, which is why alignable asks for radix 2 rather than
assuming it. Subtracting the remainder suffices; complementing the mask to keep
the high bits instead is a second trick that costs a requirement and, both
compilers agree, emits the same instructions, at most scheduled in another order.
alignable shares integer_class's opening clauses and then stops, adding two of its own:
specialized, integer, radix 2, unsigned, wider than one bit, convertible both ways with
size_t, totally ordered, closed under +, - and &. Every
unsigned_integer is alignable and no signed_integer is, while the converse
fails — which is what kept it here when the storage utilities left for
xstd-misc.
Signed types are excluded, though their arithmetic is fine: on two's complement
align_up(-5, 4) is -4. They are excluded because align_up's precondition
cannot be checked for them. For a modular type the wrapped sum is a value, so
assert(sum >= value) detects it; for a signed type the overflow is undefined
before the assert runs. Stating it as value <= max - mask would cover signed
too, at the cost of asking numeric_limits for a bound at every call.
Each function takes a pointer overload that converts to uintptr_t, delegates and
converts back, aligning an address being this same arithmetic in the address
space. Delegating rather than repeating the mask work is what gives the pointer
case that precondition: Boost's pointer align_up duplicates it and quietly
returns null when an address near the top of the space wraps. Pointer overloads
cannot be constexpr, reinterpret_cast being barred from constant expressions.
Boost.Align's detail::not_pointer is neither redundant nor a diagnostic: it is
what makes Boost's own pointer overloads reachable, a T* argument otherwise
deducing exactly and beating the conversion to void*. A concept needs no such
trait, pointers failing alignable on their own. Boost's domain is decided by
its bodies, with not_pointer the one thing said at the interface; here the
interface says all of it. std::align remains the checked version of the pointer
case: it takes the remaining space too, so it can report that a block does not
fit, which these cannot.
Integer division¶
div_result is xstd's common result for four complete integer-division
conventions: truncating div, Euclidean div_euclid, floored div_floor, and ceiling div_ceil.
Its result vocabulary follows P3724R4, while xstd
intentionally limits the operation family to these established conventions and
extends all four across its broader integer domain.
template<xstd::integer I>
struct div_result
{
I quotient;
I remainder;
};
Every operation returns quotient and remainder together and preserves:
numerator == denominator * quotient + remainder
| Function | Quotient rule | Remainder rule |
|---|---|---|
div |
toward zero | zero or numerator's sign |
div_euclid |
chosen for a nonnegative remainder | 0 <= remainder < abs(denominator) |
div_floor |
toward negative infinity | zero or denominator's sign |
div_ceil |
toward positive infinity | zero or the opposite of denominator's sign; modulo 2^N when unsigned |
constexpr auto result = xstd::div_floor(-8, 3);
static_assert(result.quotient == -3);
static_assert(result.remainder == 1);
auto const [quotient, remainder] = xstd::div_euclid(-8, 3);
auto const [q, r] = xstd::div_floor(-8, 3);
xstd adopts the relevant P3724 result vocabulary and rounding semantics, not the
complete proposal; no other rounding modes are part of xstd. All four
operations support signed, unsigned, extended, bit-precise, and paired
integer-class types. For unsigned types the first three conventions coincide, while
div_ceil rounds any inexact quotient up and leaves the remainder that requires,
numerator - denominator * quotient, taken modulo 2^N as P3724R4's
div_rem_to_pos_inf words it, so the identity above holds in the type's own
arithmetic. Its quotient is what counting whole units asks for: the blocks that
N bits take, at any block width.
Requirements and evolution¶
xstd requires a conforming C++23 compiler, and the CMake project CMake 3.30 or
later, exporting the header-only xstd::ints target. Consumers build neither the
tests nor their dependencies. New facilities should stay small, portable and
motivated by current practice; one that enters the standard library can be
retired, and the baseline can advance to C++26 once that is a practical default.
Future directions¶
Directions considered and deliberately not taken yet. Each records what it would take, so the question can be picked up without rediscovering the answer.
A module interface¶
import xstd.ints; beside the headers, never instead of them, the way
fmt ships one: a second, optional target
(XSTD_INTS_MODULE) whose interface unit includes today's headers in its global
module fragment and exports the public names, with a FILE_SET CXX_MODULES over
CMake 3.28 or later. The INTERFACE target, its headers and its install rules stay
as they are. A compiled mode, fmt's third, has no counterpart here: there is no
non-template code to move into a translation unit.
Two costs decide when. The interface unit must enumerate the public API, a list to
keep current, though it would also make "public" a statement rather than whatever
sits under include/xstd/. And Apple Clang's version numbers do not map onto
upstream Clang's, so whether Xcode 16.4 and 26.6 can build it needs checking. CI
needs nothing new: a module leg is a stub passing -DXSTD_INTS_MODULE=ON, since
every leg's generator can scan module dependencies. Installing module interface
sources for a find_package consumer is the least settled part, and fmt is the
worked example to follow. xstd-misc and xstd-bits have the same shape, and this
library, the smallest surface, is where to answer it first.
Choosing the bit-permutation kernel¶
The kernel block_compress, block_expand and block_select run follows the target macros and
nothing else, at the costs measured above. Four refinements are recorded, the
first concrete and the other three waiting on a measurement:
- The consumer states the path. A CMake cache variable
XSTD_INTS_BIT_PERMUTATIONbecomes anINTERFACEcompile definition onxstd::ints, and a non-CMake user defines the same macro.auto, the default, is today's behaviour;portablealways takes theconstexprkernels;bmi2takesPEXTandPDEPand is an#erroron a target without fast BMI2, so a build whose flags drift fails rather than quietly changing kernels. Constant evaluation stays portable under every value, and every translation unit must see the same value, since the kernels are inline. There is no run-time dispatch: a library of headers has no binary to dispatch from, and a consumer shipping one artifact for many CPUs builds one copy per target, as Stockfish's universal build does. CI would add a-march=x86-64-v3 -DXSTD_INTS_BIT_PERMUTATION=bmi2leg for GCC and for Clang, and a compile-time test that each value reaches its kernel. - Dense masks without fast BMI2. Compressing by rank costs 2.5 to 3.2 cycles
a selected bit on the Cascade Lake run, where zp7 is a flat 25 with
PCLMULQDQ, so the two cross at eight or nine selected bits. Zen 1 and 2 havePCLMULQDQthough theirPEXTis microcoded, so choosing bypopcount(mask)needs the crossover measured on them, with and without it. - Expand by select. The one-block expand always walks the mask, though
block_expand_by_selectwins on a sparse value: on the Cascade Lake run it takes a flat 24 to 25 cycles for two source ones, against 2.5 to 3.0 a mask bit for the walk, while at half density the walk wins at every width. Choosing bypopcount(src)againstpopcount(mask), in the portable path only, needs that break-even measured on the targets the portable path serves, so that the check pays for itself on dense values. - SVE2 BITPERM. AArch64's optional
BEXTandBDEP, under__ARM_FEATURE_SVE2_BITPERM, are the counterparts ofPEXTandPDEP, and need their latency measured on a core that implements them.
Each new kernel joins the agreement checks against the portable kernels and the oracle, fuzzing included. One mask applied to many values, where Hacker's Delight's compress and zp7 both compute their shift masks once and apply them to each value, needs a batch interface that the per-call signature cannot amortize, and belongs in xstd-bits. wasm32 has no bit-permute instruction, so the portable kernels are already its answer.
Contracts¶
Each function states its domain twice. What a type must be is a concept, and a
type outside it is a compile error. What a value must be is a precondition, and
a value outside it is an assert: diagnosed where assertions are enabled, and
undefined behaviour where NDEBUG has removed them. Neither is reported as a
value at run time, and to_chars is no exception: its std::errc says the buffer
was too small, never that the call was out of domain.
| Function | Precondition |
|---|---|
abs(x) |
x != numeric_limits<I>::min(), whose negation is unrepresentable; unsigned_abs is the total form of the same question and has none |
div(numer, denom) div_euclid(numer, denom) div_floor(numer, denom) div_ceil(numer, denom) |
denom is not zero, and numer is not numeric_limits<I>::min() with denom at -1. Both are the undefined behaviour / and % already have; the assert is reached first |
to_chars(first, last, value, base) |
2 <= base and base <= 36, and [first, last) a valid range |
align_up(value, alignment) align_down(value, alignment) is_aligned(value, alignment) |
alignment is a power of two, which is not zero; align_up additionally that the rounded result is representable |
bit_ceil(x) |
The result is representable: x is at most the highest power of two of its type. A call that violates it is not a constant expression even under NDEBUG, as [bit.pow.two] requires |
bit_repeat(x, l) |
l > 0. A call that violates it is not a constant expression even under NDEBUG, as P3104R6 requires |
bit_select(x, k) |
0 <= k and k < popcount(x), so x has a k-th one. A call that violates it is not a constant expression even under NDEBUG, as for bit_repeat |
sign, unsigned_abs, the <bit> counterparts other than bit_ceil and bit_repeat, and every concept and
trait are total: every value of a type they accept is in their domain.
The pointer overloads of the three alignment functions are address arithmetic and
nothing more: convert to std::uintptr_t, round, convert back. What comes out is
an address. It is not checked against the object the argument pointed into, no
object's lifetime begins there, and nothing makes it safe to dereference.
std::align is the checked form of the same case: it takes the space remaining
as well, so it can report that a block does not fit, which these cannot.