C++ has always made you repeat yourself: an enum and its to_string, a struct
and its operator==, its hash, its serialiser. Every new member means
editing four places, and forgetting one is a silent bug. Static reflection
lets a program ask the compiler about its own declarations — "what are the
members of T?", "what is this enumerator called?" — and generate that code
from the answers, at compile time.
This lesson uses GCC 16 (-freflection, which the app passes for you). Clang 23
doesn't implement reflection yet, so the editor's clangd will underline ^^;
the GCC diagnostics are the ones that count.
1. Reflections are values
The reflection operator ^^ turns a name into a value of type
std::meta::info: an opaque handle to a type, a member, an enumerator, a
namespace… Functions in <meta> query it.
#include <meta>
#include <print>
#include <vector>
struct Point {
int x;
double y;
};
int main() {
constexpr std::meta::info t = ^^Point;
std::println("{}", std::meta::identifier_of(t)); // Point
std::println("{}", std::meta::display_string_of(^^std::vector<int>));
static_assert(std::meta::size_of(t) == sizeof(Point));
static_assert(std::meta::is_type(t));
}Reflections are consteval-only: they exist only while the compiler is
evaluating constants, never in the running program. So every variable holding
one must be constexpr, and every function computing with them is consteval
(or only called in constant evaluation).
^^ turns a declaration into a std::meta::info value; <meta> functions compute with those values; [: :] turns one back into code. None of the reflections exist in the running program — only the code they produced.#include <meta>
int main() {
std::meta::info r = ^^int; // error: consteval-only variable not declared constexpr
}Errors inside the metafunctions are reported as an uncaught
std::meta::exception at compile time. For example, identifier_of(^^int)
fails with "reflection with has_identifier false", because int is a keyword,
not an identifier.
2. Splicing: from reflection back to code
The splicer [: r :] turns a reflection back into the thing it reflects,
usable in ordinary code: a type, an expression, a member access.
#include <meta>
#include <print>
struct Point {
int x;
int y;
};
int main() {
constexpr auto members = std::define_static_array(
std::meta::nonstatic_data_members_of(^^Point, std::meta::access_context::unprivileged()));
Point p{3, 4};
p.[:members[1]:] = 10; // p.y = 10
std::println("{} {}", p.[:members[0]:], p.y);
typename [:^^Point:] q{1, 2}; // a type splice
std::println("{}", q.x + q.y);
}Two details from that snippet you'll use constantly:
nonstatic_data_members_of(r, ctx)takes an access context. In final C++26 it's mandatory.access_context::unprivileged()sees what any code may access (public members),current()sees what the calling scope may access, andunchecked()sees everything, private included.std::define_static_arrayturns thestd::vector<info>the query returns into a static array. Memory fromnewcan't outlive constant evaluation, so the vector itself can't be stored or iterated bytemplate for.
3. template for: a loop the compiler unrolls
An expansion statement repeats its body once per element, with each
element as a constexpr variable — so it can be spliced.
#include <meta>
#include <print>
struct Config {
int retries;
double timeout;
bool verbose;
};
template <class T>
void dump(const T& value) {
template for (constexpr auto m :
std::define_static_array(std::meta::nonstatic_data_members_of(
^^T, std::meta::access_context::unprivileged()))) {
std::println("{}: {} = {}", std::meta::identifier_of(m),
std::meta::display_string_of(std::meta::type_of(m)), value.[:m:]);
}
}
int main() { dump(Config{3, 1.5, true}); }dump works for any aggregate whose members are printable. Add a member to
Config and the output follows — there's nothing else to update.
m: value.[:m:] becomes value.retries, then value.timeout, then value.verbose. There is no run-time loop.4. Worked example: enum ↔ string
The classic. Iterate enumerators_of(^^E), compare the value, return the
name:
#include <meta>
#include <optional>
#include <print>
#include <string_view>
#include <type_traits>
template <class E>
requires std::is_enum_v<E>
constexpr std::string_view enum_to_string(E e) {
template for (constexpr auto en : std::define_static_array(std::meta::enumerators_of(^^E))) {
if (e == [:en:]) return std::meta::identifier_of(en);
}
return {}; // a value with no enumerator, e.g. Color{7}
}
template <class E>
requires std::is_enum_v<E>
constexpr std::optional<E> enum_from_string(std::string_view name) {
template for (constexpr auto en : std::define_static_array(std::meta::enumerators_of(^^E))) {
if (name == std::meta::identifier_of(en)) return [:en:];
}
return std::nullopt;
}
enum class Color { red = 1, green = 4, blue = 9 };
int main() {
static_assert(enum_to_string(Color::blue) == "blue"); // works at compile time
std::println("{} '{}'", enum_to_string(Color::green), enum_to_string(Color{7}));
std::println("{}", enum_from_string<Color>("red").has_value());
}Notes:
- Gaps in the values (1, 4, 9) don't matter: you iterate the declared enumerators, not a range of numbers.
- If two enumerators share a value (
low = 0, minimum = 0), the loop returns the first one declared. identifier_ofreturns astring_viewinto static storage, so returning it is safe.
enumerators_of(^^Color) yields three reflections in declaration order; each knows its name and its value. enum_to_string(Color{4}) compares 1, then 4 — match — and returns "green".Faded example — your turn
Write enum_count<E>() returning how many enumerators E declares, usable in
a static_assert. One line of body is enough. Which function gives you the
list?
template <class E>
consteval std::size_t enum_count() {
return /* ??? */;
}
static_assert(enum_count<Color>() == 3);Answer
return std::meta::enumerators_of(^^E).size(); — inside a consteval function the
vector never escapes, so no define_static_array is needed.
5. Annotations: metadata on declarations
C++26 annotations attach a constant value to a declaration with [[=value]].
Reflection can read them back. That's how a library lets users say "skip this
member" or "call this field user_id in JSON" without macros.
#include <meta>
#include <print>
struct Ignore {};
struct Session {
int user;
[[=Ignore{}]] int cache;
int expires;
};
int main() {
template for (constexpr auto m :
std::define_static_array(std::meta::nonstatic_data_members_of(
^^Session, std::meta::access_context::unprivileged()))) {
constexpr bool ignored = !std::meta::annotations_of_with_type(m, ^^Ignore).empty();
std::println("{}{}", std::meta::identifier_of(m), ignored ? " (ignored)" : "");
}
}The annotation's own type is reported as const Ignore. annotations_of(m)
returns them all; annotations_of_with_type(m, ^^Ignore) filters by type,
ignoring that const.
6. Generating types: define_aggregate
Reflection can also create declarations. define_aggregate completes a
declared-but-undefined class from a list of member specifications, inside a
consteval { } block:
#include <meta>
#include <print>
struct Pixel; // declared, not defined
consteval {
std::meta::define_aggregate(^^Pixel, {
std::meta::data_member_spec(^^unsigned char, {.name = "r"}),
std::meta::data_member_spec(^^unsigned char, {.name = "g"}),
std::meta::data_member_spec(^^unsigned char, {.name = "b"}),
});
}
int main() {
Pixel p{255, 128, 0};
std::println("{} {} {} size={}", p.r, p.g, p.b, sizeof(Pixel));
}That's the building block for "struct of arrays from an array of structs" and similar generators. Use it sparingly: generated types are harder to read than written ones.
7. Making utilities generic
Combine the pieces and you get the utilities every codebase hand-writes:
| Utility | Reflection used |
|---|---|
to_string(enum), from_string |
enumerators_of, identifier_of, [:en:] |
| debug printing / JSON | nonstatic_data_members_of, identifier_of, value.[:m:] |
member-wise == and hash |
the same loop, recursing into members |
| "skip this field" | annotations + annotations_of_with_type |
When you recurse through member types, put the if constexpr checks in the
right order:
boolandcharbefore other arithmetic types (they print differently);- strings before ranges, because
std::stringis a range ofchar; - ranges before classes, because
std::arrayis an aggregate and a range.
One more C++26 change you'll notice when printing numbers: std::to_string of
a floating-point value now gives the shortest round-trip form ("2.5"), like
std::format("{}"). It used to give "2.500000".
#include <print>
#include <string>
int main() { std::println("{} {}", std::to_string(2.5), std::to_string(0.1)); }8. Limits and costs
- Compile-time cost. Every
template foris unrolled per member and per instantiation. That's fine for structs with tens of members, but noticeable in big generic code. - Access is respected unless you opt out with
unchecked(). Libraries should default tounprivileged()orcurrent(). - Runtime behaviour is ordinary code. After expansion there's nothing left of reflection, so performance is the same as the hand-written version.
- Tooling lags. As of this writing only GCC 16 implements P2996 in this environment; Clang 23 and clangd don't.
Summary
^^xgives astd::meta::info;[: r :]splices it back into code.- Reflections are consteval-only: keep them in
constexprvariables and consteval functions. - Query with
members_of,nonstatic_data_members_of(r, ctx),enumerators_of,identifier_of,type_of,annotations_of_with_type… template foroverstd::define_static_array(...)to generate per-member code.- Annotations (
[[=value]]) attach metadata;define_aggregatecreates types.