Annotations Reference¶
Header: rbe/annotations.hpp
RBE annotations are C++26 annotations ([[=expr]]) attached to a struct or to one of its non-static data
members. They have no runtime representation: RBE reads them back through reflection at compile time to
derive the wire layout and the behavior of frames.
struct [[=rbe::pack, =rbe::big]] Header {
[[=rbe::little]] std::uint16_t length;
std::uint8_t type;
};
Dimensions¶
Annotations are grouped into dimensions. At most one annotation of a dimension may apply to the same annotation range: the struct's own annotations, or a member's own annotations combined with those of its type. Annotations from different dimensions combine freely. Some dimensions add a rule across the whole, possibly nested, type.
| Dimension | Annotations | Per annotation range | Across the whole type |
|---|---|---|---|
| Endianness | little, big, order(o), bits(msb, lsb) |
at most one | |
| Alignment | pack, align, alignment(m) |
at most one | |
| Id | id, id(value) |
at most one | at most once |
| Length | self_length / header_length, frame_length, payload_length, payload_count, length(k) |
at most one | each kind at most once |
| Count | count("field") |
at most one | |
| Empty | empty |
at most one | only written on a type, never on a member |
| Friendly | friendly |
at most one | only written on a type, never on a member; not inherited by member types |
| Format | fmt |
at most one | only written on a type, never on a member; nested types carry their own |
Struct-level annotations are inherited by every member. A member's own annotations, or its type's for a nested struct, take precedence and replace the inherited ones one dimension at a time. See Annotate a type for the rules in action and the design notes for how the system is built.
Annotations on aliases are ignored
RBE reads annotations from a class and from its members, nothing else. An annotation written on a
using alias is invisible: rbe::deserialize<Alias>(...) deduces the aliased type, and the alias,
with whatever was written on it, is gone before RBE looks. Annotate the struct, or wrap the type in
a struct of its own.
Endianness¶
Header: rbe/annotations/endianness.hpp
| Annotation | Scope | Description |
|---|---|---|
=rbe::little |
struct, member | Little-endian byte order. Spelled in full: =rbe::order(rbe::endian::order::little). |
=rbe::big |
struct, member | Big-endian byte order. Spelled in full: =rbe::order(rbe::endian::order::big). |
=rbe::order(o) |
struct, member | The general form; o is an rbe::endian::order, an alias of std::endian. order::native asks for the host's order. |
=rbe::bits(msb, lsb) |
member | Reserved for explicit bit placement, both ends inclusive. The field must be integral and msb must fit in it. It joins this dimension for conflict checks but is not consumed by the layout yet. Header: rbe/annotations/bits.hpp. |
struct [[=rbe::big]] Big { std::uint32_t v; }; // =rbe::order(rbe::endian::order::big)
struct [[=rbe::little]] Little { std::uint32_t v; }; // the default when nothing is said
struct [[=rbe::order(rbe::endian::order::native)]] Native { std::uint32_t v; }; // the host's order, whatever it is
Little-endian is the default whenever no endianness annotation is in scope
(REQ-077): a fixed byte order, deliberately not the
host's. A host-dependent default would make the same program serialize a struct two different ways on two
machines. If the bytes never leave the machine, =rbe::order(rbe::endian::order::native) says so and is
free on every target.
Alignment¶
Header: rbe/annotations/alignment.hpp
| Annotation | Scope | Description |
|---|---|---|
=rbe::pack |
struct, member | Members are laid out on the wire with no padding between them. Spelled in full: =rbe::alignment(rbe::alignment_mode::pack). |
=rbe::align |
struct, member | The regular C++ layout, padding included. The default; provided to state it explicitly, e.g. to override an inherited pack. Spelled in full: =rbe::alignment(rbe::alignment_mode::align). |
=rbe::alignment(m) |
struct, member | The general form; m is an rbe::alignment_mode, pack or align. |
struct [[=rbe::pack]] Packed { std::uint8_t a; std::uint32_t b; }; // =rbe::alignment(rbe::alignment_mode::pack)
struct [[=rbe::align]] Aligned { std::uint8_t a; std::uint32_t b; }; // =rbe::alignment(rbe::alignment_mode::align)
static_assert(rbe::wire_size_of<Packed>() == 5);
static_assert(rbe::wire_size_of<Aligned>() == 8); // the C++ layout, padding included
struct [[=rbe::pack]] Outer {
Aligned inner; // the nested type said align: it keeps it
std::uint8_t c;
};
static_assert(rbe::wire_size_of<Outer>() == 9);
pack propagates into nested structs that say nothing about alignment; a nested struct annotated align
keeps its padding inside a packed parent.
Id¶
Header: rbe/annotations/id.hpp
| Annotation | Scope | Description |
|---|---|---|
=rbe::id |
member | Marks the field the id is read from on the wire. The field's type must be equality comparable. |
=rbe::id(value) |
struct | Declares the id the type answers to. value keeps its type (rbe::id(msg_type::heartbeat) carries a msg_type, not an int) and must be equality comparable. On a member it is an error. |
struct hdr { [[=rbe::id]] msg_type_t type; [[=rbe::frame_length]] std::uint16_t len; };
struct [[=rbe::id(msg_type_t::heartbeat)]] Heartbeat { hdr h; std::uint32_t timestamp; };
The two are the halves of one thing, where the id lives and which id a type answers to, so they share a
dimension and neither may repeat across the whole type. Frames consume both: a header's id field is
header.id(), and rbe::frames::any selects the candidate whose id(value) matches it. See
Frames.
Id concepts and queries¶
| Symbol | Kind | Meaning |
|---|---|---|
rbe::identifiable<T> |
concept | T declares [[=rbe::id(value)]]. |
rbe::identifying<T> |
concept | T has a direct field marked [[=rbe::id]]. Nested types are not searched. |
rbe::self_identifying<T> |
concept | Both of the above. |
rbe::id_like<T> |
concept | Equality comparable and copyable: what an id value has to be. |
rbe::id_of<T>() |
consteval function | The id T declares, with the type it was written with. Requires identifiable<T>. |
rbe::id_type_of<T> |
alias | The type of id_of<T>(). |
struct [[=rbe::id(msg_type_t::quote)]] Quote { std::uint32_t bid, ask; };
static_assert(rbe::identifiable<Quote>); // declares the id it answers to
static_assert(rbe::identifying<hdr>); // has a field marked rbe::id
static_assert(not rbe::identifying<Heartbeat>); // only direct fields count: the id field is inside hdr
static_assert(rbe::id_like<msg_type_t>); // equality comparable and copyable
static_assert(rbe::id_of<Quote>() == msg_type_t::quote); // the value, with the type it was written with
static_assert(std::same_as<rbe::id_type_of<Quote>, msg_type_t>);
Length¶
Header: rbe/annotations/length.hpp
| Annotation | Scope | Description |
|---|---|---|
=rbe::frame_length |
member | The total length of the frame on the wire, header and payload. =rbe::length(rbe::length_kind::frame). |
=rbe::payload_length |
member | The length of the payload alone, the frame minus its header. =rbe::length(rbe::length_kind::payload). |
=rbe::header_length |
member | The length of the annotated type itself, so that a header may be longer on the wire than the code knows (a newer protocol version appended fields). The same object as =rbe::self_length: =rbe::length(rbe::length_kind::self). |
=rbe::payload_count |
member | How many elements the payload holds, for a payload that is a run. =rbe::length(rbe::length_kind::count). |
=rbe::length(k) |
member | The general form; k is an rbe::length_kind: frame, payload, self, count. |
struct [[=rbe::pack_le]] FrameHeader {
[[=rbe::frame_length]] std::uint16_t length; // header + payload. Long form: =rbe::length(rbe::length_kind::frame)
[[=rbe::id]] std::uint8_t type;
};
struct [[=rbe::pack_le]] BlockHeader {
[[=rbe::header_length]] std::uint8_t header_length; // this header's own length, may exceed sizeof: =rbe::self_length
[[=rbe::payload_length]] std::uint16_t block_length; // the payload alone
[[=rbe::payload_count]] std::uint16_t count; // how many elements the payload holds
};
The kinds are independent: a header may carry any combination, each at most once across the whole type. The
field must be convertible to std::size_t. Frames read all four: they decide how a frame is
delimited and feed header.frame_length(), payload_length(), length() and
payload_count(). Outside frames, serialization and deserialization of a plain struct do not read them.
Count¶
Header: rbe/annotations/count.hpp
| Annotation | Scope | Description |
|---|---|---|
=rbe::count("field") |
member | Names the earlier member that holds the number of elements of the annotated one. The annotated member must be a contiguous, owning range without a compile-time size (std::vector, not std::array), and field must be declared before it and convert to std::size_t. |
struct Batch {
std::uint8_t n;
[[=rbe::count("n")]] std::vector<std::uint32_t> items; // `n` holds the number of elements of `items`
};
static_assert(rbe::well_annotated<Batch>); // checked today; not read by serialization yet
🚧 The annotation is checked by well_annotated but nothing reads it yet: variable-size members are not
wirable. It is the vocabulary the variable-size design settles on.
Empty¶
Header: rbe/annotations/empty.hpp
| Annotation | Scope | Description |
|---|---|---|
=rbe::empty |
struct | Declares a type with no members as deliberately empty: zero bytes on the wire. Only on the type itself, never on a member. |
| Symbol | Kind | Meaning |
|---|---|---|
rbe::explicitly_empty<T> |
concept | T is annotated empty and std::is_empty_v<T>. |
struct [[=rbe::id(msg_type_t::heartbeat), =rbe::empty]] Ping { }; // a candidate with no fields: 0 bytes on the wire
static_assert(rbe::explicitly_empty<Ping>);
static_assert(rbe::wire_size_of<Ping>() == 0);
An empty struct is not wirable (it has nothing to write), so without the annotation it cannot be a payload.
With it, it is a valid rbe::frames::any candidate, a header-only message, and match hands it over by
value.
Friendly¶
Header: rbe/annotations/friendly.hpp
| Annotation | Scope | Description |
|---|---|---|
=rbe::friendly |
struct | Grants RBE access to the type's non-public members. Only on the type itself, never on a member. |
class Sealed { std::uint32_t v_ {}; }; // a non-public member: not wirable, the author did not say so
class [[=rbe::friendly]] Opened { // the author grants RBE access to its representation
public:
constexpr Opened() = default;
constexpr explicit Opened(std::uint32_t const v) : v_(v) { }
[[nodiscard]] constexpr auto v() const -> std::uint32_t { return v_; }
private:
std::uint32_t v_ {};
};
struct [[=rbe::friendly]] Holder { Sealed inner; }; // not inherited: Sealed has to open itself
static_assert(not rbe::wirable<Sealed>);
static_assert(rbe::wirable<Opened>);
static_assert(not rbe::wirable<Holder>);
A class is wirable only if every member it declares is public, unless it is
annotated friendly. Members are private to protect an invariant, and the member-wise decode that makes a
class wirable writes them without going through any constructor, so RBE does not take that decision on the
author's behalf. The annotation is the author saying that the members are the representation and that an
object built from the wire is valid by construction: the role friend plays for other serialization
libraries.
It is not inherited: a friendly type does not open the types of its members, each of them has to say so itself. Which strategies apply to an opened class is decided as for any other: the eager one needs a default constructor, the in-place ones need the class trivially wirable, and a class with mixed access is not standard layout, so it is read member by member.
Format¶
Header: rbe/annotations/format.hpp
| Annotation | Scope | Description |
|---|---|---|
=rbe::fmt |
struct | Opts the type into RBE's std::format / std::ostream formatter (Core), which prints every member recursively. Its own dimension, written on the type and never on a member, so it combines with any wire annotation; a nested member type carries its own fmt. It has no effect on the wire. |
struct [[=rbe::fmt]] Debuggable { std::uint32_t v; }; // std::format("{}", Debuggable {}) works
struct [[=rbe::pack_be, =rbe::debug]] Both { std::uint32_t v; }; // rbe::debug == derive<fmt>, combines with the rest
Why on the type only: the formatter is a std::formatter<T> specialization, selected by the type alone.
When std::format("{}", obj.member) is compiled the member is gone, only its type is looked up, so an
annotation on the member would have nothing to reach. A nested type that should print carries its own
=rbe::fmt.
Presets: derive¶
Header: rbe/annotations/derive.hpp
rbe::derive<Args...> groups annotations into a single value, applied with one = clause or named and
reused:
struct [[=rbe::derive<rbe::pack, rbe::little>]] Order { /* ... */ };
inline constexpr auto packed_be = rbe::derive<rbe::pack, rbe::big>;
struct [[=packed_be]] Msg { /* ... */ };
| Preset | Equivalent to |
|---|---|
rbe::pack_le |
derive<pack, little> |
rbe::pack_be |
derive<pack, big> |
rbe::debug |
derive<fmt> |
Correctness¶
Header: rbe/annotations/annotation_concepts.hpp
| Symbol | Kind | Meaning |
|---|---|---|
rbe::well_annotated<T> |
concept | Every rule above holds for T and, recursively, for its members: no annotation twice in one range, one per dimension per range, the whole-type rules of Id and Length, the type-only rule of Empty and Format, and each annotation's own check (an integral field for bits, an earlier count field for count, ...). |
rbe::contains_annotation<T, A> |
concept | A appears somewhere in T, on the type, on a member or inside a nested member type. A may be a derive<...> list, in which case every element must appear. |
struct [[=rbe::little, =rbe::big]] Twice { std::uint32_t v; }; // two of one dimension in one range
struct [[=rbe::pack_be]] Fine { [[=rbe::little]] std::uint32_t v; };
static_assert(rbe::well_annotated<Fine>);
static_assert(not rbe::well_annotated<Twice>);
static_assert(rbe::contains_annotation<Fine, rbe::pack>); // anywhere in the type, in depth
static_assert(rbe::contains_annotation<Fine, rbe::little>); // the member's own
static_assert(rbe::contains_annotation<Fine, rbe::derive<rbe::pack, rbe::big>>); // a list: all of them
static_assert(not rbe::contains_annotation<Fine, rbe::id>);
A type that fails well_annotated is still a type; the error surfaces where RBE is asked to use it, as a
consteval diagnostic that names the rule.
Writing your own annotation¶
Every annotation in RBE is built from one recipe, and user code can follow it. The recipe names types in
rbe::detail, which is not stable API, so it is documented with the design notes:
Annotation System, appendix.