Types and conversions¶
Most types need nothing written for them at all. Those that do are usually described rather
than converted — an annotation, or a describe specialization for a type from someone else's
header, both covered under Annotations. This page is the rest: what serpent
already knows, and what to write when neither of those fits.
What works with no code at all¶
Arithmetic types, bool, std::string and string-likes, std::optional, std::variant, any
range, any keyed container with string keys, ranges of std::byte (as binary), and enums — which go out
as their underlying number unless they
say otherwise. None of these need opting in, in either direction.
Describing a type is usually enough¶
Naming a type's fields is Annotations, whether or not you declared the type:
an annotation where you can attach one, and serpent::describe<T> where you cannot. Both give
the same keys, the same renaming and skipping, the same strictness, in every format and both
directions.
struct [[= serpent::serializable {}]] point { int x, y; }; // a type you declared
template<> struct serpent::describe<foreign_point> { // one you did not
static constexpr serpent::naming naming { serpent::naming_style::snake_case };
};
Reach for a conversion below only when the body has to be yours — because the two directions disagree, because the type is not an object at all, or because your compiler has no reflection.
When the conversion has to be yours¶
One function, both directions¶
When the body needs to do more than list members:
struct segment {
point start, end;
std::string label;
friend void json_convert(auto &visitor, serpent::conversion_object_t<decltype(visitor), segment> value) {
visitor.member("start", value.start);
visitor.member("end", value.end);
visitor.member("label", value.label);
}
};
visitor is reading or writing, and conversion_object_t resolves to segment & or
const segment & to match. You write the field list once.
Two functions, when the directions differ¶
Defaults on read, keys omitted on write, a value that is a string one way and a bool the other:
struct connection {
std::string host = "localhost";
std::optional<int> port {};
friend void to_json(auto &out, const connection &value) {
const auto scope = out.object();
scope.member("host", value.host);
if (value.port) scope.member("port", *value.port); // (1)!
}
friend bool from_json(const auto &source, connection &value) {
if (!source.is_object()) return false;
const connection defaults {};
value.host = source["host"].template as<std::string>().value_or(defaults.host);
value.port = source["port"].template as<int>();
return true;
}
};
- An empty optional writes no key at all, rather than
null.
Keep the body format-blind
Say out.value(x) and let the writer decide what that means. BJData may pack a
std::vector<std::uint16_t> into a typed array and copy it whole; JSON writes numbers.
Neither your type nor its author needs to know.
The macro, beside the type¶
Names the members from outside the type, in its own namespace, for a type you cannot annotate
on a toolchain that cannot reflect — where describe is unavailable for the same reason:
Specialize the serializer¶
Full control, including types that are not objects at all:
template<>
struct serpent::serializer<timestamp, void> {
template<typename Writer>
static void write(Writer &out, const timestamp &value) { out.value(value.unix_seconds()); }
template<typename Source>
static bool read(const Source &source, timestamp &value) {
const auto seconds = source.template as<std::int64_t>();
if (!seconds) return false;
value = timestamp::from_unix(*seconds);
return true;
}
};
Templated or concrete, and what each costs¶
The example above names its writer template<typename Writer>, which is the form to reach for:
it serves every format, including one added later, and a type written this way never has to be
revisited. The bodies must then live in the header, as any template's must.
Naming a concrete writer instead is legitimate and buys one thing — the definitions can move into
a .cpp:
// in the header
template<>
struct serpent::serializer<ip4, void> {
static void write(serpent::bjdata::writer &out, const ip4 &value);
static bool read(const serpent::bjdata::reader &source, ip4 &value);
};
What it costs is easy to miss: that type can now only be written to that one format. Nothing complains at the declaration; the error arrives at the first call site that writes it as something else — a log line, a status endpoint — and it reads as though the call is wrong rather than the conversion. Take the concrete form when a type is genuinely single-format and the compile-time cost of the header matters, and the templated form otherwise.
A type whose two formats need genuinely different behaviour is the remaining case, and that is
what the to_json pair is for: one overload per writer, chosen by ordinary overload resolution.
example/splicing.cpp does this — bytes spliced whole into BJData, transcribed into JSON.
Only one at a time¶
A type may carry exactly one of these forms. If it is opted in to reflection and has a hand-written conversion, that is a compile error rather than a silent ranking:
static assertion failed: this type is opted in to reflection and also has a
hand-written conversion. The hand-written one would be used and the annotation
would do nothing; remove whichever of the two you did not mean
The alternative would be worse: the annotation would sit on the type looking like it does something while the hand-written form quietly won. Migrating a type therefore means replacing one with the other in the same change, not adding the annotation and coming back later.
The one exception is a serializer<T> specialization. It replaces the dispatch entirely
rather than competing inside it, so it may coexist with an annotation and it wins — which is
what specializing it is for, and the only way to override a type whose definition you cannot
touch.
A field that can hold one of several types¶
std::variant needs no tag on the wire, because a value already says what it is:
struct setting {
std::string name;
std::variant<bool, std::int64_t, double, std::string> value;
friend void json_convert(auto &visitor, serpent::conversion_object_t<decltype(visitor), setting> value) {
visitor.member("name", value.name);
visitor.member("value", value.value);
}
};
Writing emits whichever alternative is held. Reading asks each alternative, in declaration
order, whether the value fits, and the first that accepts it wins. std::monostate is null,
so std::variant<std::monostate, T> behaves like an optional that is explicitly present.
Two rules follow from "first that fits":
- Declaration order is the tie-break.
variant<double, std::int64_t>reading4gives the double, because a whole number fits one. Put the more specific alternative first. - An object must name at least one of a type's members to be read as that type. Decoding
{"radius":9}into apointwould otherwise succeed and leave every member at its default, which is right for a missing key but useless for telling alternatives apart. Inside a variant it is not a match.
Alternatives that are genuinely indistinguishable — two structs with the same field names — cannot be resolved this way at all. Give them a name instead, with a discriminant:
struct [[= serpent::discriminant("unit")]] celsius { double value; };
struct [[= serpent::discriminant("unit")]] fahrenheit { double value; };
The alternative is then chosen by what the document calls it rather than by what parses, and a name that matches nothing fails instead of guessing.
Where the alternatives are not yours to annotate, tag the field instead — which also opts them in, so neither type needs to know serpent exists:
Both need reflection; without it, order remains the only tie-break.
Durations and times¶
<serpent/chrono.hpp>, included separately so <chrono> stays out of translation units that do
not want it, handles std::chrono::duration, time_point and hh_mm_ss:
struct [[= serpent::serializable {}]] sample {
std::chrono::milliseconds elapsed;
std::chrono::sys_time<std::chrono::milliseconds> at;
};
A duration travels as its count and nothing else. A time point is the duration since its clock's epoch, and a time of day the duration it was built from, so both follow whatever the duration does.
The unit is in the type, not on the wire
Nothing here can check that both ends named the same duration. Writing milliseconds and
reading seconds gives you the same number under a different name — silently wrong by a
factor of a thousand. Say which unit a field is in, in its name or in your schema, the way
you would for any other bare number.
Containers¶
Every container shape reads back in the shape it was written, and the two directions decide by the same question so they cannot drift apart:
| on the wire | read back by | |
|---|---|---|
vector, deque, list |
an array | growing at the back |
set, unordered_set |
an array | inserting |
array, and anything else of fixed size |
an array | assigning the slots it already has |
map with text keys |
an object | by key |
map with any other key |
an array of two-element arrays | by pairs |
vector<byte>, array<byte, N> |
binary, where the format has it | in place |
A fixed-size sequence is its length: a document of another length is refused rather than filled as far as it goes, since the rest would otherwise keep whatever a default-constructed one held.
A map whose keys are not text¶
A document's keys are text, so a map whose key type is not travels as a sequence of two-element arrays instead:
std::map<uuid, calibration> table; // [[key, value], [key, value], …]
std::map<std::string, int> named; // {"x": 1}
Both sides decide by the same question — whether the key converts to a string — so a container
reads back in the shape it was written. A std::pair on its own is the same two-element array,
which is what makes the sequence form work for any key type that can be serialized at all.
Missing and extra keys¶
A member the type needs has to be in the document. Keeping its default instead is how a half-specified document passes for a whole one, and nothing downstream can tell the difference between a field that said zero and a field that said nothing.
| a plain member is absent | an error |
a std::optional member is absent |
it is empty — absence is what the type represents |
a member marked [[= serpent::defaulted {}]] is absent |
it keeps the default it declared |
an optional marked [[= serpent::required {}]] is absent |
an error — the key must be stated, though its value may be null |
| a key is present that the type does not name | ignored |
| the keys are in a different order | read correctly, slightly slower |
So a format that gains a field can mark it defaulted and keep reading documents written before
it existed, and a type that must be fully specified gets that for nothing.
A whole type can say it, the way it says a naming rule — which is what a stored configuration usually wants, since a field added in a later version is simply absent from every file written before it, and such a file must still load rather than failing and losing every other setting with it:
struct [[= serpent::serializable {}, = serpent::defaulted {}]] user_config {
std::string host = "localhost";
int port = 8080;
[[= serpent::required {}]] int version = 1; // the exception
};
An optional is unaffected by the type-wide rule: absence is already what it represents, so only
required {} on the member itself changes anything there.
struct [[= serpent::serializable {}]] calibration {
double offset; // must be there
double scale; // must be there
[[= serpent::defaulted {}]] int revision = 1; // added later; older documents may omit it
std::optional<std::string> note; // may be absent or null
};
The check costs one bit set per member and one comparison per object — reflection knows how many
members there are, so the mask is a std::uint64_t and nothing is allocated or walked twice.
A hand-written json_convert gets the same guarantee from the same rule, without saying anything
extra: member() insists, and exempts a std::optional for the reason above. The member that may
be left out is the one that has to be spelled differently.
friend void json_convert(auto &visitor, conversion_object_t<decltype(visitor), settings> value) {
visitor.member("host", value.host); // must be there
visitor.member_if_present("retries", value.retries); // keeps whatever it already held
}
That way round on purpose: the strict answer is the one you get by not thinking about it, and
leniency is a thing you ask for by name. A to_json/from_json pair writes its own body and is
on its own, as it is for everything else.
A failed read names the member it wanted:
const auto decoded = serpent::json::try_decode<calibration>(text);
if (!decoded && decoded.error().code() == serpent::errc::missing_key) {
log("the document has no %s", decoded.error().key());
}
Naming it costs a second walk over the document, taken only on the way to reporting an error —
the generated reader knows a member was missing, not which one, and the check that told it that
costs a bit set per member. decode() skips all of it and returns nothing.