Ch24.3: Reflection in Practice
1. Enum Names, Both Directions
The enum example from the first page is worth writing completely, because it shows the guard for an incomplete enum and the reverse direction:
#include <meta>
#include <optional>
#include <string_view>
#include <type_traits>
template <typename E>
requires std::is_enum_v<E>
constexpr std::string_view enum_to_string(E value)
{
if constexpr (std::meta::is_enumerable_type(^^E))
{
template for (constexpr auto e :
std::define_static_array(std::meta::enumerators_of(^^E)))
{
if (value == [:e:])
{
return std::meta::identifier_of(e);
}
}
}
return "<unnamed>";
}
template <typename E>
requires std::is_enum_v<E>
constexpr std::optional<E> string_to_enum(std::string_view name)
{
if constexpr (std::meta::is_enumerable_type(^^E))
{
template for (constexpr auto e :
std::define_static_array(std::meta::enumerators_of(^^E)))
{
if (name == std::meta::identifier_of(e))
{
return [:e:];
}
}
}
return std::nullopt;
}
is_enumerable_type is the guard for a forward-declared enum. An enum can be
declared before its enumerators are known; querying enumerators_of at that
point is an error. The if constexpr makes the function compile for both cases and
return the fallback when the list is not available.
The generated code is a chain of comparisons, one per enumerator. It is not a hash table and it does not allocate. For a small enum that is exactly what you want. For a large enum, you can build a static lookup table instead:
template <typename E>
requires std::is_enum_v<E>
constexpr auto make_enum_table()
{
constexpr auto names = std::define_static_array(
std::meta::enumerators_of(^^E));
std::array<std::pair<E, std::string_view>, names.size()> table{};
template for (constexpr std::size_t i{}; auto e : names)
{
table[i] = {[:e:], std::meta::identifier_of(e)};
}
return table;
}
The table is a normal constexpr array. You can sort it, binary-search it, or
generate a perfect hash in a later build step. Reflection gives you the data; the algorithm
is ordinary C++.
2. A Field Visitor
The generic field printer is the "hello world" of reflection:
#include <meta>
#include <print>
template <typename T>
void print_fields(T const &obj)
{
constexpr auto ctx = std::meta::access_context::current();
constexpr auto members = std::define_static_array(
std::meta::nonstatic_data_members_of(^^T, ctx));
template for (constexpr auto m : members)
{
std::print("{} = {}\n",
std::meta::identifier_of(m),
obj.[:m:]);
}
}
It works for any aggregate whose members are printable. It skips nothing: static members are
not in nonstatic_data_members_of, but base classes are not automatically
traversed. If you need bases, recurse over bases_of.
The generated code is exactly what you would have written by hand:
std::print("name = {}\n", obj.name);
std::print("age = {}\n", obj.age);
std::print("height = {}\n", obj.height);
The difference is that adding a member does not require editing the printer. The compiler sees the new member and generates the new line. That is the whole return on investment.
3. JSON: A Complete Small Serializer
JSON is a good stress test because it needs names, types and recursion. Here is a compact version for aggregates of strings, numbers, booleans and nested aggregates:
#include <concepts>
#include <meta>
#include <string>
#include <string_view>
#include <type_traits>
template <typename T>
std::string to_json(T const &obj);
inline void append_json(std::string &out, std::string const &v)
{
out += '"';
out += v;
out += '"';
}
inline void append_json(std::string &out, std::string_view v)
{
out += '"';
out += v;
out += '"';
}
inline void append_json(std::string &out, char const *v)
{
append_json(out, std::string_view{v});
}
inline void append_json(std::string &out, bool v)
{
out += v ? "true" : "false";
}
template <std::integral T>
void append_json(std::string &out, T v)
{
out += std::to_string(v);
}
template <std::floating_point T>
void append_json(std::string &out, T v)
{
out += std::to_string(v);
}
template <typename T>
void append_json(std::string &out, T const &v)
{
out += to_json(v);
}
template <typename T>
std::string to_json(T const &obj)
{
std::string out{"{"};
constexpr auto ctx = std::meta::access_context::current();
constexpr auto members = std::define_static_array(
std::meta::nonstatic_data_members_of(^^T, ctx));
bool first{true};
template for (constexpr auto m : members)
{
if (!first)
{
out += ',';
}
first = false;
out += '"';
out += std::meta::identifier_of(m);
out += "\":";
append_json(out, obj.[:m:]);
}
out += '}';
return out;
}
The overload set is the policy layer. Reflection supplies the member names and the field
accesses; the overloads decide how a std::string, an int or a
nested aggregate becomes JSON. Add a type and you add an overload, not a member list.
This serializer deliberately ignores escaping. A real JSON writer must escape quotes, backslashes and control characters, reject non-finite floating-point values, and decide what to do with unknown types. Reflection does not make those decisions for you. It only removes the "walk the fields" boilerplate.
4. Generating a Config Type
define_aggregate is useful when the shape of a type is data. A configuration
schema, a database row, a protocol message with a fixed field list — all of these can be
described as a list of members and turned into a real C++ aggregate:
#include <meta>
#include <string>
struct config;
consteval
{
std::meta::info fields[]
{
std::meta::data_member_spec(^^std::string, {.name = "host"}),
std::meta::data_member_spec(^^int, {.name = "port"}),
std::meta::data_member_spec(^^bool, {.name = "tls"})
};
std::meta::define_aggregate(^^config, fields);
}
int main()
{
config c{.host = "example.com", .port = 443, .tls = true};
return c.port == 443 ? 0 : 1;
}
A schema-driven generator would read the field list from a file at build time, but the
compile-time core is the same: build a std::vector<std::meta::info> of
data_member_spec results, then call define_aggregate. The result is
a normal aggregate, usable with designated initializers and with the JSON serializer above.
The limits are important: define_aggregate produces a simple aggregate. It does
not generate constructors, member functions, virtual functions, access specifiers or bases.
If the generated type needs behavior, write a separate wrapper or a free function that takes
the aggregate.
5. Layout and ABI Checks
Reflection can answer layout questions at compile time:
template <typename T>
consteval bool has_padding()
{
constexpr auto ctx = std::meta::access_context::current();
auto members = std::meta::nonstatic_data_members_of(^^T, ctx);
std::size_t end{};
for (auto m : members)
{
auto off = static_cast<std::size_t>(std::meta::offset_of(m).bytes);
auto size = std::meta::size_of(m);
if (off != end)
{
return true;
}
end = off + size;
}
return end != std::meta::size_of(^^T);
}
This is not a replacement for a platform ABI document. It is a compile-time guard: if a struct that must be layout-compatible with a wire format or a C ABI changes, the assertion fires at build time instead of producing a corrupt packet at run time.
The same data supports a generated description, a static assertion on offsets, or a test that compares the C++ layout with a C header. Reflection turns "we hope the compiler lays this out the same way" into a check the compiler performs.
6. Designing a Reflection Library
A production reflection library is mostly policy, not syntax. The structure that works is:
- One generic traversal. Reflect the members once; call a customization point for each member. Do not write a separate loop in every serializer.
- One customization point. A function or a concept that says how to handle a leaf type. The traversal decides the order; the customization decides the representation.
-
Bases as a separate traversal.
nonstatic_data_members_ofdoes not include bases. If the format is flat, visit bases explicitly withbases_ofand recurse. - Annotations for policy. Annotations can mark a field as optional, renamed, secret or versioned. The traversal reads them; the serializer obeys them. Keep the annotation vocabulary small.
-
Static storage for tables. Use
define_static_arrayordefine_static_stringwhen a generated table must outlive a single constant expression.
The anti-pattern is a different reflection loop per format. That is just hand-written code with extra steps. The point of reflection is to have one traversal and many formats.
7. Compile-Time Cost and Debugging
Reflection is compile-time work. A header that reflects a large type graph in every translation unit will slow the build, and a deeply recursive serializer can hit constexpr evaluation limits. A few habits keep it manageable:
- Keep the reflected type set small in headers that many translation units include.
-
Cache generated tables in a
static constexprvariable or a variable template, so the compiler evaluates them once per specialization. -
Use
if constexprto guard incomplete types and unsupported categories before querying them. - Prefer one traversal with a customization point over many specialized traversals.
-
When a diagnostic points at a splice, reduce it to the smallest reflection expression and
print the reflection's
display_string_ofin a test.
Debugging reflection is different from debugging templates. The error often appears at the
splice site, not at the query that produced the bad reflection. A small
static_assert on is_class_type, is_enum_type or
has_identifier before the splice turns a wall of errors into one clear message.
Key Takeaways
-
Reflect with
^^, query withstd::meta, splice with[: :], unroll withtemplate for. -
std::define_static_arraypromotes a compile-time list into static storage so an expansion statement can use it. -
is_enumerable_typeguards enums that are only forward-declared. -
define_aggregategenerates simple aggregates, not arbitrary classes. - One traversal, one customization point, many formats. That is the library design that pays off.
- Reflection removes boilerplate, not policy. Escaping, optional fields, versions and errors are still your design.
The final page covers compiler support, feature-test macros, the limits of the current implementations, and how to adopt reflection without betting the build on it.