Search code examples
c++language-lawyerc++20c++-conceptscrtp

CRTP operator= and Concepts


Clang rejects this demo, while GCC and MSVC accept it. (https://godbolt.org/z/M1Wsxs8fj)

Who is correct? Or is this ill-formed no diagnosis required?

#include <type_traits>

#if 0
#define METHOD balabala   // OK
#else
#define METHOD operator=  // Clang error
#endif

struct base {};

template<typename T>
concept derived = std::is_base_of_v<base, T>;

template<derived Lhs, derived Rhs> struct assign;

template<typename T>
struct crtp: base {
  template<derived Rhs>
  constexpr auto METHOD(const Rhs& rhs) -> assign<T, Rhs> {
    return {};
  }
};

template<typename T>
struct foo: crtp<foo<T>> {
  using crtp<foo<T>>::METHOD;
};

template<derived Lhs, derived Rhs>
struct assign: crtp<assign<Lhs, Rhs>> { };

static_assert(std::is_base_of_v<base, foo<int>>);

Clang message:

<source>:12:19: error: substitution into constraint expression resulted in a non-constant expression
concept derived = std::is_base_of_v<base, T>;
                  ^~~~~~~~~~~~~~~~~~~~~~~~~~
<source>:14:10: note: while checking the satisfaction of concept 'derived<foo<int>>' requested here
template<derived Lhs, derived Rhs> struct assign;
         ^
<source>:14:10: note: while substituting template arguments into constraint expression here
template<derived Lhs, derived Rhs> struct assign;
         ^~~~~~~
<source>:19:44: note: while checking constraint satisfaction for template 'assign<foo<int>, crtp<foo<int>>>' required here
  constexpr auto METHOD(const Rhs& rhs) -> assign<T, Rhs> {
                                           ^~~~~~~~~~~~~~
<source>:17:8: note: while substituting deduced template arguments into function template 'operator=' [with Rhs = crtp<foo<int>>]
struct crtp: base {
       ^
<source>:25:8: note: while declaring the implicit copy assignment operator for 'foo<int>'
struct foo: crtp<foo<T>> {
       ^
/opt/compiler-explorer/gcc-snapshot/lib/gcc/x86_64-linux-gnu/13.0.1/../../../../include/c++/13.0.1/type_traits:3361:68: note: in instantiation of template class 'foo<int>' requested here
  inline constexpr bool is_base_of_v = __is_base_of(_Base, _Derived);
                                                                   ^
<source>:32:20: note: in instantiation of variable template specialization 'std::is_base_of_v<base, foo<int>>' requested here
static_assert(std::is_base_of_v<base, foo<int>>);
                   ^
<source>:12:19: note: initializer of 'is_base_of_v<base, foo<int>>' is unknown
concept derived = std::is_base_of_v<base, T>;
                  ^
/opt/compiler-explorer/gcc-snapshot/lib/gcc/x86_64-linux-gnu/13.0.1/../../../../include/c++/13.0.1/type_traits:3361:25: note: declared here
  inline constexpr bool is_base_of_v = __is_base_of(_Base, _Derived);
                        ^
<source>:12:19: error: substitution into constraint expression resulted in a non-constant expression
concept derived = std::is_base_of_v<base, T>;
                  ^~~~~~~~~~~~~~~~~~~~~~~~~~
<source>:14:10: note: while checking the satisfaction of concept 'derived<foo<int>>' requested here
template<derived Lhs, derived Rhs> struct assign;
         ^
<source>:14:10: note: while substituting template arguments into constraint expression here
template<derived Lhs, derived Rhs> struct assign;
         ^~~~~~~
<source>:19:44: note: while checking constraint satisfaction for template 'assign<foo<int>, crtp<foo<int>>>' required here
  constexpr auto METHOD(const Rhs& rhs) -> assign<T, Rhs> {
                                           ^~~~~~~~~~~~~~
<source>:17:8: note: while substituting deduced template arguments into function template 'operator=' [with Rhs = crtp<foo<int>>]
struct crtp: base {
       ^
<source>:25:8: note: while declaring the implicit move assignment operator for 'foo<int>'
struct foo: crtp<foo<T>> {
       ^
/opt/compiler-explorer/gcc-snapshot/lib/gcc/x86_64-linux-gnu/13.0.1/../../../../include/c++/13.0.1/type_traits:3361:68: note: in instantiation of template class 'foo<int>' requested here
  inline constexpr bool is_base_of_v = __is_base_of(_Base, _Derived);
                                                                   ^
<source>:32:20: note: in instantiation of variable template specialization 'std::is_base_of_v<base, foo<int>>' requested here
static_assert(std::is_base_of_v<base, foo<int>>);
                   ^
<source>:12:19: note: initializer of 'is_base_of_v<base, foo<int>>' is unknown
concept derived = std::is_base_of_v<base, T>;
                  ^
/opt/compiler-explorer/gcc-snapshot/lib/gcc/x86_64-linux-gnu/13.0.1/../../../../include/c++/13.0.1/type_traits:3361:25: note: declared here
  inline constexpr bool is_base_of_v = __is_base_of(_Base, _Derived);
                        ^
2 errors generated.

Solution

  • The backtrace in Clang's error message explains what it's doing:

    note: while substituting deduced template arguments into function template 'operator=' [with Rhs = crtp<foo<int>>]
    struct crtp : base {
           ^
    note: while declaring the implicit copy assignment operator for 'foo<int>'
    struct foo: crtp<foo<T>> {
    

    When the compiler instantiates the definition of struct foo, and it sees that you have not declared a copy assignment operator, it is obligated to declare one implicity ([class.copy.assign]/2) right before the closing brace ([special]/1). Note that a using-declaration naming a base class copy assignment operator does not suppress the implicit copy assignment operator of foo (Note 7 to [class.copy.assign]/8).

    In declaring the implicit copy assignment operator, the compiler is required to determine whether or not it is deleted. The standard has specific rules ([class.copy.assign]/7) as to when the implicit copy assignment operator is deleted, and if the compiler is to declare it as deleted, it must do so at the first declaration (which, as mentioned previously, is just before the closing brace). It is not permitted to declare it first without a definition, then later define it as deleted ([dcl.fct.def.delete]/4).

    So, while foo is still incomplete, the compiler has to go and check whether its copy assignment operator should be deleted. In order to do that, it has to check whether each non-static member of class type (or array thereof) and each base class has a usable copy assignment operator—meaning that in this case, it must perform overload resolution to determine which crtp<foo>::operator= will be called (given an argument that is a const lvalue of type crtp<foo>). If overload resolution fails, foo's copy assignment operator is deleted. If overload resolution succeeds but the chosen function is deleted or inaccessible, foo's copy assignment operator is deleted.

    The crtp<foo<int>> base class has your user-declared assignment operator template:

    template<derived Rhs>
    constexpr auto operator=(const Rhs&) -> assign<T, Rhs> {
      return {};
    }
    

    and its own implicitly declared copy and move assignment operators. Clearly, the copy assignment operator should win the overload resolution, but see [over.match.viable]/1:

    From the set of candidate functions constructed for a given context ([over.match.funcs]), a set of viable functions is chosen, from which the best function will be selected by comparing argument conversion sequences and associated constraints ([temp.constr.decl]) for the best fit ([over.match.best]). The selection of viable functions considers associated constraints, if any, and relationships between arguments and function parameters other than the ranking of conversion sequences.

    The compiler is obligated to assemble the list of viable candidates first before it compares them to select the winner. In order to determine the list of viable candidates, it must first determine the list of candidates. As specified in [over.match.funcs.general]/8, candidate function templates are subject to template argument deduction in order to select a particular specialization that will be a candidate. As part of the deduction process, the compiler must substitute the deduced template arguments into the function type (other than any noexcept-specifier), including its return type; see [temp.deduct.general]/7. In general, if this substitution fails, then deduction fails (and no candidate is generated from the template), but only failures in the immediate context will cause deduction to fail.

    In this case, when substitution is performed into the return type, the compiler has to check whether assign<T, Rhs> is a valid type-id. As specified in [temp.names]/7.5, it is only valid if its constraints are satisfied, so it is necessary to check whether Lhs (which is foo<int>) satisfies the concept derived. And finally it is at this point that a hard error occurs, because "Derived shall be a complete type" in order to use std::is_base_of1 ([meta.rel]/2). And a class is not complete until right after its closing brace.

    When a complete type is required by a standard library component, and the user supplies an incomplete type, the program is IFNDR ([res.on.functions]/2.5). So all compilers are right.

    You might be thinking that it's sort of funny that GCC doesn't diagnose it, though. Clang is actually using libstdc++ in the Godbolt link, and the error occurs when the builtin __is_base_of is instantiated. Does GCC not also diagnose an error when __is_base_of is instantiated with an incomplete type? My experiments show that it does. So, even though GCC isn't obligated to diagnose the program, wouldn't you expect it to, anyway?

    I suspect what's happening here is that GCC is short-circuiting the overload resolution for operator= in crtp<foo<int>>: it can see that the copy assignment operator is going to win, so it doesn't bother performing deduction and instantiation for the templated operator=. In this particular case GCC is saved by IFNDR, but there are other situations where GCC simply doesn't follow the standard, in that it fails to produce a diagnostic in cases where a diagnostic would be required if it were performing all the required instantiations and not short-circuiting overload resolution. This appears to be a situation where MSVC is also short-circuiting. And although this does not appear to be a situation where Clang short-circuits, I'm told that there are other such situations out there.

    Some members of the committee believe that [temp.inst]/9 (which doesn't apply to this particular case because we are instantiating the declaration of assign<foo<int>, not its definition) should be expanded to allow more types of short-circuiting, such as declining to evaluate constraints in cases where the compiler can already tell that the function is going to lose overload resolution.

    1 There are some exceptions to the completeness requirement, which you can see by reading [meta.rel]/2. They don't apply in this scenario.