Fun With Deducing This, SMFs and = delete

Deducing This is a new way of writing C++ member functions, which was introduced in C++23. This feature allows you to explicitly write the normally-implicit object argument (aka this) in the argument list, just like Python’s self argument:

struct S
{
    int value;
    void fun(int r) { value = r; } // normal member
    void fun2(this const S& self, int r) { self.value = r; } // deducing this
};

S s;
s.fun(4);
s.fun2(5); // usage is the same

As you can see, the syntax for DT is to prepend this on the first argument, which will be treated as the object argument that appears before . or ->. This is essentially a weakened form of Uniform Function-Call Syntax (UFCS), since DT essentially allows specifically-marked static non-member functions (as implemented behind the scenes) to be called with the member syntax.

However, this post’s purpose is not to explore the detail of Deducing This. Instead, it tries to answer a seemingly obvious question: can we write special member functions (SMFs) with Deducing This? If so, can they be = defaulted? This simple question have surprisingly non-trivial answers and incites several compiler bugs and inconsistent behavior across the board!

Terminology

Before we explore the interaction between Deducing This and SMFs, we must first clarify an often misunderstood term: what counts as special member functions?

Special Member Functions

Traditionally, special member functions refer to the functions that will be automatically declared by the compiler for a class, including:

Note: The reason that all the constructors and operators are in plural form, and destructors is prepended by “prospective”, is because of C++20 Concepts. With requires clauses, you can have several “prospective” destructors for a class, but only one will be available at any given time to act as the “real” destructor.

These (except the default constructors) are also the functions affected by “Rule of Five”, which describes the customs and idioms related to defining those functions for a class (refer to the linked page for more information).

Special Member Functions is a term defined by the standard, so the definition of them seems to be crystal clear, right?

Not so fast! What is the exact signature required for a constructor to be considered a SMF? (For example, is the copy-and-swap assignment operator X& operator=(X) considered copy assignment or move assignment?) What about default arguments? What about templates? Nothing is that simple in C++!

Let’s look at each special member function in detail.

Default Constructors and Destructors

This is the easiest case. A constructor is a default constructor if and only if:

That’s it! (Standard) What this means essentially is that as long as the constructor can be called with no arguments (A()), it is a default constructor.

struct A
{
    A(); // default constructor
    A(int x = 2, int y = 3); // also a default constructor
    template<typename T = int> A(T x = 2); // also a default constructor
    template<typename... Ts> A(Ts... args); // also a default constructor
    template<typename... Ts> A(); // also a default constructor

    A(int a, int b = 2); // not a default constructor
    template<typename T> A(); // also not
};

(Note that the access specifier, noexcept, explicit, requires, or constexpr/consteval specifier will not affect whether a constructor is a SMF, same below.)

Of course, the implicitly generated default constructor (will be generated if no constructor is declared) always have the form:

A() = default;

(the constexpr and noexcept-ness will be deduced by the member/base’s default constructors; same below)

A destructor for the class is a member declared with the ~A() syntax (with optional preceding specifier and noexcept/requires). It cannot be declared in any other form, so this is the only requirement. If no destructor and move operations is defined for a class, one will be implicitly generated with the form:

~A() = default;

Copy/Move Constructors

Copy/Move constructors are called when an object is constructed by copying/moving another object. The standard specified that a constructor for class A will be identified as a copy/move constructor if and only if:

Note: [cv] refers to any combinations of const and volatile, same below.

Again, this essentially means that the compiler will treat a constructor as SMF based on its callability with one argument, instead of its declared number of arguments.

struct A
{
    A(const A&); // copy constructor
    A(A&); // also (used by auto_ptr<T> to indicate stole semantics)
    A(const volatile A&, int x = 2); // also

    A(A&&); // move constructor
    A(const A&&) // also (although very weird)
    A(volatile A&&, double x = 2.0); // also

    template<typename T = int>
    A(const A&); // not a copy constructor
    A(A&&, int x); // not a move constructor
};

If no copy constructor is defined for a class, a copy constructor will be implicitly generated with the form

A(const A&) = default; // normal
A(A&) = default; // only if a subobject (member or base) have a copy constructor with argument [volatile] A&

If no copy/move operations and destructors are defined for a class, a move constructor will be implicitly generated with the form

A(A&&) = default;

Note: a critical difference here is the criteria of implicit generation. If a move operation is declared, the copy constructor will still be generated; it will just be declared as = delete. However, if a copy/move operation or a destructor is declared, the move constructor will not be generated at all, falling silently back to copying.

Copy/Move Assignment

Note that operator= can only be declared as a member function, so we don’t need to deal with operator overload form shenanigans here.

Copy/Move assignment are called when an object is assigned by lvalue/rvalue of the same type. The standard specified that a declared operator= member function for class A will be identified as a copy/move assignment if and only if:

Note: operator overloads, except for operator() and operator[], cannot have default arguments, so that item does not apply here.

struct A
{
    A& operator=(const A&); // copy assignment
    A& operator=(A&); // also (used by auto_ptr<T> to indicate stole semantics)
    int operator=(const volatile A&) const &; // also

    A& operator=(A&&) &; // move assignment
    double operator=(const A&&) const &&; // also (although very weird)

    template<typename T = int>
    A& operator=(const A&); // not a copy assignment
};

Note: return types, const, volatile, and ref-qualifiers also does not affect the validity of a copy/move assignment operator.

If no copy assignment operator is defined for a class, a copy assignment operator will be implicitly generated with the form

A& operator=(const A&) = default; // normal
A& operator=(A&) = default; // only if a subobject (member or base) have a copy assignment operator with non-object argument [volatile] A&

If no copy/move operations and destructors are defined for a class, a move assignment operator will be implicitly generated with the form

A& operator=(A&&) = default;

= default

Compared to SMFs which was a thing since the inception of C++, = default is a relatively “new” (with 14 years of age already!) thing. Essentially, it requests the compiler to “do as if this thing had been implicitly generated”. You can explicitly request the default function body by using = default as the function body:

struct A {}; // SMFs implicitly generated
struct B
{
    B() = default; // implemented as-if it is implicitly generated
    B& operator=(B&&) & = default; // implemented as-if it is implicitly generated
};

Besides the textual benefit of writing out implicit functions explicitly, = default also allows you to make small modifications to the implicit signatures of SMFs, as demonstrated by the use of ref-qualifiers above. However, the possible modifications are restricted by the standard explicitly. Only the following difference are permitted for = default functions compared to the implicitly generated signatures:

struct A
{
    A() noexcept = default; // fine, Rule 1
    A& operator=(const A&) noexcept & = default; // fine, Rule 1 + Rule 2
    A(A&) = default; // fine, Rule 3
    int operator=(const A&) = default; // error, not a permitted difference
    A(int x = 2) = default; // error, not a permitted difference
};

In this sense, functions that can be = defaulted can be said to be a more strictly restricted version of SMF signatures that are valid… or can they?

Actually, these two sets are disjoint! Besides SMFs, there are other functions that can be = defaulted: comparison operators.

The full story for comparison is too long to be described in this post, but interested readers can consult here for a detailed description. For this post, it is sufficient to note that

The criteria for a defaulted comparison operator, regardless of which operator is being declared, is as follows:

struct A
{
    int x;
    bool operator==(const A&) const = default; // fine, const A& + const A&
    bool operator<(const A&) = default; // error, first argument (implicit) is A&
    friend bool operator>(A, A) = default; // fine

    auto operator<=>(const A&) const = default; // fine
    friend std::any operator<=>(A, A) = default; // fine
    int operator<=>(const A&) const = default; // error, return type of x <=> x not convertible to int
};

Note: the fact that operators other than <=> cannot use auto in lieu of bool or auto& in lieu of A& is inconsistent, and there is a proposal to fix that.

Deducing This

Now onto the main part of this post: what does all of this have to do with Deducing This? Of course, since it is a new (or dare I say better?) way of writing member functions, we should use it to write special member functions!

What Does The Standard Say?

Surprisingly little at first! The author of DT seems to not consider the interaction with SMFs and comparison functions at all in the initial proposal, and thus the C++23 standard initially does not have any regulations regarding whether SMFs and comparison operators’s validity when written in DT form.

This omission was later identified, and resolved by the adoption of CWG 2586. Two key modification are made as a result of this issue:

However, standard is just a document, what does the implementations say about the matter?

Implementation Divergence

Let’s see! (All results are obtained from the trunk versions of compilers as of January 2025)

Note: since DT cannot be used on constructors or destructors, the only valid forms are on copy/move assignment operators and comparison operators.

Form Standard GCC Clang MSVC EDG Link Comments
Copy Assignment
A& operator=(this A&, const A&); Welp, it seems that MSVC does not implement CWG 2586 at all... Godbolt Normal Copy Assignment
A& operator=(this A&, A&); Same as above, erroneously generate an implicit copy assignment operator and do resolution based on that Godbolt Stealing Copy Assignment
A& operator=(this A&, A); Same as above, ambiguous between the implicitly generated one and CAS Godbolt CAS Copy Assignment
A& operator=(this const A&, const A&); There is a note in CWG 2586 that pointed out that it is weird for this to be considered a copy assignment; however as of now it is the status quo in the standard. Silently calls the implicitly generated one Silently calls the implicitly generated one ? Godbolt Copy Assignment With const A& Object Param
A& operator=(this A, const A&); Ambiguous with the implicitly generated one Ambiguous with the implicitly generated one ? Godbolt Copy Assignment With A Object Param
A& operator=(this int, const A&); Silently calls the implicitly generated one Silently calls the implicitly generated one Somehow generated an invalid redeclaration error Godbolt Copy Assignment With Unrelated Object Param
A& operator=(this auto&&, const A&); Must not be a template Godbolt Copy Assignment With Templated Object Param
A& operator=(this A&, const A&) = default; Currently it seems that MSVC just rejects defaulting functions with DT EDG complains about signature only Godbolt Above With = default
A& operator=(this A&, A&) = default; Godbolt
A& operator=(this A&, A) = default; Not an allowed signature for defaulting Reject only because it cannot handle defaulting DT at all Godbolt
A& operator=(this const A&, const A&) = default; The last rule for defaulting above specifies that any kind of reference to A is acceptable Ill-formed Default as deleted Ill-formed Ill-formed Godbolt
A& operator=(this A, const A&) = default; Not a reference to A, which should be default as deleted Ill-formed Default as deleted Ill-formed Ill-formed Godbolt
A& operator=(this int, const A&) = default; Ill-formed Default as deleted Ill-formed Ill-formed Godbolt
A& operator=(this auto&&, const A&) = default; Must not be a template Godbolt
Move Assignment
A& operator=(this A&, A&&); Erroneously generate an implicit move assignment operator and do resolution based on that Godbolt Normal Move Assignment
A& operator=(this A&, const A&&); Godbolt Weird Move Assignment
A& operator=(this const A&, A&&); There is a note in CWG 2586 that pointed out that it is weird for this to be considered a move assignment; however as of now it is the status quo in the standard. Silently calls the implicitly generated one Ambiguous with the implicitly generated one Silently calls the implicitly generated one Godbolt Move Assignment With const A& Object Param
A& operator=(this A&&, A&&); Ambiguous with the implicitly generated one Ambiguous with the implicitly generated one Godbolt Move Assignment With A&& Object Param
A& operator=(this int, const A&); Silently calls the implicitly generated one Conflicts with copy assignment Silently calls the implicitly generated one Conflicts with copy assignment Godbolt Move Assignment With Unrelated Object Param
A& operator=(this auto&&, A&&); Must not be a template Godbolt Move Assignment With Templated Object Param
A& operator=(this A&, A&&) = default; Currently it seems that MSVC just rejects defaulting functions with DT EDG complains about signature only Godbolt Above With = default
A& operator=(this A&, const A&&) = default; Not a permitted deviation; only stripping const is allowed; should be default as deleted Default as deleted Default as deleted Ill-formed Ill-formed Godbolt
A& operator=(this const A&, A&&) = default; The last rule for defaulting above specifies that any kind of reference to A is acceptable Ill-formed Default as deleted Ill-formed Ill-formed Godbolt
A& operator=(this A&&, A&&) = default; Ill-formed Default as deleted Ill-formed Ill-formed Godbolt
A& operator=(this int, A&&) = default; Ill-formed Default as deleted Ill-formed Ill-formed Godbolt
A& operator=(this auto&&, A&&) = default; Must not be a template Godbolt
Comparison
auto operator<=>(this A, A) = default; Refuse to recognize this as a comparison Refuse to default comparison written in DT Godbolt Normal Spaceship With A
auto operator<=>(this const A&, const A&) = default; Godbolt Normal Spaceship With const A&
auto operator<=>(this const A&, A) = default; Two parameter must be of same type Godbolt Asymmetric Spaceship
auto operator<=>(this A&, A&) = default; Two parameter must be of either A or const A& Godbolt Wrong Param Type Spaceship
int operator<=>(this A, A) = default; Must return auto or a category type Godbolt Wrong Return Type Spaceship
int operator==(this A, A) = default; Must return bool Godbolt Wrong Return Type Equality
auto operator<=>(this auto, A) = default; Must not be a template Godbolt Templated Spaceship

Hmmm… Guess let’s not use DT on SMFs for now if you want portability…