Overview¶
RBE turns a plain C++ struct into a wire format. You describe the bytes once, as the struct itself plus a
few annotations, and the library derives the serialization and
deserialization code at compile time through C++26 static reflection. There is no schema file, no code
generator and no runtime cost over the equivalent hand-written code: a struct whose in-memory layout already
matches the wire is copied with one memcpy.
This page walks through the three layers of the library on one small protocol: a packet header followed by
a run of messages, each one introduced by its own header. The whole example is example/docs/overview.cpp.
Vocabulary
The documentation uses a few words with a precise meaning: wirable, trivially wirable, frame, header, payload, delimiting, narrow and wider contract. They are collected in the glossary, and wherever one appears in the text, hovering over it shows its definition.
Describe the wire format¶
An annotation is written as [[=value]] on a struct or on one of its members. RBE reads it back through
reflection to decide how the type travels; the struct itself is untouched, sizeof and member offsets stay
what the compiler says.
#include <rbe/annotations.hpp>
enum class msg_type : std::uint8_t { add_order = 0x21, reduce_size = 0x25, tick = 0x2A };
// struct will travel packed and big-endian over the wire
struct [[=rbe::pack, =rbe::big]] PacketHeader {
std::uint16_t length;
std::uint8_t count;
std::uint8_t unit;
std::uint32_t sequence;
};
// =rbe::pack_be is a preset for `=rbe::pack, =rbe::big`
struct [[=rbe::pack_be]] MessageHeader {
[[=rbe::frame_length]] std::uint8_t length;
[[=rbe::id]] msg_type message_type;
};
struct [[=rbe::pack_be, =rbe::id(msg_type::add_order)]] AddOrder {
std::uint32_t time_offset;
std::uint32_t order_id;
std::uint8_t side_indicator;
std::uint32_t quantity;
std::uint64_t symbol;
std::uint32_t price;
};
struct [[=rbe::pack_be, =rbe::id(msg_type::reduce_size)]] ReduceSize {
std::uint32_t time_offset;
std::uint64_t order_id;
std::uint32_t cancelled_shares;
};
// trivially_wirable example, wire and host representation are identical
struct [[=rbe::id(msg_type::tick)]] Tick {
std::uint64_t timestamp;
std::uint32_t price;
std::uint32_t quantity;
};
=rbe::packand=rbe::bigset the layout and byte order for every member. A member inherits the struct's annotations and may override them with its own.=rbe::pack_beis a preset for the pair;rbe::derive<...>builds your own.=rbe::frame_lengthand=rbe::idmark the fields a frame reads to find where a message ends and which message it is. They say nothing about the bytes themselves.Tickhas no annotations: little-endian and the regular C++ layout are the defaults, and because that is also its in-memory layout the type is trivially wirable.
Serialize and deserialize¶
Serialization is one call. Deserialization asks you to pick a strategy, because what a message costs to read depends on how much of it you need.
alignas(Tick) std::array<std::byte, 1500> buffer{};
// Serialize to raw buffer: returns the number of bytes written
auto size = rbe::serialize(buffer, AddOrder{});
// Wider contract: std::nullopt instead of undefined behavior when the buffer is too small
auto checked = rbe::try_serialize(std::span{buffer}.first(4), AddOrder{});
// Eager: decode every field up front, get the struct back
auto order = rbe::deserialize<AddOrder>(buffer, rbe::dsrl::eager);
auto price = order.price;
// Lazy: get a proxy over the buffer, each field decoded on access
auto view = rbe::deserialize<AddOrder>(buffer, rbe::dsrl::lazy);
auto timestamp = view.field<"time_offset">(); // ✅ correct
// auto invalid = view.field<"invalid">(); // ❌ compile-time error: no such field
// In-place: type pun the buffer into a struct, no decoding at all.
// The type must be `trivially_wirable` and the buffer must meet alignment requirements.
auto& tick = rbe::deserialize<Tick>(buffer, rbe::dsrl::in_place);
| Strategy | Returns | When |
|---|---|---|
rbe::dsrl::eager |
T |
you need the whole value, or the buffer will not outlive it |
rbe::dsrl::lazy |
rbe::dsrl::proxy<T>, a view |
you read a few fields; each is decoded on access, nothing is copied |
rbe::dsrl::in_place |
T const& |
the wire layout is the in-memory layout: the buffer is reinterpreted, nothing is decoded |
The functions assume the buffer is large enough. rbe::try_serialize and rbe::try_deserialize check it
and return std::nullopt instead: a wider contract, though a pointer-based one can never be fully wide.
Frames¶
A frame is a header followed by a payload. The payload may be a plain struct, one of several structs chosen
by the id in the header (rbe::frames::any), a run of frames each walked by its own length
(rbe::frames::many), or another frame. A whole protocol is one type:
using packet = rbe::frames::frame<
PacketHeader, // packet header
rbe::frames::many< // ...followed by a run of messages
rbe::frames::frame<
MessageHeader, // message header
rbe::frames::any<AddOrder, ReduceSize, Tick> // one of these
>
>
>;
Reading it is a view over the buffer, like the lazy strategy: headers are proxies, the run is a range, and
the any dispatches on the id with match.
// Read it back as a view over the buffer
auto packet_view = rbe::deserialize<packet>(buffer);
auto sequence = packet_view.header().field<"sequence">();
for (auto [hdr, payload] : packet_view.payload()) {
payload.match( // match supports both eager and lazy dispatch
[](AddOrder const&) { /* ... */ },
[](ReduceSize const&) { /* ... */ },
[](rbe::dsrl::proxy<Tick>) { /* ... */ },
[](rbe::unmatched auto) { /* unknown message type, handle error */ }
);
}
A match callback takes the message by value to have it decoded, or as rbe::dsrl::proxy<T> to read it
lazily. The rbe::unmatched callback is mandatory: it receives ids the code does not know, and the
candidates no other callback handles.
Writing a frame is done part by part today: the header, then each message, each where the previous one ended.
// One packet holding a single AddOrder: the parts are written one after the other
constexpr auto message_size = rbe::wire_size_of<MessageHeader>() + rbe::wire_size_of<AddOrder>();
auto datagram = std::vector<std::byte>(rbe::wire_size_of<PacketHeader>() + message_size);
auto out = std::span {datagram};
out = out.subspan(rbe::serialize(out, PacketHeader {.length = 0, .count = 1, .unit = 1, .sequence = 7}));
out = out.subspan(rbe::serialize(out, MessageHeader {.length = message_size, .message_type = msg_type::add_order}));
out = out.subspan(rbe::serialize(out, AddOrder {.time_offset = 1, .order_id = 42, .side_indicator = 'B',
.quantity = 100, .symbol = 0x4143'4d45, .price = 2500}));
Planned
A value type generated from the frame (packet::value_type) and a writer that appends header and
messages straight into the buffer (packet::srl_type) are designed but not implemented. The
feature matrix tracks them.
What you get¶
- Declarative: the struct is the single source of truth for the wire format; frames compose structs into a protocol.
- Checked at compile time: conflicting annotations, a field name typo in
field<"...">, a frame built from the wrong parts, amatchmissing its fallback: all compile errors. - Zero overhead: everything is derived at compile time; trivially wirable types become a
memcpy. - Header-only, no dependencies: GCC 16 and
-freflectionare the whole requirement.
Next steps¶
- Installation: Conan, CMake or the single header.
- How-to examples: one page per task, from annotating a struct to writing a custom frame.
- Market data feeds: five real protocols modelled end to end.
- Reference: every annotation, function and concept.