Skip to content

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.