Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

C++20 concepts: how to refer to the class name in the `requires` clause?

I have a CRTP class

template <typename T>
class Wrapper
{
  // ...
};

that is intended to be derived as

class Type : Wrapper<Type>
{
  // ...
};

and I would like to enforce that by putting a constrain on the template parameter T. There is a friend trick that can accomplish that, but I figure that in the age of concepts there should be a better way. My first attempt was

#include <concepts>

template <typename T>
  requires std::derived_from<T, Wrapper<T>>
class Wrapper
{
  // ...
};

but this doesn't work since I'm referring to Wrapper before it was declared. I have found some workarounds that are not fully satisfactory. I can add the constraint to a constructor

Wrapper() requires std::derived_from<T, Wrapper<T>>;

but that is not convenient if I have more constructors that would have to be constrained as well. I can do it with the destructor

~Wrapper() requires std::derived_from<T, Wrapper<T>> = default;

but it feels a little silly to declare the destructor just to put requires on it.

I wonder if there is a better, more idiomatic way to do that. In particular, while these approaches seem to work (tested on gcc 10), one unsatisfying thing is that if I derive Type from Wrapper<OtherType>, then the error is raised only when I instantiate Type. Is it possible to have the error at the point of definition of Type?

like image 347
60rntogo Avatar asked Jun 29 '20 21:06

60rntogo


1 Answers

No, this is really not possible.

Right now it is a language problem - the name of the class does not exist before it is actually written in the code. But even if the C++ compiler read the file in multiple passes and knew the names, it would still not be enough. Allowing this would either require a major change of the type system and not for the better or it would be a very brittle solution at best. Let me explain.

Hypothetically if the name could be mentioned in the requires clause, the code would also fail because T=Me is still an incomplete type at this point. @Justin demonstrated that in his noteworthy comment my answer builds upon.

But to not make it end here and be a very boring version of "You are not allowed to do that" lets ask ourselves why is Me incomplete?

Take a look the following rather contrived example and see that knowing the full type of Me is impossible inside its base class.

#include <type_traits>



struct Foo;
struct Bar{};

template<typename T>
struct Negator {
    using type = std::conditional_t<!std::is_base_of_v<Foo,T>, Foo, Bar>;
};

struct Me: Negator<Me>::type
{

};

This is of course nothing else than C++ version of Russell's paradox which demonstrates that well-defined objects/sets cannot be defined using themselves.

So, what is the value of std::is_base_of_v<Foo,Me>? I.e is Me deriving from Foo?

If it is not, in that case the conditional in Negator class is true and thus Me is deriving from Negator<Me>::type i.e. Foo which is a contradiction.

On the other hand, if it does derive from Foo, we find out it actually does not.

It might seem like an artificial example and it is, you did ask about something else after all. Yes, there probably is a finite number of paragraphs you could add to the Standard to allow the particular usage of your Wrapper and disallow my usage of Negator, but there would have to be drawn a very thin line between these not so dissimilar examples.

This need for early completeness before }; breaks sizeof which is probably a more commonly given argument:

  • sizeof(Me) obviously depends on the size of all base classes. So using the expression inside a base class that is still being written, thus cannot be complete by definition and not having a size, is another land mine waiting for you to step on.

  • Still, even easier example is:

    struct Me
    {
        int x[sizeof(Me)];
    };
    

The "friend trick"

I believe you are speaking about this. Yes, that works but for the same reason you putting requires near the methods worked. The constructor being deleted or inaccessible is checked only when its call is actually generated, which usually is only when an instance is created and at that point Me is a complete type.

This is also done for a good reason, you would want this code to work:

struct Me
{
    int size(){
        return sizeof(Me);
    }
};

A method cannot influence the type of Me, so this does not create any problems.

like image 61
Quimby Avatar answered Oct 09 '22 01:10

Quimby