Erlang lets you describe and pattern-match binary layouts inline:
pack(Version, Type, Len, Body) ->
<<Version:4, Type:4, Len:16, Body/binary>>.
unpack(Bin) ->
<<Version:4, Type:4, Len:16, Body/binary>> = Bin,
{Version, Type, Len, Body}.There's no C++ syntax that can literally look like <<...>>, but C++26's
static reflection (P2996) plus structured annotations give us the next best
thing: annotate an ordinary struct's fields with their bit widths, and get
pack/unpack for free, with zero per-type boilerplate and zero macros.
struct Packet {
std::uint8_t version [[=bits{4}]];
std::uint8_t type [[=bits{4}]];
std::uint16_t length [[=bits{16}]];
std::vector<std::byte> body [[=rest]];
};
auto bytes = pack(p); // <<Version:4, Type:4, Len:16, Body/binary>>
Packet p2 = unpack<Packet>(bytes); // the matching/destructuring sideThe struct is the bit-syntax pattern. Reflection walks its members at
compile time, reads off each one's annotations, and generates the packing
code — the same role nonstatic_data_members_of + template for play in
most other "derive a trait from a struct's shape" reflection demos.
C++26 reflection is real (it's a merged part of the C++26 working draft), but
as of today no shipping, mainstream compiler implements it end-to-end,
particularly the annotation feature ([[=expr]]) that this library leans on
to attach bits{N} etc. to a member. So this project is split into two
honest halves:
-
bitsyntax::detail::bit_writer/bit_reader(insideerlbits.hpp) — the actual bit-packing engine: MSB-first packing (Erlang's default), arbitrary bit widths that cross byte boundaries, little-endian byte-aligned words, byte-aligned "consume the rest" reads, andmatch_errorthrown on a truncated buffer. This part has no reflection dependency, and it is compiled and unit-tested — seebitcore_test.cpp, which builds cleanly withg++ -std=c++20and passes all assertions:$ g++ -std=c++20 -O2 -Wall -Wextra -o bitcore_test bitcore_test.cpp && ./bitcore_test a3 12 34 68 69 test 1 OK: version=10 type=3 len=0x1234 rest_len=2 test 2 OK (unaligned bit widths) test 3 OK (little-endian) test 4 OK (badmatch on short buffer) ALL TESTS PASSED -
pack<T>()/unpack<T>()inerlbits.hpp, and all ofdemo.cpp— the reflection-driven layer that turns annotations into calls into the engine above. This is written against P2996's publicly documented API (^^T,std::meta::info,nonstatic_data_members_of,annotations_of/extract,[: :]splicing,template for), but that API is still moving between proposal revisions, so treat this half as a design sketch, not portable code. The algorithm is straightforward (iterate members, dispatch on which annotation is present, delegate to the tested engine) and shouldn't need to change even if exact spelling of the reflection calls does.
erlbits.hpp guards the reflective half behind
#if ERLBITS_REFLECTION_AVAILABLE, so the header parses and the tested half
still works on ordinary compilers even though the whole file targets C++26.
The closest thing to a working P2996 implementation right now is Bloomberg's experimental fork of Clang: https://github.com/bloomberg/clang-p2996. Once built:
clang++ -std=c++26 -freflection -stdlib=libc++ \
-DERLBITS_HAVE_REFLECTION -o demo demo.cpp
(Exact flags depend on how that fork exposes reflection/annotations at the time you're reading this — check its README, since the flag names have changed between revisions of the fork.)
| Erlang segment syntax | erlbits annotation | Notes |
|---|---|---|
Field:Size |
[[=bits{Size}]] |
plain unsigned, big-endian, Size bits |
Field:Size/unit:U |
[[=bits{Size}, =unit{U}]] |
effective width is Size * U |
Field:Size/little |
[[=bits{Size}, =little]] |
default is /big if omitted, same as Erlang |
Field:Size/signed |
[[=bits{Size}, =is_signed]] |
default is unsigned, same as Erlang |
Rest/binary (tail segment) |
[[=rest]] |
must be the last member; consumes remaining bytes |
<<16#89504E47:32, ...>> |
[[=bits{32}, =expect<T>{0x89504E47}]] |
throws match_error (a badmatch) if it doesn't match |
- Erlang's bare
_:N(match-and-discard) segment — in C++ every struct member needs a name, so the closest analogue is just adding a normal named field and ignoring it; there's no dedicated "unnamed skip" annotation here. - Float segments (
Field:32/float) — would be a small addition (reinterpret the read bits viastd::bit_cast), left out to keep the sketch focused. - Nested/variable-length-prefixed segments where one field's
Sizeis read from an earlier field (e.g.<<Len:16, Body:Len/binary>>) — the currentbits{N}annotation takes a compile-time constant; supporting a runtime-computed size from a previous field is a natural extension but needs a bit more plumbing (the annotation would need to name a sibling member rather than hold a literal), so it's noted here rather than built.
erlbits.hpp— the library (tested bit engine + reflective sketch)bitcore_test.cpp— standalone, currently-compilable unit tests for the bit enginedemo.cpp— Erlang-equivalent examples using the reflectivepack/unpack