Design¶
No intermediate representation¶
The point is what it does not do. Nothing is inflated from bytes into an object graph and then converted into your classes. You get forward iterators into the bytes you already have, and decoders that go straight from those into your types.
A tree you can build freely sits on top of this, in <serpent/value.hpp>, for the
documents whose shape is not known until they are written. Nothing here includes it, and it is not
what any of this is built out of.
Three layers¶
| Namespace | Knows about | Holds |
|---|---|---|
serpent |
no format | kind, errc, sinks, the emitter, the customization layer |
serpent::json |
JSON | scanner, reader, writer |
serpent::bjdata |
BJData | markers, view, writer, N-D, block notation |
serpent::msgpack |
MessagePack | format bytes, reader, writer |
serpent depends on neither format. Each format depends only on serpent. One header,
serpent/bjdata/json.hpp, knows both — it renders a BJData document as JSON.
Customizations are dumb, writers are smart¶
A customization names fields and hands over values. Choosing markers, measuring whether a packed array beats a generic one, and taking the memcpy path are the writer's business.
friend void to_json(auto &out, const samples &value) {
const auto scope = out.object();
scope.member("readings", value.readings); // that is all
}
From that line BJData may write a packed [$u# array copied in one go, or a generic one when
that is smaller; JSON writes plain numbers. A format added later needs no change here.
Anything a customization had to ask the writer about would be a capability that belonged in
value() and was not there yet.
Templated, not erased¶
to_json and from_json are templated on the writer and reader, so a format added later
needs no new overload and the writer keeps its concrete API. The cost is one instantiation per
type per format actually used:
| per type | |
|---|---|
| one format | 1,234 B |
| both formats | 2,376 B |
Only types actually used with both pay it. Erasing the writer would remove that cost, at the price of the concrete API that the zero-copy paths are built on.
Why a reader cannot be copied¶
A reader is a handle to one value and holds nothing of its own - but it does keep a note of how
far a walk of that value got, so that the iterator that owns it can step past it without walking
it again. A copy would carry a note of its own that the iterator never sees, and a loop written
for (auto child : ...) would quietly cost twice what for (auto &child : ...) costs. Deleting
the copy makes the compiler say so. Nothing is shared between traversals or between threads, and
nothing points at anything that can die before it.
Correctness¶
Checked against a corpus of 44 documents encoded by another implementation, each with the values it holds, its block notation and its JSON alongside:
- the block notation matches, token for token;
- the view decodes to the same values;
- written again by this library, preferring size and preferring speed, it decodes to the same values again;
- splicing reproduces the document byte for byte;
- the JSON output matches, but for a very large or very small real, which this library writes with an exponent, and parsing that JSON gives the same values again.
Every suite also truncates its documents at each byte offset and requires a clean rejection, under AddressSanitizer and UndefinedBehaviorSanitizer. Every header compiles on its own.