C++26 Reflection, Exploration
Every big C++ codebase eventually needs a serializer, and every one of them has been written by hand, generated by a Python script or other C++ code, or dragged in behind a 17-layer-deep macro. C++26 finally puts reflection in the language, and I wanted to give it a try. As of writing (16/08/2026) GCC 16.1.0 is the only compiler that supports it, poor MSVC :( So after downloading the correct version I set out to write a very basic struct to JSON writer.
The version I did not want to write
Here is the baseline. Given a struct, walk the fields by hand and hope you remember to update the writer when someone adds a field.
struct TestStruct {
int number;
float realNumber;
std::string str;
TestSubStruct sub;
std::vector<int> ids;
std::vector<Vec3> vecs;
};
void WriteJsonInto1( TestStruct data, std::stringstream & ss ) {
ss << "{";
ss << "number:\"" << std::to_string(data.number) << "\",";
ss << "realNumber:\"" << std::to_string(data.realNumber) << "\",";
ss << "str:\"" << data.str << "\"";
ss << "}";
}
It's fine, it works. Super boring, super tedious, super frustrating. Every time you add something to the struct you have to remember to add it to the serializer. But what if we could get the compiler to auto generate this code? Introducing C++26 reflection.
Three new pieces of syntax
Reflection in C++26 rests on a small number of additions. Everything below is built out of these.
The reflection operator ^^ takes a type, a namespace, a variable
or an expression and gives you back a value of type std::meta::info. This is a
normal constexpr value. You can put it in an array, pass it to a function, compare it. It just
happens to describe a piece of your program.
A splicer [: ... :] goes the other way. Give it an
std::meta::info and it turns back into the thing it describes, in whatever
position you wrote it. data.[:m:] is a member access.
typename [:t:] is a type.
Finally template for, an expansion statement. The compiler unrolls it at compile time and the loop variable
is a constant in each copy of the body.
Json Serializer 1
Putting those together and the field walk collapses into a few lines.
void WriteJsonInto2( TestStruct data, std::stringstream & ss ) {
constexpr auto TestType = ^^TestStruct;
static constexpr auto testMembers = std::define_static_array(
std::meta::nonstatic_data_members_of( TestType, std::meta::access_context::current() ) );
ss << "{";
template for ( constexpr auto m : testMembers ) {
if constexpr ( HasToString<typename [:std::meta::type_of(m):]> ) {
ss << "\"" << std::meta::identifier_of(m) << "\":\"" << ToString(data.[:m:]) << "\",";
}
};
ss.seekp(-1, std::ios_base::end);
ss << "}";
}
Reading it left to right: nonstatic_data_members_of hands back a
std::vector<std::meta::info>, one entry per field. That vector lives in the
constant evaluator and cannot escape into runtime on its own, so
std::define_static_array copies it into an array with static storage duration and
gives you a span over it. Otherwise you get a compile error.
The access_context::current() argument answers "who is asking". Reflection
respects access control, so a query from outside the class sees the public fields and a query
from a member function sees everything. So basically, do you want the public or the private variables?
Then the loop body does two things with each field. identifier_of(m) gives me the
field name as it was spelled in the source. data.[:m:] gives the field itself, as a real lvalue of the right
type.
The dispatch is if constexpr over a concept:
template<typename _type_>
concept HasToString = requires ( const _type_ & s ) {
{ ToString(s) } -> std::convertible_to<std::string>;
};
typename [:std::meta::type_of(m):] splices the field's type into the concept
check, so every unrolled copy of the body asks its own question and only the matching branch
is compiled. Reflection gives the members, and the concept and constraint machinery does the rest.
Json Serializer 2, going recursive
Nothing in that function is specific to TestStruct except the type name, so
replace it with a template parameter and reflect on that instead. ^^_type_ works
on a dependent type exactly as you would expect.
template<typename _type_>
void WriteJsonInto3( _type_ data, std::stringstream & ss ) {
constexpr auto Type = ^^_type_;
static constexpr auto testMembers = std::define_static_array(
std::meta::nonstatic_data_members_of( Type, std::meta::access_context::current() ) );
ss << "{";
template for ( constexpr auto m : testMembers ) {
ss << "\"" << std::meta::identifier_of(m) << "\":";
if constexpr ( HasToString<typename [:std::meta::type_of(m):]> ) {
ss << "\"" << ToString(data.[:m:]) << "\"";
} else {
WriteJsonInto3(data.[:m:], ss);
}
ss << ",";
};
ss.seekp(-1, std::ios_base::end);
ss << "}";
}
The else branch allows for sub-structs. A field the concept does
not recognise is assumed to be an object, so we recurse into it, and the recursion
instantiates a fresh version of the function for that field's type, which reflects on
its members, and so on down.
Vectors still come out wrong, because a std::vector<int> has no members you
care about and recursing into its internals is not what anyone wants.
Json Serializer 3, arrays
Better is to stop asking "does this have members" and start asking "what kind of JSON value is this". Split the writer in two. One function classifies a value, one function walks an object's fields, and they call each other.
template<typename _type_>
static void WriteJsonValue( const _type_ & value, std::stringstream & ss ) {
if constexpr ( std::is_same_v<_type_, bool> ) {
ss << ( value ? "true" : "false" );
} else if constexpr ( std::is_arithmetic_v<_type_> ) {
ss << ToString( value );
} else if constexpr ( HasToString<_type_> ) {
WriteJsonString( ToString( value ), ss );
} else if constexpr ( std::ranges::range<_type_> ) {
ss << "[";
bool first = true;
for ( const auto & element : value ) {
if ( !first ) { ss << ","; }
first = false;
WriteJsonValue( element, ss );
}
ss << "]";
} else {
WriteJsonObject( value, ss );
}
}
template<typename _type_>
static void WriteJsonObject( const _type_ & value, std::stringstream & ss ) {
constexpr auto Type = ^^_type_;
static constexpr auto members = std::define_static_array(
std::meta::nonstatic_data_members_of( Type, std::meta::access_context::current() ) );
ss << "{";
bool first = true;
template for ( constexpr auto m : members ) {
if ( first == false ) { ss << ","; }
first = false;
ss << "\"" << std::meta::identifier_of(m) << "\":";
WriteJsonValue( value.[:m:], ss );
};
ss << "}";
}
The ordering of those branches is important. bool has to come first
because it is arithmetic and would otherwise print as 1. Arithmetic goes in bare, since JSON
numbers are not quoted. Anything with a ToString is a string and gets escaped
properly. Anything that satisfies std::ranges::range is an array, which catches
std::vector, arrays and anything else that iterates. Whatever is left over is an
object, and objects are the only case that needs reflection at all.
Feeding the test struct, the output is what you would want:
{"number":2,"realNumber":5,"str":"GG",
"sub":{"boolean":true,"otherNumber":7},
"ids":[1,2,3],
"vecs":[{"x":1,"y":2,"z":3},{"x":4,"y":5,"z":6}]}
Vec3 was never mentioned by name in the serializer. Neither was TestSubStruct. I added
std::vector<Vec3> to the struct and the writer produced an array of objects
without any change.
Oddities
The static storage dance around nonstatic_data_members_of is unavoidable.
The function returns a vector because the constant evaluator is allowed to
allocate, but that allocation has to be gone by the end of constant evaluation.
define_static_array is the escape hatch that promotes it into the program image.
I'm not sure why they decided to make it return a vector, it seems like unnecessary boilerplate, but maybe there is a use case or implementation requirement?
The float output was strange: std::to_string(5.0f) printed 5, not
the 5.000000 I expected. Anyone who's dealt with float serialization to text like this knows that it usually writes as
5.000, which causes all sorts of pain with respect to floating point error, when minor drifts cause huge diffs in source control.
In C++26 however, they have redefined to_string for floating point. It now uses std::format,
which gives the shortest round-trippable output. Nice! FYI, one approach to fixing the floating point error is to write out the float in hex, though then it's not human readable.
Building it
This needs a compiler with the reflection paper implemented. I used a MinGW GCC build with the feature switched on:
g++ -std=c++26 -freflection -O2 -Wall -Wextra json_struct_reflection.cpp -o json_struct_reflection.exe
Conclusion
Serialization is the obvious demo, but it is only half of the job. Reading JSON back into a
struct means matching a runtime string against compile time field names, which the same
identifier_of loop can do with a chain of comparisons. But 90% of that is just parsing the text/JSON file,
which is just busy work, so I stopped here.
Beyond that, we can grab the enum names without having a hacky 100 line switch statement. Hurray!
There are also per-field annotations/tags, which allow a struct to say "skip this one" or "call it something else in the output".
[[JSON_SERIALIZE("Simplified name")]] for example.
All this will be a great help in serializing structs, populating UIs, network serialization, etc. We just have to wait for the other compilers to catch up, and hope the compile times don't explode! :D