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:

  1. One generic traversal. Reflect the members once; call a customization point for each member. Do not write a separate loop in every serializer.
  2. 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.
  3. Bases as a separate traversal. nonstatic_data_members_of does not include bases. If the format is flat, visit bases explicitly with bases_of and recurse.
  4. 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.
  5. Static storage for tables. Use define_static_array or define_static_string when 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:

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

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.