CodeRigor

FREE LESSON

Static reflection (C++26)

Inspect types, members and enumerators at compile time with ^^ and std::meta, splice them back with [: :], and generate to_string, equality and hashing instead of writing them by hand.

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.

C++
#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).

Reflect, query, splice — all while compiling
^^ 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.
Reflect, query, splice — all while compiling
^^ 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.
C++
#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.

C++
#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, and unchecked() sees everything, private included.
  • std::define_static_array turns the std::vector<info> the query returns into a static array. Memory from new can't outlive constant evaluation, so the vector itself can't be stored or iterated by template 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.

C++
#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.

template for stamps out one copy of the body per member
The members are compile-time values, so each copy can splice its own m: value.[:m:] becomes value.retries, then value.timeout, then value.verbose. There is no run-time loop.
template for stamps out one copy of the body per member
The members are compile-time values, so each copy can splice its own 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:

C++
#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_of returns a string_view into static storage, so returning it is safe.
enum_to_string walks the declared enumerators, not the values
start / 1
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".
enum_to_string walks the declared enumerators, not the values
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?

C++
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.

C++
#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:

C++
#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:

  • bool and char before other arithmetic types (they print differently);
  • strings before ranges, because std::string is a range of char;
  • ranges before classes, because std::array is 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".

C++
#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 for is 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 to unprivileged() or current().
  • 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

  • ^^x gives a std::meta::info; [: r :] splices it back into code.
  • Reflections are consteval-only: keep them in constexpr variables and consteval functions.
  • Query with members_of, nonstatic_data_members_of(r, ctx), enumerators_of, identifier_of, type_of, annotations_of_with_type…
  • template for over std::define_static_array(...) to generate per-member code.
  • Annotations ([[=value]]) attach metadata; define_aggregate creates types.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Practical C++26 reflection (^^, std::meta, splicing) from one of P2996's authors.

Why introspection needs generation — reflection used to write real code.

P2996's design and what comes after it, from its lead author.

A short practical reflection example.

Generating new types with C++26 reflection (define_aggregate and friends).

C++26 `template for` (expansion statements): iterating over reflected members and packs at compile time.