Easter sale up to 25% off | 24 MAR — 3 APR

Simple declarative reflection in C++20

21 December 2022

Almost every real-world application requires a lot of similar operations such as saving its internal state, making API requests, logging events, etc. This leads to writing hundreds of lines of code to convert classes to JSON, serializing to various formats, and so on.

But if we knew the internal structure of classes, then we could write generic functions for all use cases and get the desired features quickly and easily.

Current C++ doesn’t provide a way to get the list of the fields of an arbitrary class. There is a powerful Reflection TS but it’s still a work in progress and not yet merged into one of the future standards of C++ (as of the end of 2022).

In this article, we explore how to describe fields in a declarative way and write functions to read and write them.

Design

What storage can be used for field information?

To make actual types available to field manipulation functions, the field information class should be a template itself, so the only standard container suitable for this is std::tuple.

The list of fields should be defined using a template variable that can be overridden to make it possible to define fields for external classes too. But for easiness of use field list can also be defined inside the class for which we define fields like this:

struct point
{
    float x;
    float y;    
    // Reflection information in the class itself
    static constexpr std::tuple reflection{
        field{ "x", &point::x },
        field{ "y", &point::y },
    };
};

For both methods to work the following definition is enough:

template <typename T>
inline constexpr const auto& reflection_of = T::reflection;

With this, we can add reflection to built-in types or other types we can’t modify.

template <typename T1, typename T2>
inline constexpr const std::tuple reflection_of<std::pair<T1, T2>> = {
    field{ "first", &std::pair<T1, T2>::first },
    field{ "second", &std::pair<T1, T2>::second },
};

If reflection_of will be instantiated with a cv-qualified type (reflection_of<const point>), the specialization won’t work. To fix this, the following lines should be added:

template <typename T>
inline constexpr const auto& reflection_of<const T> = reflection_of<T>;
template <typename T>
inline constexpr const auto& reflection_of<volatile T> = reflection_of<T>;
template <typename T>
inline constexpr const auto& reflection_of<T&> = reflection_of<T>;
template <typename T>
inline constexpr const auto& reflection_of<T&&> = reflection_of<T>;

As for the field class, it is a template struct that holds the name and the pointer to the struct member.

template <typename Class, typename FieldType>
struct field {
    std::string_view name;
    FieldType Class::*pointerToField;
    constexpr field(std::string_view name, FieldType Class::*pointerToField) noexcept
        : name(name), pointerToField(pointerToField) {}
};

A deduction guide is required to be able to omit template parameters for field class because std::string_view doesn’t match a string literal and automatic deduction won’t work.

template <std::size_t N, typename Class, typename FieldType>
field(const char (&)[N], FieldType Class::*) -> field<Class, FieldType>;

Enumerating fields

This template variable contains the number of fields in the list:

template <typename T>
constexpr inline std::size_t reflection_num_fields =
    std::tuple_size_v<std::remove_cvref_t<decltype(reflection_of<T>)>>;

To call a function for each field of the supplied class we need multiple calls to std::get<>, so the index sequence should be generated.

namespace internal {
template <typename T, typename Class, typename... FieldType, std::size_t... Indices,
          typename Fn>
inline constexpr void for_each_field(T&& val, const std::tuple<field<Class, FieldType>...>& fields,
                                      std::integer_sequence<std::size_t, Indices...>, Fn&& fn) {
    (static_cast<void>(fn(std::get<Indices>(fields).name, val.*(std::get<Indices>(fields).pointerToField))),
     ...);
}
}
template <typename T, typename Fn>
inline constexpr void for_each_field(T&& val, Fn&& fn) {
    internal::for_each_field(std::forward<T>(val), reflection_of<T>,
                             std::make_index_sequence<reflection_num_fields<T>>{}, std::forward<Fn>(fn));
}

static_cast<void> here saves us from executing any comma operators defined for the function return type. Yes, we still need this workaround in C++20.

As the result, the lambda fn will be called with field name and, potentially const-qualified, reference to the field storage.

Now the following code will print the field names with the corresponding field values:

for_each_field(std::make_pair("answer", 42), [](std::string_view name, auto value) {
    std::cout << name << "=" << value << std::endl;
});

Result:

first=answer
second=42

Adding attributes

The following example shows how easy is to add custom attributes to struct fields:

struct attr_mask {
    uint32_t mask;
};

struct attr_json_name {
    std::string_view json_name;
};

struct color_rgba32 {
    uint8_t red{};
    uint8_t green{};
    uint8_t blue{};
    uint8_t alpha{};

    // Reflection information with extra fields
    static constexpr inline std::tuple reflection{
        field{ "red", &color_rgba32::red, attr_mask{ 0x000000FFu }, attr_json_name{ "r" } },
        field{ "green", &color_rgba32::green, attr_mask{ 0x0000FF00u }, attr_json_name{ "g" } },
        field{ "blue", &color_rgba32::blue, attr_mask{ 0x00FF0000u }, attr_json_name{ "b" } },
        field{ "alpha", &color_rgba32::alpha, attr_mask{ 0xFF000000u }, attr_json_name{ "a" } },
    };
};

How it’s implemented?

For this to work, we should add the attribute list to the template parameters of the field class, inherit from it, and add initialization for parent classes.

template <typename Class, typename FieldType, typename... Attributes>
struct field : public Attributes... {
    std::string_view name;
    FieldType Class::*pointerToField;

    constexpr field(std::string_view name, FieldType Class::*pointerToField,
                    Attributes... attr) noexcept((std::is_nothrow_move_constructible_v<Attributes> && ...))
        : Attributes(std::move(attr))..., name(name), pointerToField(pointerToField) {}
};

Deduction guide should be slightly changed too:

template <std::size_t N, typename Class, typename FieldType, typename... Attributes>
field(const char (&)[N], FieldType Class::*, Attributes...) -> field<Class, FieldType, Attributes...>;

Turning attributes into base classes for field allows accessing field attributes in a uniform way as if they were regular members of field class.

Example:

for_each_field<color_rgba32>(
    [](std::string_view name, const field<color_rgba32, uint8_t, attr_mask, attr_json_name>& field) {
        std::cout << name << " (mask: " << std::hex << std::setw(8) << std::setfill('0') << field.mask
                    << std::dec << ", json: " << field.json_name << ")" << std::endl;
    });
  • field.name contains the name of the field (string_view).
  • field.pointerToField contains the pointer to data member.
  • field.json_name contains json name (inherited from attr_json)
  • field.mask contains bit mask (inherited from attr_mask)

This is how the updated for_each_field function should look like:

namespace internal {
template <std::size_t Index, typename T, typename Class, typename FieldType, typename... Attributes,
          typename Fn>
inline constexpr void call_fn(T&& val, const field<Class, FieldType, Attributes...>& fld, Fn&& fn) {
    if constexpr (std::is_invocable_v<Fn, std::string_view, FieldType&,
                                      field<Class, FieldType, Attributes...>>)
        fn(fld.name, val.*(fld.pointerToField), fld);
    else
        fn(fld.name, val.*(fld.pointerToField));
}
template <typename T, typename Class, typename... FieldType, typename... Attributes, std::size_t... Indices,
          typename Fn>
inline constexpr void for_each_field(T&& val,
                                     const std::tuple<field<Class, FieldType, Attributes...>...>& fields,
                                     std::integer_sequence<std::size_t, Indices...>, Fn&& fn) {
    (call_fn<Indices>(val, std::get<Indices>(fields), fn), ...);
}
}

Lambda calling is moved to function call_fn which checks what arguments can be passed to the lambda before calling it.

The fact that field is inherited from attributes allows lambdas to have the following prototypes (example for color_rgba32 class):

[](std::string_view name, const uint8&, const field<color_rgba32, uint8_t, attr_mask, attr_json_name>& field){ ... }
[](std::string_view name, const uint8&, auto field){ ... }
[](std::string_view name, const uint8&, const attr_mask& mask){ ... }
[](std::string_view name, const uint8&, const attr_json_name& mask){ ... }

Full code

Some of the utility functions have been omitted from the code for simplicity. For the full code, see Mirror, a (micro) declarative reflection library that is available at https://github.com/kfrlib/mirror under MIT license.

The library requires c++20 but can easily be backported to c++17 which only lacks the following functions and type traits: countr_zero, remove_cvref_t.