Let's play with C++ reflection

●5 ●23 ●89
calendar_today ago • schedule9 min read

Exploring the biggest change to C++.


Since the reflection proposal is implemented and usable in GCC 16, I decided to give it a shot and find out the good, the bad, and the (potentially) ugly. Let's explore what reflection can do for you with a personal pet-peeve of mine - JSON serialization.

To start with, let's compare a simple (de)serialization example in C++ using nlohmann::json library, and in C# using System.Text.Json:

// C++
#include <nlohmann/json.hpp>

struct Foo
{
    int Version = 1;
    std::string Data = "";
};

void to_json(nlohmann::json& j, const Foo& foo)
{
    j["version"] = foo.Version;
    j["data"] = foo.Data;
}

void from_json(const nlohmann::json& j, Foo& foo)
{
    if (j.contains("version")) j["version"].get_to(foo.Version);
    if (j.contains("data")) j["data"].get_to(foo.Data);
}

versus C#:

using System.Text.Json;

class Foo
{
    [JsonPropertyName("version")]
    public int Version { get; set; } = 1;

    [JsonPropertyName("data")]
    public string Data { get; set; } = string.Empty;
}

NOTE: I deliberately didn't use NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE because I wanted to express the optionality of each member and the different capitalization.

The difference is apparent at first glance. C++ version is imperative and requires a lot of boilerplate while C# is purely declarative and the entire logic is expressed via attribute annotations. This is exactly what reflection allows you to do - automatically enumerate members of a given object (class, struct, enum, ...), inspect all their properties and behave accordingly.

Now - how do we achieve this in C++ with the newly added reflection?

For the rest of the article, assume the following configuration - GCC compiler at version 16 or better, compiler flags -std=c++26 -freflection. Yes, reflection is one of those features you actively need to opt in through two different compiler flags, similar to stack trace. No idea why - this is one of those committee decisions that really grind my gears.

Defining user annotations

C++ supports annotations for quite a while and you've probably met some of them:

[[nodiscard]] bool foo(int value)
{
    if (value > 10) [[unlikely]] return false;
    return true;
}

Those annotations also work as key-value pairs:

[[deprecated("Replaced with bar()")]] void foo() {}

So naturally defining your own works out of the box, right?

struct Foo
{
    [[JsonPropertyName("version")]]
    int Version = 1;
};

This passes compilation, but emits a warning on certain compilers that the global namespace is reserved for standard annotations and that you should wrap them in a namespace (like msvs::intrinsic). Either way, this is not what we need. Instead, C++26 invented a new kind of annotation that is not just a key-value pair, but rather a custom struct (which is what C# does as well). Annoyingly, it comes with an extra operator:

struct JsonPropertyName
{
    // note: 'JsonPropertyName::Value' has a non-structural type
    std::string Value = "";
};

struct Foo
{
    // error: annotation does not have structural type
    [[=JsonPropertyName("version")]] // note the equal sign
    int Version = 1;
};

Okay, so this is the gist, but the code doesn't compile, what's wrong? The annotation needs to be a structural type, which is not easy to explain quickly. A very simple rundown is that it cannot contain private members, lambdas with captures, and anything that allocates memory. Full documentation is here.

struct JsonPropertyName
{
    const char* Value = "";
};

struct Foo
{
    [[=JsonPropertyName("version")]]
    int Version = 1;

    [[=JsonPropertyName("data")]]
    std::string Data = "";
};

Note that this code is still wrong, but it compiles for now, so let's proceed.

Reflecting

Let's write a simple mock function for generating a to_json. I am going to write this one as pseudocode that we can start fixing and shaping into a working code:

#include <meta>

template<class Serializable>
void to_json(nlohmann::json& j, const Serializable& foo)
{
    for (auto&& member : std::meta::members_of(^^Serializable))
    {
        const auto&& annotations = std::meta::get_annotations_of<JsonPropertyName>(member);
        if (annotations.empty())
            j[member.name] = foo.[:member:];
        else
            j[annotations.first().Value] = foo.[:member:];
    }
}

I already used a few real features from the new feature: the <meta> header, the ^^ operator for type reflection and the [::] operator for backward materialization, but so far I used them very liberally. The code obviously doesn't compile, let's start fixing it. Here are the correct functions that we are going to need:

  • std::meta::members_of -> std::meta::nonstatic_data_members_of
  • std::meta::get_annotations_of -> std::meta::annotations_of_with_type + std::meta::extract
  • member.name -> std::meta::identifier_of

std::meta::nonstatic_data_members_of also requires a second parameter of std::meta::access_context. This is another rather confusing concept which filters the results of certain std::meta functions from properties that are not visible/accessible under a given context. For example, using std::meta::access_context::unpriviledged() would yield only public members of a given class/struct. I am going to use unchecked, which returns everything:

template<class Serializable>
void to_json(nlohmann::json& j, const Serializable& foo)
{
    auto access_ctx = std::meta::access_context::unchecked();
    for (auto&& member : std::meta::nonstatic_data_members_of(^^Serializable, access_ctx))
    {
        auto&& annotations = std::meta::annotations_of_with_type(member, ^^JsonPropertyName);
        if (annotations.empty())
            j[std::meta::identifier_of(member)] = foo.[:member:];
        else
        {
            auto&& propertyName = std::meta::extract<JsonPropertyName>(annotations.first());
            j[propertyName.Value] = foo.[:member:];
        }
    }
}

Note that [:member:] refers to Serializable::memberName, but when prefixed with a concrete instance as in foo.[:member:], it refers to the member of that specific instance. Or in other words: foo.Serializable::memberName is a valid C++ code. Right now, the function compiles... until you call it:

int main()
{
    auto&& foo = Foo
    {
        .Version = 2,
        .Data = "hello world"
    };

    std::println("{}", nlohmann::json(foo).dump(4));
}

which yields tons of errors:

error: consteval-only variable 'access_ctx' not declared 'constexpr' used outside a constant-evaluated context
    auto access_ctx = std::meta::access_context::unchecked();
    note: add 'constexpr'

error: consteval-only variable '__for_range ' not declared 'constexpr' used outside a constant-evaluated context
    for (auto&& member : std::meta::nonstatic_data_members_of(^^Serializable, access_ctx))
    note: add 'constexpr'

error: call to consteval function 'std::meta::nonstatic_data_members_of(^^Foo, access_ctx)' is not a constant expression
    for (auto&& member : std::meta::nonstatic_data_members_of(^^Serializable, access_ctx))

error: the value of 'access_ctx' is not usable in a constant expression
    note: 'access_ctx' was not declared 'constexpr'
    auto access_ctx = std::meta::access_context::unchecked();

error: call to consteval function '(&<anonymous>)->std::vector<std::meta::info>::~vector()' is not a constant expression
    for (auto&& member : std::meta::nonstatic_data_members_of(^^Serializable, access_ctx))
... and much more ...

Constexpr all the things

So, what do these errors tell you? They are trying to tell you that the reflection is a compile-time feature and therefore all code needs to adhere to rules of writing constexpr code. This means a bunch of things, for starters that all local variables coming from std::meta API need to be marked constexpr. Furthermore the for loop itself needs to be marked as running in compile-time.

C++ already has a similar feature where you can evaluate if condition in constexpr environment by writing if constexpr, so naturally with the for it is the same and you write... template for. WTF?! I really don't understand why we cannot write for constexpr to keep some sort of style with if constexpr, but for now, template for it is.

Funnily enough, as I was debugging and researching for this article using various chatbots, Bing Chat was probably trained on a proposal other than the one accepted and it cited various functions such as std::meta::name_of and also the construct for constexpr. Shame it didn't pass.

Another utterly weird thing is that while you should almost always use auto&&, it a) does not imply constexpr when needed, b) constexpr auto&& doesn't compile and you need to use constexpr auto. With the exception of declaration of member in the for loop, that one is okay as constexpr auto&&. The code now looks like this:

template<class Serializable>
void to_json(nlohmann::json& j, const Serializable& foo)
{
    constexpr auto accessCtx = std::meta::access_context::unchecked();
    template for (constexpr auto&& member : std::meta::nonstatic_data_members_of(^^Serializable, accessCtx))
    {
        constexpr auto&& annotations = std::meta::annotations_of_with_type(member, ^^JsonPropertyName);
        if (annotations.empty())
            j[std::meta::identifier_of(member)] = foo.[:member:];
        else
        {
            constexpr auto&& propertyName = std::meta::extract<JsonPropertyName>(annotations[0]);
            j[propertyName.Value] = foo.[:member:];
        }
    }
}

It still doesn't compile though, with this rather cryptic error:

error: 'std::meta::nonstatic_data_members_of(^^Foo, access_ctx)' is not a constant expression because it refers to a result of 'operator new'
template for (constexpr auto&& member : std::meta::nonstatic_data_members_of(^^Serializable, access_ctx))

The reason is that nonstatic_data_members_of returns std::vector and it either is not yet fully constexpr, or it just plain doesn't work for this use case. Either way, you have to wrap the result of this function in std::define_static_array. Same goes for the annotations variable:

template<class Serializable>
void to_json(nlohmann::json& j, const Serializable& foo)
{
    constexpr auto accessCtx = std::meta::access_context::unchecked();

    template for (constexpr auto&& member : std::define_static_array(std::meta::nonstatic_data_members_of(^^Serializable, accessCtx)))
    {
        constexpr auto annotations = std::define_static_array(
            std::meta::annotations_of_with_type(member, ^^JsonPropertyName));

        if (annotations.empty())
            j[std::meta::identifier_of(member)] = foo.[:member:];
        else
        {
            constexpr auto propertyName = std::meta::extract<JsonPropertyName>(annotations[0]);
            j[propertyName.Value] = foo.[:member:];
        }
    }
}

Almost there. We are getting one more cryptic error:

error: uncaught exception of type 'std::meta::exception'; 'what()': 'reflect_constant failed'
constexpr auto propertyName = std::meta::extract<JsonPropertyName>(annotations[0]);

Which pertains to the definition of JsonPropertyName:

struct JsonPropertyName
{
    const char* Value = "";
};

This is technically valid C++ for as long as you are putting only compile-time constants into the Value (otherwise the pointer might be freed and dangle), but the compiler rejects it for the constexpr environment. Instead, we need to define some reasonable max size for this property:

struct JsonPropertyName
{
    const char Value[32];
};

Don't worry - if you make this too small, compiler would error out with a nice message for once:

error: initializer-string for 'const char [2]' is too long [-fpermissive]
[[=JsonPropertyName("version")]]

One extra gotcha

The code compiles... until you add an un-annotated property to the struct:

struct Foo
{
    [[=JsonPropertyName("version")]]
    int Version = 1;

    [[=JsonPropertyName("data")]]
    std::string Data = "";

    bool Flag = true;
};

which yields:

in 'constexpr' expansion of 'annotations.std::span<std::meta::info const, 18446744073709551615>::operator[](0)'
constexpr auto propertyName = std::meta::extract<JsonPropertyName>(annotations[0]);

error: call to non-'constexpr' function 'void std::__glibcxx_assert_fail(const char*, int, const char*, const char*)'
__glibcxx_assert(__idx < size());

This extremely eloquent error is trying to tell you that the condition if (annotations.empty()) was not marked constexpr and therefore the execution fell into the else branch, trying to access item at index 0 of an empty array.

Remember me yapping about if constexpr vs template for? Yep, mark this condition with constexpr and you're good to go:

{
    "Flag": true,
    "data": "hello world",
    "version": 2
}

Closing thoughts

In the past two years, I've made a few attempts to read the proposals and try them out in Compiler Explorer, but I was never able to find the correct proposal and/or working compiler. Maybe I am stupid, but I don't know how to identify the correct proposal and therefore I could not propose any changes to it.

If I could, I would change the following:

  • template for -> for constexpr: both are retarded to read, latter would be consistent with if constexpr.
  • auto&& should decay to constexpr auto when appropriate. Or at the very least, constexpr auto&& should consistently compile.
  • Iteration over std::meta::nonstatic_data_members_of should work out of the box, not after wrapping it in std::define_static_array (same applies to std::meta::annotations_of_with_type and similar functions)
  • It is weird that else branch of non-constexpr if condition gets evaluated in constexpr context.
  • As cute as ^^ (aka "cat ears operator") might be, I would prefer having something like reflexp function for the same purpose. Similar to typeid.

Reflection in C++ sadly feels like a completely different language that one needs to master atop of the regular 2-3 C++ dialects we already have. At least for now, the compiler support is very poor (GCC merged, Clang on feature branch, MSVC ETA unknown, AppleClang ETA unknown) so you can safely ignore it for at least one or two more years.

The full code is here and you can play with it on Compiler Explorer:

#include <string>
#include <nlohmann/json.hpp>
#include <meta>
#include <print>

struct JsonPropertyName
{
    const char Value[32];
};

struct Foo
{
    [[=JsonPropertyName("version")]]
    int Version = 1;

    [[=JsonPropertyName("data")]]
    std::string Data = "";

    bool Flag = true;
};

template<class Serializable>
void to_json(nlohmann::json& j, const Serializable& foo)
{
    constexpr auto accessCtx = std::meta::access_context::unchecked();

    template for (constexpr auto member : std::define_static_array(std::meta::nonstatic_data_members_of(^^Serializable, accessCtx)))
    {
        constexpr auto annotations = std::define_static_array(
        std::meta::annotations_of_with_type(member, ^^JsonPropertyName));

        if constexpr (annotations.empty())
            j[std::meta::identifier_of(member)] = foo.[:member:];
        else
        {
            constexpr auto propertyName = std::meta::extract<JsonPropertyName>(annotations[0]);
            j[propertyName.Value] = foo.[:member:];
        }
    }
}

int main(){
    auto&& foo = Foo{
        .Version = 2,
        .Data = "hello world"
    };

    std::println("{}", nlohmann::json(foo).dump(4));
}

Note that I dropped the auto&& from member declaration for consistency.

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Tuesday Coding Tip 06 - Explicit template instantiation

Jakub Neruda - Apr 7

Tuesday Coding Tip 02 - Template with type-specific API

Jakub Neruda - Mar 10

Local-First: The Browser as the Vault

Pocket Portfolio - Apr 20

Tuesday Coding Tip 17 — Wrapping C APIs

Jakub Neruda - Jun 30

Tuesday Coding Tip 05 - Object initialization in C++

Jakub Neruda - Mar 31
chevron_left
2.1k Points • 117 Badges
Brno, Czech Republic • linkedin.com/in/jakub-neruda
42Posts
35Comments
22Connections
Experienced C++ developer, team lead and hobby gamedev. I enjoy writing stuff about C++, clean code, clean architecture, and game development.

Related Jobs

Commenters (This Week)

2 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!