Reflection for aggregates

An aggregate is an array or a class with

  • no user-declared or inherited constructors,
  • no private or protected direct non-static data members,
  • no virtual functions, and
  • no virtual, private, or protected base classes.

Aggregates can be initialized in aggregate initialization, and, for most cases, decomposed in a structured binding:

struct Point { int x, y; }; // aggregate

Point pt = {1, 2};          // aggregate init
auto const& [x, y] = pt;    // decomposition

Aggregate classes (that is, not arrays) in some aspects are close to tuples, except that their members have meaningful names. However, unlike tuples, you cannot access their members by index.

In this post we will see how to provide an index-based access to aggregate members and write a “for-each” loop that iterates over all members of a given aggregate type. We will also have a look at PFR (Precise and Flat Reflection) library which implements this already.

Here is our goal. We have a piece of code:

struct Record
{
  std::string name;
  int         age;
  double      salary;
};

struct Point
{
  int x;
  int y;
};

int main()
{
  Point pt{2, 3};
  Record rec {"Baggins", 111, 999.99};
 
  auto print = [](auto const& member) {
    std::cout << member << " ";
  };  

  for_each_member(rec, print);
  for_each_member(pt, print);
}

We need to implement function for_each_member so that it works for for every aggregate type. Its signature is:

template <typename T, typename F>
  // requires std::is_aggregate_v<T>
void for_each_member(T const& v, F f);

We assume C++17.

As a first step, we will write function size for determining the number of members in an aggregate class. We know that an aggregate of three members can be brace-initialized with a three-element initializer. We will detect this using a SFINAE trick. To make our implementation task easier, we will reduce the problem to aggregates of four or fewer elements:

template <typename T>
constexpr auto size_() 
  -> decltype(T{{}, {}, {}, {}}, 0u)
{ return 4u; }

template <typename T>
constexpr auto size_() 
  -> decltype(T{{}, {}, {}}, 0u)
{ return 3u; }

template <typename T>
constexpr auto size_() 
  -> decltype(T{{}, {}}, 0u)
{ return 2u; }

template <typename T>
constexpr auto size_() 
  -> decltype(T{{}}, 0u)
{ return 1u; }

template <typename T>
constexpr auto size_() 
  -> decltype(T{}, 0u)
{ return 0u; }

template <typename T>
constexpr size_t size() 
{ 
  static_assert(std::is_aggregate_v<T>);
  return size_<T>(); 
}

Type decltype(T{{}, {}, {}}, 0u) means, “it is either unsigned or a malformed construct that makes the corresponding function silently disappear from the overload set. The last comma is the comma operator: its left-hand side argument is ignored, and only its right-hand side’s type contributes to the result. Of course, such expression assumes that each member of the aggregate can be value-initialized, which might not be the case. In order to address that, we have to use another trick: an auxiliary class that can be converted to anything:

struct init
{
  template <typename T>
  operator T(); // never defined
};

Now, we can rewrite our function declarations using it:

template <typename T>
constexpr auto size_() 
  -> decltype(T{init{}, init{}, init{}, init{}}, 0u)
{ return 4u; }

template <typename T>
constexpr auto size_() 
  -> decltype(T{init{}, init{}, init{}}, 0u)
{ return 3u; }

template <typename T>
constexpr auto size_() 
  -> decltype(T{init{}, init{}}, 0u)
{ return 2u; }

template <typename T>
constexpr auto size_() 
  -> decltype(T{init{}}, 0u)
{ return 1u; }

template <typename T>
constexpr auto size_() 
  -> decltype(T{}, 0u)
{ return 0u; }

template <typename T>
constexpr size_t size() 
{ 
  static_assert(std::is_aggregate_v<T>);
  return size_<T>(); 
}

We can now test it for Point:

size<Point>();

It does not compile, because of ambiguous matching functions size_. This is because while point can be initialized with two arguments, it can also be initialized with one or with zero: you do not have to initialize explicitly all members of an aggregate: the remaining ones are value-initialized. In order to fix the problem we have to order our overloads, so that the one with four elements is a better match than the one with three elements, and so on. For this we’ll use inheritance: An overload taking class C is a better overload than the one taking C's base:

struct B {};
struct C : B {};

void fun(B); // (1)
void fun(C); // (2)

fun(C{});    // picks (2)

The call to fun(C{}) picks overload (2), but if we removed it, overload (1) would still be picked. This trick is used in STL for iterator tags. (For details, see here.) Now, we have to generalize it for an arbitrarily long inheritance chain. Let’s use a recursive class definition:

template <unsigned I>
struct tag : tag<I - 1> {};

template <>
struct tag<0> {};

Now, lets use it to fix our implementation of size:

template <typename T>
constexpr auto size_(tag<4>) 
  -> decltype(T{init{}, init{}, init{}, init{}}, 0u)
{ return 4u; }

template <typename T>
constexpr auto size_(tag<3>) 
  -> decltype(T{init{}, init{}, init{}}, 0u)
{ return 3u; }

template <typename T>
constexpr auto size_(tag<2>) 
  -> decltype(T{init{}, init{}}, 0u)
{ return 2u; }

template <typename T>
constexpr auto size_(tag<1>) 
  -> decltype(T{init{}}, 0u)
{ return 1u; }

template <typename T>
constexpr auto size_(tag<0>) 
  -> decltype(T{}, 0u)
{ return 0u; }

template <typename T>
constexpr size_t size() 
{ 
  static_assert(std::is_aggregate_v<T>);
  return size_<T>(tag<4>{}); // highest supported number 
}

Now, our function size works as expected:

static_assert(size<Record>() == 3);
static_assert(size<Point>() == 2);

Now we can implement our for-each function. Given that we have limited ourselves to aggregates of four or less members, the task becomes quite trivial: just decompose all elements with a structured binding and call our function for each of them:

template <typename T, typename F>
void for_each_member(T const& v, F f)
{
  static_assert(std::is_aggregate_v<T>);

  if constexpr (size<T>() == 4u)
  {
    const auto& [m0, m1, m2, m3] = v;
    f(m0); f(m1); f(m2); f(m3);
  }
  else if constexpr (size<T>() == 3u)
  {
    const auto& [m0, m1, m2] = v;
    f(m0); f(m1); f(m2);
  }
  else if constexpr (size<T>() == 2u)
  {
    const auto& [m0, m1] = v;
    f(m0); f(m1);
  }
  else if constexpr (size<T>() == 1u)
  {
    const auto& [m0] = v;
    f(m0);
  }
}

And we are done! Now we can run our test program:

struct Record
{
  std::string name;
  int         age;
  double      salary;
};

struct Point
{
  int x;
  int y;
};

int main()
{
  Point pt{2, 3};
  Record rec {"Baggins", 111, 999.99};
 
  auto print = [](auto const& member) {
    std::cout << member << " ";
  };  

  for_each_member(rec, print);
  for_each_member(pt, print);
}

This outputs:

Baggins 111 999.99 2 3

In a similar way, we could implement a function that provides a tuple access to our aggregate:

template <typename T>
auto as_tuple(T const& v)
{
  static_assert(std::is_aggregate_v<T>);

  if constexpr (size<T>() == 4u)
  {
    const auto& [m0, m1, m2, m3] = v;
    return std::tie(m0, m1, m2, m3);
  }
  else if constexpr (size<T>() == 3u)
  {
    const auto& [m0, m1, m2] = v;
    return std::tie(m0, m1, m2);
  }
  else if constexpr (size<T>() == 2u)
  {
    const auto& [m0, m1] = v;
    return std::tie(m0, m1);
  }
  else if constexpr (size<T>() == 1u)
  {
    const auto& [m0] = v;
    return std::tie(m0);
  }
}

int main()
{
  Point pt{2, 3};
  assert(as_tuple(Point{1, 1}) < as_tuple(pt));
}

A complete working example can be found here.

We could implement more useful functions, generalize it for bigger aggregates, make it work in C++14, and more. But why do that if we have a library that implements all this already? It is Precise and Flat Reflection (PFR) by Antony Polukhin. It is from this library (and Antony’s talk) that I have learned how to implement basic reflection for aggregates in C++17. With this library, our example would look like this:

#include <iostream>
#include <boost/pfr.hpp>

struct  Record
{
  std::string name;
  int         age;
  double      salary;
};

struct Point
{
  int x;
  int y;
};

int main()
{
  Point pt{2, 3};
  Record rec {"Baggins", 111, 999.99};
  
  auto print = [](auto const& member) {
    std::cout << member << " ";
  };  
 
  boost::pfr::for_each_field(rec, print);
  boost::pfr::for_each_field(pt, print);
}

Of course, the library provides more useful features. To give you just one example, the following code will not compile:

int main()
{
  std::set<Point> s {Point{0, 0}, Point{0,1}};
}

This is because no ordering has been defined for aggregate class Point. With PFR you can fix it by providing a comparator that knows how to handle aggregates:

int main()
{
  std::set<Point, boost::pfr::less<>> s {{0, 0}, {0,1}};
}

The PFR library is now undergoing the process of Boost review. This is how Boost acquires new libraries. A number of professional developers reviews the library to make sure it is well designed, correctly implemented, well documented and tested, and useful. You can contribute to the review. The announcement with the review details is under this link. If you are interested in doing this, you can either send an email with your review to Boost Users mailing list, or put it in the comments below, and I will deliver it to the review manager.

This entry was posted in programming and tagged , , . Bookmark the permalink.

1 Response to Reflection for aggregates

  1. Mariusz Jaskółka says:

    Yet another hacky reflection implementation. It’s fun, but can’t wait for seeing language-level one to rule them all!

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.