C++ Reflection: a Universal Printer

C++ Reflection: a Universal Printer

By Lieven de Cock

Overload, 34(193):8-13, June 2026


C++26 introduced reflection. Lieven de Cock demonstrates how to print information about class members using this new feature.

Our mission

The challenge of this article is to write a universal printer/formatter, so that we can format (nearly) any type we have. By that we mean print out the values (and names) of all its members, including the members of base classes.

Let’s get started.

The Lift operator

The first thing we need to do is move from the code domain to the reflection domain, and that is done by applying the ‘lift operator’ (^^) to a specific type.

Say we have a type Coordinate:

  struct Coordinate
  {
    int x{10};
    int y{20]:
    int z{30};
  };

Let’s reflect on it with ^^Coordinate. This gives us an object of type std::meta::info, and this contains a lot of information we can inspect by calling several inspection/reflection methods.

The members of a struct

We can query for all the members of a struct/class by calling the nonstatic_data_members_of() method.

This method requires the std::meta::info we obtained by reflecting, and also a second argument. We will see later on what we can do with that second argument. For now, let’s pass std::meta::access_context::current().

  constexpr auto ctx = 
    std::meta::access_context::current();
  nonstatic_data_members_of(^^Coordinate, ctx);

Let’s store the outcome of that method in an array, so we can loop over it. We’ll stuff them in a static array:

  std::define_static_array(
    nonstatic_data_members_of(^^Coordinate, ctx));

And we can loop over it, in a range-based for-loop style… only we don’t use a regular for, but the template for (see Listing 1).

constexpr auto ctx = 
  std::meta::access_content::current();
template for (constexpr auto member ;
  std::define_static_array(
   nonstatic_data_members_of(^^Coordinate, ctx)))
{
  //do something with 'member'
}
Listing 1

member is the meta info on each member of our struct. This means we can inspect that using other methods.

A few things come to mind, which are useful for our goal:

  • the name of the member: identifier_of(member)
  • the value of the member: hmmmmm…, there is no method for this.

Alright, we cannot inspect the value during reflection (compile time) since the value will be determined at run-time, so we need to switch back from the reflection domain to the code domain (using the splice operator: [: :]) and use our regular specification.

So, on our instance (co) of our type (Coordinate) we need to identify the correct member – something like t.x or t.y – so x or y, but what are we looking at during the template for loop, we look at ‘member’, so let’s switch that ‘member’ back to the code domain with the above mentioned operator: [:member:], giving a statement like: t.[:member:].

Putting these things together we end up with Listing 2:

#include <meta>
#include <print>
struct Coordinate
{
    int x{10};
    int y{20};
    int z{30};
};
int main()
{
  Coordinate co;
  constexpr auto ctx = 
    std::meta::access_context::current();
  template for (constexpr auto member :
    std::define_static_array(
      nonstatic_data_members_of(^^Coordinate,
                                ctx)))
  {
    std::println("{} : {}", 
      identifier_of(member), co.[:member:]);
  }
  return 0;
}
Listing 2

which gives the following output.

  x: 10
  y: 20
  z: 30

Well, look at that. ☺ See this live at: https://compiler-explorer.com/z/31qhbxMc8

Different types of access_context

Let’s make a little change, and make the z coordinate ‘private’.

  struct Coordinate
  {
    int x{10};
    int y{20]:
  
  private:
    int z{30};
  };

Now our output becomes:

  x: 10
  y: 20

See this live at: https://compiler-explorer.com/z/Tax1K7YbY

So, we don’t get the private members anymore with our current() context. In order to get them back, we need to use a different access_context: unchecked().

  constexpr auto ctx = 
    std::meta::access_context::unchecked();

And now we have our full output again.

See this live at: https://compiler-explorer.com/z/qK91qP71e

Custom formatter

With the above test program we have put down the basics for implementing a custom formatter for our Coordinate, and more generic for any type. So let’s implement a custom formatter, which can be used as follows:

  Coordinate co;
  const auto asString = std::format("{}", co);
  std::println("{}", co);

We need to implement two methods for a custom formatter for our type T:

  • parse
  • format

The first one is very easy. We will not support any format specifiers and as such our parse implementation is:

  struct UniveralFormatter
  {
    constexpr auto parse(auto& ctx) {
      return ctx.begin(); }
  };

Next up is the format method:

  struct UniversalFormatter
  {
    template <typename T>
    auto format(T const& t, auto& ctx) const
    {
      // code to be inserted here
    }
  }

Two things to observe:

  • we go for fully generic ctx via auto (meaning not restricting to just char-based stuff)
  • for the first parameter, we use the traditional template way (so we have a name for our type (T) )

We have to print a lot of things during the format, for that purpose we make a reference to the output iterator of the format context:

  auto out = ctx.out();

And several std::format_to calls will use it.

We will print everything on one line, and members are separated by a comma, but we want to avoid a leading or trailing comma. So, in our loop, we will start by inserting a comma, unless it is the very first member.

This can be solved with a lambda that holds the state ‘to know if it is the first time or not’, and puts the comma (or not) in the out (which it captures by reference):

  auto delim = [first = true, &out]() mutable
  {
    if (!first)
    {
      std::format_to(out, ", ");
    )
    first=false;
  };

Putting some things together gives us Listing 3.

template <typename T>
auto format(T const& t, auto& ctx) const
{
  auto out = ctx.out();
  std::format_to(out, "{}{{",
    identifier_of(^^T));
  auto delim = [first = true, &out]() mutable
  {
    if (!first)
    {
      std::format_to(out, ", ");
    }
    first = false;
  };
  constexpr auto access_ctx = 
    std::meta::access_context::unchecked();
  template for (constexpr auto member :
    define_static_array(nonstatic_data_members_of
    (^^T, access_ctx)))
  {
    delim();
    std::format_to(out, ".{} = {}",
      identifier_of(member), t.[:member:]);
  }
  std::format_to(out, "}");
  return out;
 }
Listing 3

In Listing 4 is the entire code example we have created and, as an extra, we created another struct CoordinatePair, which contains two Coordinate members and a double. We are printing out a Coordinate struct, and CoordinatePair struct (the latter of course requires the former to be formattable.

#include <meta>
#include <format>
#include <print>

struct UniversalFormatter
{
  constexpr auto parse(auto& ctx) {
    return ctx.begin(); }
  template<typename T>
  auto format(const T& t, auto& ctx) const
  {
    auto out = ctx.out();
    std::format_to(out, "{}{{", 
      identifier_of(^^T));
    auto delim = [first = true, &out]() mutable
    {
      if (!first)
      {
        std::format_to(out, ", ");
      }
      first = false;
    };
    constexpr auto access_ctx 
      = std::meta::access_context::unchecked();
    template for (constexpr auto member :
      define_static_array(
      nonstatic_data_members_of(^^T,
      access_ctx)))
    {
      delim();
      std::format_to(out, ".{} = {}", 
        identifier_of(member), t.[:member:]);
    }
    std::format_to(out, "}}");
    return out;
  }
};

struct Coordinate
{
  int x{10};
  int y{20};
private:
  int z{30};
};

template <> struct std::formatter<Coordinate> 
: UniversalFormatter { };

struct CoordinatePair
{
  Coordinate co1;
  Coordinate co2;
  double dbl{2.42};
};

template <> struct std::formatter<CoordinatePair> : UniversalFormatter { };

int main()
{
  std::println("{}", Coordinate());
  std::println("{}", CoordinatePair());
}
Listing 4

See it live at: https://compiler-explorer.com/z/aGTjseG11

Notice how we need to ‘unlock’ the formatter for each type we want to use it for:

  template <> struct std::formatter<Coordinate> 
  : UniversalFormatter { };
  template <> struct std::formatter<CoordinatePair> 
  : UniversalFormatter { };

It gives the following output:

  Coordinate{.x = 10, .y = 20, .z = 30}
  CoordinatePair{.co1 = Coordinate{.x = 10, 
  .y = 20, .z = 30}, .co2 = Coordinate{.x = 10, 
  .y = 20, .z = 30}, .dbl = 2.42}

Inheritance

When printing a derived class, we want the base class members to be included. So we need to reflect/inspect over the base classes. This can be done by fetching those via the method bases_of(^^T, access_ctx).

Again, an access_ctx comes into play (public versus private inheritance). Re-applying the recipe for looping over the members, but now over the bases, we have:

  template for (constexpr auto base 
    : define_static_array(bases_of(^^T,
    access_ctx)))
  {
    delim();
    std::format_to(out, "{}",
     (typename [: type_of(base) :] const&)(t);
  }

We are casting our instance t to the base class. But that requires we know the type of the base class (in the code world), which is given to us by: type_of(base) and next we splice that one back into the code world. Note the keyword typename, which is needed here.

Don’t forget to have custom formatters for the two base types:

  template <> struct std::formatter<Base1> 
    : UniversalFormatter { };
  template <> struct std::formatter<Base2> 
    : UniversalFormatter { };

Our updated code example is in Listing 5.

#include <meta>
#include <format>
#include <print>

struct UniversalFormatter
{
  constexpr auto parse(auto& ctx) {
    return ctx.begin(); }

  template<typename T>
  auto format(const T& t, auto& ctx) const
  {
    auto out = ctx.out();
    std::format_to(out, "{}{{",
      identifier_of(^^T));

    auto delim = [first = true, &out]() mutable
    {
      if (!first)
      {
        std::format_to(out, ", ");
      }
      first = false;
    };

    constexpr auto access_ctx 
      = std::meta::access_context::unchecked();

    template for (constexpr auto base :
      define_static_array(bases_of(^^T,
      access_ctx)))
    {
      delim();
      std::format_to(out, "{}",
        (typename [: type_of(base) :] const&)
        (t));
    }

    template for (constexpr auto member :
      define_static_array(
      nonstatic_data_members_of(^^T,
      access_ctx)))
      {
        delim();
        std::format_to(out, ".{} = {}",
          identifier_of(member), t.[:member:]);
      }
      std::format_to(out, "}}");
      return out;
  }
};

struct Base1
{
  int time{100};
};

struct Base2
{
  int fifthDimension{5};
};

struct Coordinate: public Base1, private Base2
{
  int x{10};
    int y{20};
private:
  int z{30};
};

template <> struct std::formatter<Base1> 
  : UniversalFormatter { };
template <> struct std::formatter<Base2> 
  : UniversalFormatter { };
template <> struct std::formatter<Coordinate> 
  : UniversalFormatter { };

struct CoordinatePair
{
    Coordinate co1;
    Coordinate co2;
    double dbl{2.42};
};

template <> struct std::formatter<CoordinatePair>
  : UniversalFormatter { };

int main()
{
    std::println("{}", Coordinate());
    std::println("{}", CoordinatePair());
}
Listing 5

See this live at: https://compiler-explorer.com/z/c9znYKx44

It gives the following output:

  Coordinate{Base1{.time = 100}, 
  Base2{.fifthDimension = 5}, .x = 10, .y = 20, 
  .z = 30}
  CoordinatePair{.co1 = Coordinate{Base1{.time 
  = 100}, Base2{.fifthDimension = 5}, .x = 10,
  .y = 20, .z = 30}, .co2 = Coordinate{Base1{
  .time = 100}, Base2{.fifthDimension = 5},
  .x = 10, .y = 20, .z = 30}, .dbl = 2.42}

There’s more, to be complete

What if a type does not have a name, or member does not have a name…

Let’s create a type with no name

  struct Foo
  {
    int a{0};
    struct
    {
      int iii{242};
    } b;
  }

The member b, of Foo, has a type with no name. How does this print, without making any changes.

Well, it no longer compiles: https://compiler-explorer.com/z/3aYG79hWb, and see Figure 1.

Figure 1

We are asking for an identifier for a type which has no identifier. Let’s agree, we will call it "unnamed" in this case. All that remains to be solved is, how do we know if there is an identifier or not ?

Maybe, just maybe, there is a method for that. Well, rest assured, there is:

  has_identifier(...)

This allows us to refactor our code at the spot where we write out the name of the type, since when that type is unnamed it does not have an identifier.

  std::string_view typeName = "unnamed";
  if constexpr (has_identifier(^^T))
  {
    typeName = identifier_of(^^T);
  }
  std::format_to(out, "{}{{", typeName);

The full program looks like Listing 6.

#include <meta>
#include <format>
#include <print>

struct UniversalFormatter
{
  constexpr auto parse(auto& ctx) {
    return ctx.begin(); }

  template<typename T>
  auto format(const T& t, auto& ctx) const
  {
    auto out = ctx.out();

    std::string_view typeName = "unnamed";
    if constexpr (has_identifier(^^T))
    {
      typeName = identifier_of(^^T);
    }
    std::format_to(out, "{}{{", typeName);

    auto delim = [first = true, &out]() mutable
    {
      if (!first)
      {
        std::format_to(out, ", ");
      }
      first = false;
    };

    constexpr auto access_ctx 
      = std::meta::access_context::unchecked();

    template for (constexpr auto base 
      : define_static_array(bases_of(^^T,
      access_ctx)))
    {
      delim();
      std::format_to(out, "{}", 
        (typename [: type_of(base) :] const&)
        (t));
    }

    template for (constexpr auto member :
      define_static_array
      (nonstatic_data_members_of(^^T,
      access_ctx)))
    {
      delim();
      std::format_to(out, ".{} = {}",
        identifier_of(member), t.[:member:]);
    }

    std::format_to(out, "}}");
    return out;
  }
};

struct Foo
{
  int a{0};
  struct
  {
    int iii{242};
  } b;
};
template <> struct std::formatter<decltype
  (Foo::b)> : UniversalFormatter { };

template <> struct std::formatter<Foo> 
  : UniversalFormatter { };

int main()
{
  std::println("{}", Foo());
}
Listing 6

Giving the following output:

  Foo{.a = 0, .b = unnamed{.iii = 242}}

See it live at: https://compiler-explorer.com/z/W1snKMTrd

All that is left is the case where a member does not have a name - it does not have an identifier. This use case seems to be exceptional, and it may be best to avoid it as it involves/comprises anonymous structs (illegal) and anonymous unions (legal). For that reason we are not including that case, which keeps the code simple. If you run into a need for this, it is not hard to add it. Also, watch out for bitfields.

Enumeration

Printing enumeration types is now also easy thanks to reflection.

Let’s consider the following enum class and struct:

  enum class Color
  {
    Green,
    Red
  };
  struct Colors
  {
    int x{0};
    Color col{Color::Green};
  };

At this moment, our universal printer will again run into a compile error, since the enum class type Color has no formatter. What if it could just print out ‘Green’ ?

It can. Let’s create a (trivial) formatter template for enumerations (see Listing 7.)

struct EnumFormatter
{
  constexpr auto parse(auto& ctx) {
    return ctx.begin(); }

  template<typename E>
  requires std::is_enum_v<E>
  auto format(const E& value, auto& ctx) const
  {
    std::string_view label{"unknown"};
    template for(constexpr auto& enu 
      : std::define_static_array(
      std::meta::enumerators_of(^^E)))
    {
      if (value == [:enu:])
      {
        label = std::meta::identifier_of(enu);
        break;
      }
    }
    auto out = ctx.out();
    std::format_to(out, "{}", label);
    return out;
  }
};
Listing 7

Why do I say trivial? Because again we don’t allow format specifiers, something we could easily add by inheriting from std::formatter<Std::string>.

So, let’s analyze the code. Some familiar concepts and a new one pop up:

  • enumerators_of() gives all the enumerators of an enum
  • stuff them into a static array, and loop over them
  • one such enumerator has an identifier, obtained by identifier_of()
  • check the enumerator at hand (enu) with the actual value, so we need to splice that enumerator to bring it back in the code domain
  • we protected ourselves for ‘integer values not corresponding to any enumerator’, which will result in ‘unknown’.

The entire code example for this looks like Listing 8:

#include <meta>
#include <format>
#include <print>

struct EnumFormatter
{
  constexpr auto parse(auto& ctx) { 
    return ctx.begin(); }

  template<typename E>
  requires std::is_enum_v<E>
  auto format(const E& value, auto& ctx) const
  {
    std::string_view label{"unknown"};
    template for(constexpr auto& enu 
      : std::define_static_array(
      std::meta::enumerators_of(^^E)))
    {
      if (value == [:enu:])
      {
        label = std::meta::identifier_of(enu);
        break;
      }
    }
    auto out = ctx.out();
    std::format_to(out, "{}", label);
    return out;
  }
};

struct UniversalFormatter
{
  constexpr auto parse(auto& ctx) { 
    return ctx.begin(); }

  template<typename T>
  auto format(const T& t, auto& ctx) const
  {
    auto out = ctx.out();

    std::string_view typeName = "unnamed";
    if constexpr (has_identifier(^^T))
    {
      typeName = identifier_of(^^T);
    }
    std::format_to(out, "{}{{", typeName);

    auto delim = [first = true, &out]() mutable
    {
      if (!first)
      {
        std::format_to(out, ", ");
      }
      first = false;
    };

    constexpr auto access_ctx 
      = std::meta::access_context::unchecked();
    template for (constexpr auto base 
      : define_static_array(bases_of(^^T,
       access_ctx)))
    {
      delim();
      std::format_to(out, "{}",
        (typename [: type_of(base) :] const&)
        (t));
    }
    template for (constexpr auto member :
      define_static_array(
      nonstatic_data_members_of(^^T, 
      access_ctx)))
    {
      delim();
      std::format_to(out, ".{} = {}",
        identifier_of(member), t.[:member:]);
    }
    std::format_to(out, "}}");
    return out;
  }
};

enum class Color
{
  Green,
  Red
};

template <> struct std::formatter<Color> 
  : EnumFormatter { };

struct Colors
{
  int x{0};
  int& y{x}; // look a reference --> works, 
             // but not for pointer, needs extra
             // care
  //int* z{&x};
  void* z{&x};
  Color col{Color::Green};
};

template <> struct std::formatter<Colors> 
  : UniversalFormatter { };

int main()
{
  std::println("{}", Colors());

  std::println("{}", Color(Color::Red));
  std::println("{}", Color(242));
}
Listing 8

giving the output:

  Colors{.x = 0, .y = 0, .z = 0x7fff94787d40, 
  .col = Green}
  Red
  unknown

See it live at: https://compiler-explorer.com/z/qMWEhsd76

Note, that we also stuffed a reference in our struct, works fine. However, a pointer will not work; it will need some extra care. This is due to the fact that we would have, for example, an int*, and format only allows (const) void*. Let’s consider this a home work assignment.

Recap

We saw how we can achieve things we have wanted for a long time, in a rather trivial way by reflection. Just imagine, what else can be achieved.

So, we learned about:

  • lift operator (^^)
  • splice operator ([: :])
  • define_static_array
  • nonstatic_data_members_of
  • bases_of
  • identifier_of
  • type_of
  • access_context (current() and unchecked())
  • has_identifier
  • enumerators_of

With that we conclude our first examples of reflection. Enjoy.

Lieven de Cock Lieven is a software developer, architect, team lead, manager, coach and mentor, with 30 years of experience. He is passionate about C++, software craftsmanship, and clean code. Recently he founded his own consulting company CppDriven, providing services in coaching and workshops for teams on modern C++ and its eco-system of tools.






Your Privacy

By clicking "Accept Non-Essential Cookies" you agree ACCU can store non-essential cookies on your device and disclose information in accordance with our Privacy Policy and Cookie Policy.

Current Setting: Non-Essential Cookies REJECTED


By clicking "Include Third Party Content" you agree ACCU can forward your IP address to third-party sites (such as YouTube) to enhance the information presented on this site, and that third-party sites may store cookies on your device.

Current Setting: Third Party Content EXCLUDED



Settings can be changed at any time from the Cookie Policy page.