c++template-meta-programmingc++-concepts

Interpretation of two complex requires clauses


This post talks about a method that counts the number of members in class:

struct UniversalType { template<typename T> operator T();  };

template<typename T, typename... A0>
consteval auto MemberCounter(auto ...c0) 
{
    if constexpr (requires { T{ {A0{}}..., {UniversalType{}}, c0... }; } == false
               && requires { T{ {A0{}}..., c0..., UniversalType{}   }; } == false )
   {
        return sizeof...(A0) + sizeof...(c0);
    }
    else if constexpr (requires { T{ {A0{}}..., {UniversalType{}}, c0... }; })
    {
        return MemberCounter<T,A0...,UniversalType>(c0...);
    }
    else
    {
        return MemberCounter<T,A0...>(c0...,UniversalType{});
    }
}

using TestType = struct { int x[3]; float y; char z; };
static_assert (MemberCounter<TestType>() == 3);

int main() {}

In particular, the two following requires clauses puzzle me a bit since they mix simple and double braces:

  1. requires { T{ {A0{}}..., {UniversalType{}}, c0... }; }
  2. requires { T{ {A0{}}..., c0..., UniversalType{} }; }

Question: what kind of items do they exactly allow to match ?


Solution

  • The hint lies in TestType, which was deliberately designed to demonstrate the problem nested {} solves.

    The premise of counting member variables is to check the maximum number of arguments the type will accept in aggregate initialization. This can be done without knowing the type of the members through UniversalType, which can fake being any type. For example

    struct X { int a, b; };
    X{UniversalType{}};                                    // compiles
    X{UniversalType{}, UniversalType{}};                   // compiles
    X{UniversalType{}, UniversalType{}, UniversalType{}};  // doesn't compile
    

    which tells us X has two member variables.

    There is one problem with this: aggregate initialization recursively initializes subobjects. So for example

    struct Y { int a[2], b; };
    Y{UniversalType{}};                                    // compiles
    Y{UniversalType{}, UniversalType{}};                   // compiles
    Y{UniversalType{}, UniversalType{}, UniversalType{}};  // compiles?!
    

    To remedy that, an extra nested {} is introduced. Each {UniversalType{}} or {A0{}} can only initialize a single subobject even if it is an aggregate, solving the problem.

    While it may seem like {UniversalType{}} can initialize any type, as in

    int i = {UniversalType{}};  // list initialization
    

    an empty aggregate is an exception. For an empty aggregate, {UniversalType{}} is an aggregate initialization that fails because there are too many clauses in the initializer. So we need to test for both {UniversalType{}} and UniversalType{}.


    That said, there are several edge cases that are wrong with this implementation.

    struct B
    {
        B(int, int) {}
    };
    
    struct A
    {
        int i;
        B b;
    };
    
    struct E
    {
    };
    
    struct C
    {
        E e;
        int i[3];
    };
    
    static_assert(std::is_aggregate_v<A> && MemberCounter<A>() != 2);
    static_assert(std::is_aggregate_v<C> && MemberCounter<C>() != 2);
    

    For A, A{UniversalType{}} fails because the second member b cannot be default initialized, so the counter returns 0. This can fixed by counting downwards instead of upwards.

    For C, the problem is a bit more fundamental. The implementation has no way of remembering where the empty aggregates are. Since {A0{}} precedes c0, no aggregate can follow an empty aggregate.