Size assumptions: final_structure, self_describing, sized¶
By default every derived type is evolvable (Unsized): its serialized size isn't known up front, so when it is
nested inside another struct or enum, the parent stores a length for it (a nibble-based reverse length, like
UNib32). That length is what lets fields be added later without breaking anyone (see
evolving types).
Three directives opt a type out of that flexibility in exchange for a smaller wire size. They are mutually exclusive, and they are part of the type's wire contract: once released, a choice can't be undone. (Why the lengths exist at all: the FIFO of lengths.)
The options¶
| Directive | ElementSize |
Meaning | Trade-off |
|---|---|---|---|
| (none) | Unsized |
Size unknown, stored as a reverse length in the parent. | Fully evolvable. |
final_structure |
UnsizedFinalStructure |
Size still unknown, but the type is flattened onto its parent: no length is stored for it; its own lists still have theirs. | Can't evolve. Only the first version of a type can be final_structure, and it can't be removed later. |
self_describing |
SelfDescribing |
No size is stored; a reader can tell where the value ends from the encoding itself. | No overhead, but only for types whose encoding ends by itself (variable-length numbers, enums of unequal variants). |
sized |
Sized { size_bits } |
Size is fixed and known at compile time, computed from the fields. Nothing is stored for it, ever. | Can't evolve at all: adding, removing or resizing a field changes the layout for every reader. Most compact. |
A struct of one field can also take its field's size class with transparent
instead of one of these: a Count(u64) is Sized { size_bits: 64 }, a wrapped UNib32 SelfDescribing, a wrapped
&str UnsizedFinalStructure, with the bytes of the plain field. It excludes all three.
Free bits can still be used
A final_structure or sized type can still grow into the gaps it already has, which are zero by default. If
a bool is followed by a u8, 7 bits are unused and skipped by readers; a later version can put a bool or an
Option flag there, and old data reads as false / None.
What it looks like on the wire¶
ww_repr alone does not make an enum compact: without one of these directives it stays Unsized, exactly like
a struct.
#[derive_shrink_wrap(borrowed, derive(Debug, PartialEq), ww_repr = u2)]
enum SpeedUnsized { Slow, Medium, Fast, Turbo }
#[derive_shrink_wrap(borrowed, derive(Debug, PartialEq), ww_repr = u2, sized)]
enum SpeedSized { Slow, Medium, Fast, Turbo }
// A `Sized` field: its 2 bits are known up front, so the next field packs into the same byte.
#[derive_shrink_wrap(borrowed, derive(Debug, PartialEq))]
struct PayloadWithSized {
speed: SpeedSized,
after: bool,
}
// An `Unsized` field: the struct stores a length for it, and the next field starts on a fresh byte.
#[derive_shrink_wrap(borrowed, derive(Debug, PartialEq))]
struct PayloadWithUnsized {
speed: SpeedUnsized,
after: bool,
}
fn compact_vs_not() {
let mut buf = [0u8; 4];
// Sized, so (Slow, Turbo) packs into 2+2 = 4 bits of a single byte:
let bytes = (SpeedSized::Slow, SpeedSized::Turbo).to_ww_bytes(&mut buf).unwrap();
assert_eq!(bytes, &[0x30]);
// Unsized, standalone: no length needed, since nothing follows it.
let mut buf2 = [0u8; 4];
let bytes2 = SpeedUnsized::Turbo.to_ww_bytes(&mut buf2).unwrap();
assert_eq!(bytes2, &[0xC0]);
// 0b11_1_00000: Turbo (11), then `after` (1), then padding.
let mut buf3 = [0u8; 4];
let sized_payload = PayloadWithSized { speed: SpeedSized::Turbo, after: true };
let bytes3 = sized_payload.to_ww_bytes(&mut buf3).unwrap();
assert_eq!(bytes3, &[0xE0]);
// `speed` is padded to its own byte; `after` starts a new byte, and the length of
// `speed` (1) shares its low bits:
let mut buf4 = [0u8; 4];
let unsized_payload = PayloadWithUnsized { speed: SpeedUnsized::Turbo, after: true };
let bytes4 = unsized_payload.to_ww_bytes(&mut buf4).unwrap();
assert_eq!(bytes4, &[0xC0, 0x81]);
// bool and reverse length end up in the same byte, and there are even 3 bits left
}
Enums¶
Only one variant of an enum is on the wire, so its size is the discriminant (see
ww_repr) plus the fields of one variant:
sizedneeds every variant to take the same number of bits (unit variants take 0), and a fixed-size discriminant (anything butunib32).ELEMENT_SIZEis thenSized { size_bits: discriminant + variant }.-
Variants of different sizes make the enum
self_describing: the discriminant tells how long the value is. Asking forsizedis a compile error that names the variants and their sizes:
sized and self_describing give the same bytes, for the type and for whatever contains it, so switching between
the two is not a wire change.
Checked at compile time¶
Whichever assumption you pick, the macro inserts a compile-time assertion that the type's actual ELEMENT_SIZE
matches the one asked for. If a field turns out to make the type Unsized after all, the build fails instead of
silently producing an incompatible layout.
User types are counted wherever they appear in a field (Point, [Point; 3], (Point, u8), Option<Point>,
Range<Point>): the macro can't see their size, so it emits a const expression over their ELEMENT_SIZE and the
compiler folds it. Array lengths can be any const expression; a sized type's ELEMENT_SIZE carries them
symbolically.
size_bits is the sum of the fields' own bits. Alignment padding between fields depends on where the value starts
in the buffer and is not part of it: struct { a: bool, b: u8 } is Sized { size_bits: 9 } and takes 2 bytes on
its own. How sizes combine in detail: traits and ElementSize.