A class without a copy constructor

Here’s a puzzle for you: How do you write a class without a copy constructor? You might answer:

struct A {};

But no: A has a copy constructor. The compiler has generated a defaulted copy constructor for A. You might try again like this:

struct B { B(const B&) = delete; };

But no again! B has a copy constructor. That copy constructor is deleted, but it’s still present. You can tell it’s present, because if you use B in a situation where overload resolution would consider both the copy constructor and some other (less desirable) candidate, the deleted copy constructor will still win out:

struct FromB { FromB(const B&); };
void f(B);
void f(FromB);
void test(const B& b) { f(b); }

[...]
error: call to deleted constructor of 'B'
    void test(const B& b) { f(b); }
                              ^

Here’s the answer I have in mind. Starting in C++20, we can use Concepts to make classes that (almost) do not have copy constructors. For example:

template<class T>
struct C {
    C(const C&) requires (sizeof(T) != 1);
};

Now, technically, C has a copy constructor: our user-provided one. Since it has a user-provided copy constructor, the compiler does not generate any implicitly defaulted copy constructor for it. However, when T=char, our user-provided copy constructor is not eligible, because its constraint is not satisfied. So, C<char> has no implicitly defaulted copy constructor and its existing copy constructor is not eligible. We can observe the change in behavior on MSVC (Godbolt):

struct FromC { FromC(const C<char>&); };
void f(C<char>);
void f(FromC);
void test(const C<char>& c) {
    f(c);
        // C<char> has no copy constructor, so
        // f(FromC) is the only viable candidate
}

As of this writing, neither GCC nor Clang implements this scenario the way I’d expect them to: they both continue to think that void f(C<char>) is the best-matching candidate, despite there being no implicit conversion sequence from const C<char>& to C<char>.

All three compilers agree that if you give C additional implicit conversions to and from Other, then there will be an implicit conversion sequence from const C<char>& to C<char> — it just goes via Other! This leads to an amusing situation where the compiler will actually pick an implicit conversion sequence that takes the scenic route “past” another, worse-matching, candidate overload before circling back to the better-matching overload (Godbolt):

struct Other {};

template<class T>
struct C {
    C(const C&) requires (sizeof(T) != 1);
    operator Other() const;
    C(const Other&);
};

void f(C<char>);
void f(Other);
void test(const C<char>& c) {
    f(c);
        // equivalent to f(C<char>(Other(c))),
        // even though f(Other(c)) would have
        // had a shorter conversion sequence
}

Relevance to “implicit move”

In C++11, C++14, and C++17, the rule for return x; was to first do overload resolution with x as an rvalue, looking specifically for constructors taking type X&&; and then if that failed, redo the overload resolution treating x as an lvalue (and looking for all viable conversion sequences).

In C++20, my P1155 “More implicit moves” changed the rules so that the first overload resolution would look for any conversion sequence. This allowed us to pick up constructors taking BaseOfX&&, converting constructors taking X by value, etc., in the first overload resolution pass. This produced a similarly counterintuitive effect, because it has always been easy to create a class with no move constructor!

struct D {
    D(D&);
    operator Other() const;
    D(Other);
};

D rf(D d) {
    return d;
}

In C++17, this would fail the first overload resolution and end up calling D(D&). In C++20, the first overload resolution succeeds because “convert D to Other, then convert Other to D” is considered a usable implicit conversion sequence. (I’m honestly not sure why it’s considered usable; I’ve always thought C++ had a general rule that you could never stack user-defined conversions more than 1 deep. But that “rule” seems to be implemented as a laundry list of special cases in [over.best.ics.general] rather than a blanket prohibition.)

The C++20 rule change has generally been treated by compiler vendors as a “DR” — i.e., they’ve backported it into earlier modes as well — because it tends to give better codegen and also to be easier to implement. So for any given compiler release, you won’t see any difference between -std=c++17 and -std=c++20 on the above snippet. But comparing different releases of the same compiler, you’ll see a point in time where it switches from the old behavior to the new behavior in all modes. For MSVC, this has already happened, in MSVC 19.24. For Clang, the switchover is happening in Clang 13. I don’t know about GCC.


Another interesting ramification of the new rules is that “copy elision” can now elide things that aren’t copy or move constructors. For example, suppose we rewrite the function above as

D rf2() { D d; return d; }
  • If copy elision doesn’t happen, we get a call to D::operator Other() const and a call to D(Other).

  • If copy elision happens, then we get nothing.


I tentatively imagine that CWG might want to add a new bullet point to the laundry list in [over.best.ics.general], to forbid stacking user-defined conversions in return statements. Such a change would temporarily restore the C++11 behavior of rf(D) above — but only until P2266 “Simpler implicit move” got rid of the second overload resolution pass altogether, at which point rf(D) would become ill-formed (and we’d all probably breathe a sigh of relief).


UPDATE, 2021-09-20: I still think the ability to make a class with no (eligible) copy constructor is new-in-C++20, but Lénárd Szolnoki sends me this example in pure C++98 proving that the “scenic route” of stacking user-defined conversions has been possible since the dawn of time.

struct H;
struct G {
    G(G&);
    G(const H&);
};

struct H { H(const G&); };

void f(G);
void f(H);

void test(const G& g) {
    f(g);  // equivalent to f(G(H(g)))
}
Posted 2021-09-17