Binary Backend

← serde

Introduction

xpp::serde::bin::Serializer and bin::Deserializer provide a compact length-prefixed binary backend. Field names are not encoded on the wire — structs are field values back-to-back. All multi-byte integers are little-endian.

This format is not self-describing — both ends must agree on the schema (field types and order). It is roughly 2-4x smaller than JSON for typical structs and faster to parse (no string comparisons for field names).

Usage

Serialize

#include <xpp/serde/bin.h>
#include <xpp/serde/serde.h>

Person p{"Alice", 30};

xpp::serde::bin::Serializer ser;
xpp::serde::serialize(p, ser);
xpp::Vec<uint8_t> bytes = ser.into_buffer();
// bytes contains the compact binary encoding

into_buffer() moves the internal buffer out (the Serializer is left empty). buffer() returns a Span<const uint8_t> borrow instead — use this when you need to inspect the bytes without taking ownership.

reset() clears the internal state for reuse.

Deserialize

auto d_res = xpp::serde::bin::Deserializer::from_bytes(bytes);
if (!d_res.is_ok()) {
  // Should not happen for a valid Vec<uint8_t>, but check anyway
  return;
}
auto d = std::move(d_res).unwrap();

auto r = xpp::serde::deserialize<Person>(d);
if (r.is_ok()) {
  Person &p = r.unwrap();
  // p.name == "Alice", p.age == 30
} else {
  xpp::serde::Error &e = r.unwrap_err();
  // e.g. Eof (truncated input), InvalidValue (bad UTF-8)
}

Entry points:

MethodInputOwnership
Deserializer::from_bytes(const Vec<uint8_t>&)VecCopies the bytes into the deserializer
Deserializer::from_bytes(const uint8_t*, size_t)Raw pointer + lengthCopies the bytes
Deserializer::borrow(Span<const uint8_t>)SpanBorrows — caller must keep the buffer alive

The Deserializer holds its own copy by default (safe). borrow is the zero-copy escape hatch for hot paths — the caller must ensure the source buffer outlives the Deserializer.

Wire Format

TypeEncodingSize
bool0x00 (false) / 0x01 (true)1 byte
i32 / u32little-endian4 bytes
i64 / u64little-endian8 bytes
f32IEEE 754 LE4 bytes
f64IEEE 754 LE8 bytes
Stringu32 length + UTF-8 bytes (no NUL terminator)4 + N
Option::None0x001 byte
Option::Some(v)0x01 + value1 + sizeof(v)
Vec<T>u32 count + count × element4 + Σ
structfield values back-to-back, no namesΣ
Enum (external)u32 tag_index + payload4 + payload
Enum (adjacent)struct{tag: String, content: struct{...}}varies

Self-delimiting for fixed-width primitives, Option, and Vec. Structs rely on the visitor knowing the field count (passed via deserialize_struct's n parameter) — there are no length prefixes or field separators between struct fields.

Example encoding

struct Point { int32_t x; int32_t y; };
XPP_SERDE(Point, (x), (y))

Point{42, -7} encodes to 8 bytes:

2a 00 00 00   f9 ff ff ff
└─ x = 42 ─┘  └─ y = -7 ─┘

For comparison, the JSON encoding is {"x":42,"y":-7} — 15 bytes, nearly 2x larger.

Schema Evolution

Because field names are not on the wire, schema changes require care:

ChangeCompatible?Notes
Add field at the endNo (old data is shorter)Reader expects the field, hits Eof.
Add field at the end + old readerYesOld reader stops after existing fields; new field ignored.
Remove fieldNoReader expects it, data is misaligned.
Reorder fieldsNoBinary is positional.
Change field typeNoWidth/encoding mismatch.
Add Option<T> field at endPartialOld data has no byte for it — reader hits Eof. Use a version prefix instead.

For forward-compatible binary protocols, version the format explicitly: prefix the payload with a u32 version and dispatch on it in the Deserialize<T> specialization. Alternatively, use XPP_FIELD_SKIP on the sender side to omit new fields — but both ends must agree on which fields are skipped.

Cross-Backend Interop

The same Serialize<T> / Deserialize<T> specialization works for both JSON and binary. You can serialize to JSON, deserialize from JSON, then re-serialize to binary (or vice versa) with no code changes:

// JSON -> Person -> binary
auto jd = json::Deserializer::from_string(json_str).unwrap();
Person p = serde::deserialize<Person>(jd).unwrap();

bin::Serializer ser;
serde::serialize(p, ser);
Vec<uint8_t> bytes = ser.into_buffer();

This is useful for transcoding at protocol boundaries (e.g. receive JSON from an HTTP API, store as binary in a cache).

Error Handling

Binary-specific failures:

ScenarioErrorKind
Truncated input (ran out of bytes mid-field)Eof
Invalid UTF-8 in StringInvalidValue
f64 is NaN/Inf on serializeInvalidValue
Option discriminator byte is neither 0x00 nor 0x01InvalidValue
Enum tag_index out of rangeInvalidValue

Unlike JSON, there is no "unknown field" concept — the reader consumes exactly the fields it expects, in order. Extra trailing bytes are silently ignored (the Deserializer stops at the last field, not at end of buffer).