Reflection¶
The compiler already knows what your fields are called. Where it can tell you, serpent uses that and you write no conversion code at all.
struct [[= serpent::serializable {}]] point {
int x = 0;
int y = 0;
};
json::encode(point { 3, 4 }); // {"x":3,"y":4}
That is the whole opt-in. The same type now reads and writes in every format.
A base's members are the object's members¶
A type that inherits is written whole — base members first, then its own, which is the order they were declared in and the order anyone listing them by hand would write:
struct [[= serpent::serializable {}]] identified {
int id = 0;
};
struct [[= serpent::serializable {}]] equipment : identified {
std::string name;
};
Only public bases, which falls out of the library asking the compiler what it can see: a base a type did not expose is not part of what it presents to the world.
A key follows the naming rule of the type that declared it, so a derived type cannot restyle the keys a base already settled — those are someone else's wire format, and changing them from a derived declaration would be a silent break.
Adjusting the keys¶
Identifiers are rarely the keys you want on the wire, so a type can carry a naming rule and a field can override or opt out of its own:
struct [[= serpent::serializable {},
= serpent::naming { serpent::naming_style::snake_case }]] link_config {
[[= serpent::key("ip")]] std::string ipAddress = "0.0.0.0";
[[= serpent::skip {}]] int cacheGeneration = 0;
int mtuBytes = 1500; // becomes "mtu_bytes"
};
| Annotation | On | Does |
|---|---|---|
serializable {} |
a type | opts it in |
naming {style} |
a type | derives every key from the identifiers |
key("...") |
a field | overrides one key |
skip {} |
a field, or an enumerator | leaves it out of the document, both directions — and an enumerator so marked is not a candidate when choosing what to write, which is what a sentinel aliased onto a real value needs |
required {} |
an optional field | the key must be present, though its value may be null |
defaulted {} |
a plain field, or a type | the document may omit it, and it keeps its declared default; on a type it means every member, and required {} on one of them is the exception |
omit_empty {} |
an optional field, or a type | an empty optional writes no key, rather than null; on a type it means every optional the type declares, and required {} on one of them is the exception |
discriminant("key") |
a type | names it on the wire under key, so a variant can select it |
tagged("key") |
a variant field | the same, decided at the field instead of on the alternatives |
as(...) |
an enumerator | what it is on the wire, where its identifier will not do |
fallback {} |
an enumerator | the one to use when nothing matches, in both directions |
enum_as_name {...} |
a field | writes the enumerations in it by name, whatever they say themselves — see At the field |
enum_as_number {...} |
a field | writes them as numbers, whatever they say themselves |
with<Codec> {} / with { write, read } |
a field | writes and reads it with these instead of by its type — see A codec for one field |
Styles: as_written, snake_case, screaming_snake_case, kebab_case, camel_case,
pascal_case.
Annotations are ordinary values rather than parsed strings, which is why they are written with
= and braces, and always qualified.
Naming a type on the wire¶
A std::variant is read by asking each alternative whether the value fits, which cannot
separate two types that have the same members. A discriminant gives the type a name instead:
struct [[= serpent::discriminant("unit")]] celsius { double value; };
struct [[= serpent::discriminant("unit")]] fahrenheit { double value; };
struct [[= serpent::discriminant("unit", "K")]] kelvin { double value; };
using temperature = std::variant<celsius, fahrenheit, kelvin>;
The name defaults to the type's own identifier, which reflection already knows; the second argument overrides it. It is not a member — nothing declares it, reading a type on its own ignores it, and it is written before the fields.
Decoding a variant then reads that key and picks the alternative it names. A name that matches nothing fails, rather than falling back to a guess.
Tagging at the field instead¶
A discriminant on the type needs the alternatives to be yours to annotate. Where they are not, put the tag on the field:
struct point { int x, y; }; // someone else's header
struct circle { int radius; };
struct [[= serpent::serializable {}]] drawing {
std::string title;
[[= serpent::tagged("kind")]] std::variant<point, circle> body;
};
Naming the alternatives here is also what opts them in, so neither type needs to know serpent exists. They are named by their own identifiers unless you say otherwise:
Because the tag belongs to the field, the same types can be tagged differently in different places, which a type-level annotation cannot do.
Alternatives that are not objects¶
Only an alternative written as an object of its own members can carry a name — there is nowhere to put one on a number. Those keep the untagged behaviour, which is all they need:
A number is recovered as a number, and the objects are still told apart by name. This holds for
the type-level discriminant too: alternatives that cannot carry one do not stop the ones that
can from being named. A name that matches no alternative still fails, rather than falling
through to the untagged attempt.
Enumerations¶
An enumeration that says nothing goes out as its underlying number. Annotate it and the enumerators go by their identifiers instead:
Where an identifier is not what the wire calls it, say so on the enumerator. Note the position: an enumerator's attribute follows its name.
enum class [[= serpent::serializable {}]] fix_dimension {
none [[= serpent::fallback {}, = serpent::as(nullptr)]],
two_dimensional [[= serpent::as("2d")]],
three_dimensional [[= serpent::as("3d")]],
};
as takes null, a boolean, a whole number, a real or a string, and they may be mixed within
one enumeration — a stop-bit count that is 1, 1.5 and 2 is written exactly so, and a
word length maps to 5, 6, 7, 8 regardless of its underlying values. The type's
naming rule applies to the identifiers it falls back on, so
notConnected under kebab_case is "not-connected".
Sentinels aliased onto real values
A C API often declares bounds among its enumerators, and they are often equal to a real
one — ESP_PARTITION_SUBTYPE_APP_OTA_MIN is APP_OTA_0, and it is declared first.
Mark the bound skip {} and it stops being a candidate, so the value writes under the name
it has rather than the bound's identifier:
app_ota_min [[= serpent::skip {}]] = 0x10,
app_ota_0 [[= serpent::as("ota_0")]] = 0x10, // what 0x10 writes as
Declaration order then stops mattering, which is the point: nothing about the wire should depend on which alias the header happened to put first.
fallback {} names one enumerator as the answer when nothing else fits, in both directions:
a value on the wire matching no enumerator reads as it, and an enumeration value that is not any
enumerator — cast in from a number — writes as it. Without one, an unrecognised value fails to
read rather than guessing.
Absent, null, unrecognised¶
Four things a document can do to an enumeration member, and they stay four:
| The document | The member gets |
|---|---|
| leaves the key out | whatever its own initialiser said, if it may be left out at all |
| says a value the enumeration names | that enumerator |
| says a value it does not name | the fallback {}, or the read fails if there is none |
says null |
an enumerator annotated as(nullptr), else the fallback {}, else the read fails |
The default is at the member and the fallback is on the enumeration, which is the right way round: the default is what this field means when unsaid, so two structs holding the same enumeration may disagree about it, and it lives in the initialiser where a C++ programmer looks for it.
struct [[= serpent::serializable {}]] listener {
[[= serpent::defaulted {}]] auth_method auth = auth_method::hotspot;
};
Null is a value the table does not name, so a fallback catches it
Only a fallback does. Without one, null fails the read exactly as a plain int member
does when the document says null — serpent's answer for a field where null is meaningful
is std::optional, which reads it as empty and needs no enumerator spent on it.
So a fallback is a total function from every wire value to an enumerator, null included.
If you want null to mean one enumerator and unrecognised values another, say
as(nullptr) on the one null means; it is matched before the fallback is considered.
Two enumerations may give the same spelling different meanings, which a rule derived from the
identifiers could not: rtk_float is "float" in one and "rtk_float" in another.
At the field¶
An enumeration that has nothing to do with serialization need not say how it is written: the field holding it can. The same enumeration may then be a name in one document and a number in another.
enum class probe_state { idle, busyNow, lost }; // says nothing
struct [[= serpent::serializable {}]] status {
[[= serpent::enum_as_name {}]] probe_state probe; // "busyNow"
[[= serpent::enum_as_name { serpent::naming_style::snake_case }]] probe_state last; // "busy_now"
[[= serpent::enum_as_number {}]] link_kind link; // a number, though link_kind is named
};
An enumerator is its own as(...) where it has one, else its identifier under the field's naming
rule, else the enumeration's. Entries change single enumerators for this field only — a new
spelling, skip {} to keep one off the wire, fallback {} to replace the enumeration's own:
[[= serpent::enum_as_name {
serpent::enum_entry { probe_state::idle, "waiting" },
serpent::enum_entry { probe_state::lost, "gone", serpent::fallback {} },
}]] probe_state probe;
[[= serpent::enum_as_number { serpent::enum_entry { reachability::unknown, -1 } }]] reachability reach;
The field's choice reaches every enumeration inside an optional, a sequence, a set, a pair or a map, keys included; the containers are written as they always are. The checks an annotated enumeration gets — no two enumerators the same on the wire, one fallback at most — are made again for each field, over the mapping as the field leaves it.
A codec for one field¶
with hands a field to something else to write and read. A codec is a type with the two static
functions a serializer<T> specialization has, so an
existing specialization serves as it is; two lambdas that capture nothing will also do:
[[= serpent::with<seconds_since_boot> {}]] std::chrono::milliseconds uptime;
[[= serpent::with<serpent::serializer<timestamp>> {}]] timestamp started;
[[= serpent::with {
[](auto &out, const int &value) { out.value(value + 100); },
[](const auto &source, int &value) {
const auto held = source.template as<int>();
if (held) value = *held - 100;
return held.has_value();
},
}]] int offset;
The codec is given the field whole — on a std::vector<T> it gets the vector. Whether the
document must carry the field is decided as for any other, by required {} and defaulted {}.
A type you cannot annotate¶
An annotation cannot go on a type declared in someone else's header. serpent::describe<T> is
the same place for that type, from outside: specializing it is the opt-in, and what it carries
is what the annotations carry, under the same names. There is one trait to learn whether you are
describing a struct, an enumeration, or only a naming rule.
Every member, by a naming rule alone¶
A description that says nothing about individual members puts every member on the wire, exactly
as serializable alone would:
template<>
struct serpent::describe<foreign_reading> {
static constexpr serpent::naming naming { serpent::naming_style::snake_case };
};
That is the whole opt-in — the specialization existing is what turns the type on, so a description with nothing in it at all is valid and means "every member, named as written".
Members that need to say more¶
A members table names the type's members from outside, taking each key from the declaration
rather than from a string you restate — so only the exceptions are written down, and one list
serves both directions:
template<>
struct serpent::describe<esp_netif_ip_info_t> {
static constexpr serpent::member_entry members[] {
^^esp_netif_ip_info_t::ip, // key is "ip"
{ ^^esp_netif_ip_info_t::netmask, serpent::key("mask") }, // the exception
{ ^^esp_netif_ip_info_t::gw, serpent::defaulted {} },
};
};
No serializer<T> is involved; the type now reads and writes in every format, strictly, exactly
as an annotated one does. A member may carry key, skip, required or defaulted in any
order — the annotations, spelled the same and meaning the same — and the table may carry a
type-wide naming rule beside the list.
A table is held to its type
A table must account for every member the compiler can see. Leave one out and the build stops, naming it:
error: static assertion failed: this serpent::describe table does not name every member
of its type. Not named: gateway. List them, or say { member, serpent::skip {} } to keep
one off the wire deliberately, or say `static constexpr bool partial = true` if the table
is meant to be a subset
So a member added by an SDK upgrade is caught at the build that picks it up, rather than quietly disappearing from everything you write. A table that is meant to be a subset says so once:
Where the list comes from is up to you¶
The list is an ordinary constexpr range of members, so hand-picking and taking all of them are the same primitive rather than two features:
all_members_of is a convenience and nothing more — it fills the same sequence from the
compiler's own list and runs the same walk. Narrowing is <ranges> and <meta> code you already
know, not a filtering vocabulary this library invented.
An enumeration¶
An annotation cannot go on an enumeration declared in someone else's header, so the table goes
in a specialization instead. It carries exactly what the annotations carry — a value per
enumerator, of any of the kinds as takes, and one fallback:
template<>
struct serpent::describe<uart_stop_bits_t> {
static constexpr serpent::enum_entry<uart_stop_bits_t> values[] {
{ UART_STOP_BITS_1, 1, serpent::fallback {} },
{ UART_STOP_BITS_1_5, 1.5 },
{ UART_STOP_BITS_2, 2 },
{ UART_STOP_BITS_MAX, serpent::skip {} }, // a sentinel, never on the wire
};
};
Both forms run the same lookup, so null, the mixed kinds and the fallback behave identically whichever way an enumeration was mapped. The table needs no reflection at all — it is the way to map an enumeration on a toolchain that has none.
A table is held to its type
Where reflection is available, the table is checked against the enumerators the compiler can see, and one it does not name is a compile error naming the one it missed:
error: static assertion failed: this serpent::describe table does not name every
enumerator of its type. Not named: UART_PARITY_MARK. Give each a value, or say
{ enumerator, serpent::skip {} } to keep it off the wire deliberately
Ownership only blocks attaching an annotation to an enumerator; it does not block enumerating them. So an enumerator added by an SDK upgrade is caught at the build that picks it up, rather than quietly reading and writing as the fallback.
What it does not reach¶
Only non-static data members can be named this way. A wrapper whose values live behind a pointer,
a member that is computed rather than stored, or a type that is one scalar rather than an object
has no declaration for a table to point at — those want a serializer<T>.
Unlike an enumeration's table, a members table needs reflection:
addressing a member without naming it is a splice.
Requirements¶
Reflection needs three C++26 papers together — P2996 (reflection), P1306 (template for) and
P3394 (annotations). GCC 16 has all three, behind a flag:
No other released compiler has the full set yet. The CMake build probes for it and builds the reflected tests only where it works, so a project can carry annotated types before every compiler it targets has caught up:
serpent::reflection_available is the same answer as a constexpr bool. example/reflection.cpp
shows a type written both ways.
The annotation is not ignorable, so a shared header needs the guard
On a compiler that does not know the syntax the annotation is a parse error, not an attribute that is quietly dropped:
So a header compiled by more than one toolchain cannot simply carry the annotation and let
older compilers skip it — it has to carry both forms behind #if SERPENT_HAS_REFLECTION.
Verified against GCC 15.2 (the ESP-IDF v6.1 toolchain), which rejects it, and GCC 16.1,
which accepts it.
GCC 16 without -freflection is the case to watch: it parses the annotation and then
ignores it, so the type is not reflected and the first thing to complain is the converter,
at the point of use. The message says so, but check -freflection is really on the command
line before believing the type is at fault.
What the compiler refuses to let you get wrong¶
The list of members, and what each one is called on the wire, is knowable where reflection is — and nowhere else. So a mistake about the relationship between a type and its document is a build error rather than something you find by reading a document that came out wrong:
| Refused | Because |
|---|---|
| two members that are the same key | one would overwrite the other reading, and both would be written |
| an enumeration with two enumerators of the same wire value | whichever was written, only one could ever be read back |
more than one fallback {} |
nothing says which |
a serpent::describe table that does not name every enumerator |
the one it missed would quietly become the fallback |
required {} on a member that is not an optional |
it is required already; the annotation says nothing |
required {} and defaulted {} together |
they are opposites |
skip {} beside key("…") |
a member that is not in the document has no key |
as(…) or naming {…} on a data member |
both belong somewhere else |
a field's enum_as_name / enum_as_number making two enumerators one wire value, or naming two fallbacks |
the same as on the enumeration, checked per field |
enum_as_name / enum_as_number on a field with no enumeration in it |
there is nothing for it to do |
more than one of with, enum_as_name, enum_as_number and tagged on one field |
nothing says which |
Each names what it found:
error: static assertion failed: two members of this type are the same key on the wire: port.
One would overwrite the other reading, and both would be written; rename one with
serpent::key, or leave one out with serpent::skip
error: static assertion failed: this enumeration cannot be read back as it is written:
off and standby are the same value on the wire
Two of these hold without reflection, because a serpent::describe table is ordinary data:
duplicate entries and a second fallback are refused wherever the table compiles. The rest need
the member or enumerator list, so on a toolchain without reflection they are simply absent —
the code still builds and the mistake still ships. Build once with reflection somewhere, and it
is caught.
Reflection and a hand-written conversion are exclusive¶
Annotating a type that already has a json_convert, or a to_json / from_json pair, is a
compile error rather than a silent ranking — see only one at a
time.
Why opting in is deliberate¶
Reflecting every aggregate that merely lacks a json_convert would turn any struct that
happens to be serializable into a wire-format commitment, silently. Adding a field would
change the wire; so would reordering. Annotating the type makes that a decision.
Limitations¶
- Acronyms convert naively:
IPAddressbecomesipaddress, notip_address. Usekey("ip_address")where it matters. - A
keyis capped at 63 characters, because an annotation's type has to be structural and so stores an array rather than a pointer. - Only non-static data members are enumerated, in declaration order.
The naming rules themselves are ordinary constexpr code and are compiled and tested everywhere, reflection or not: