Skip to content

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::pack and =rbe::big set the layout and byte order for every member. A member inherits the struct's annotations and may override them with its own.
  • =rbe::pack_be is a preset for the pair; rbe::derive<...> builds your own.
  • =rbe::frame_length and =rbe::id mark the fields a frame reads to find where a message ends and which message it is. They say nothing about the bytes themselves.
  • Tick has 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, a match missing 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 -freflection are the whole requirement.

Next steps