C++ =default and =delete: Triviality, Deleted Overloads, and the Implicit Move Trap
Key takeaways
=default and =delete look like syntax sugar, but they change triviality, aggregate status, value-initialization and overload resolution. This article walks through each effect with g++ 10.3 output, including the destructor-disables-move trap.
= default and = delete arrived in C++11 and are often described as “a clearer way to say what the compiler already does.” That undersells them. Where you put = default, and whether you write = default or an empty body {}, changes how a type is classified: trivial or not, aggregate or not, zero-initialized or left with garbage. = delete takes part in overload resolution, which is why it can block conversions that explicit cannot reach. This article covers those effects with small programs compiled with g++ 10.3 (-std=c++20 unless stated otherwise).
= default is not the same as an empty body
Here are three constructors that all “do nothing”:
#include <cstdio>
#include <new>
#include <type_traits>
struct A { int x; A() = default; }; // defaulted on first declaration
struct B { int x; B() {} }; // user-provided, empty body
struct C { int x; C(); };
C::C() = default; // defaulted out of line: user-provided!
int main() {
std::printf("trivial: A=%d B=%d C=%d\n",
std::is_trivially_default_constructible_v<A>,
std::is_trivially_default_constructible_v<B>,
std::is_trivially_default_constructible_v<C>);
alignas(int) unsigned char buf[sizeof(int)];
for (auto& c : buf) c = 0xAB;
A* a = new (buf) A(); // value-init: zero-initialized first
std::printf("A{}.x = %d\n", a->x);
for (auto& c : buf) c = 0xAB;
B* b = new (buf) B(); // value-init only calls B(): x untouched
std::printf("B{}.x = %d\n", b->x);
}
Output:
trivial: A=1 B=0 C=0
A{}.x = 0
B{}.x = -1414812757
Two separate rules produce this.
Triviality. A special member is trivial only if it is not user-provided. A() = default; on the first declaration is user-declared but not user-provided, so A stays trivially default constructible. B() {} is user-provided even though the body is empty. C is the surprising one: the declaration is plain C(); and the = default sits in the .cpp file, so from the point of view of every other translation unit the constructor is user-provided and non-trivial. Triviality matters because it is what lets std::vector, memcpy-based optimizations, and std::is_trivially_copyable checks treat a type as plain bytes.
Value-initialization. When you write A() or A{} and the default constructor is not user-provided, the object is zero-initialized first and then the constructor runs. For B, the rule says “just call the user-provided constructor”, and since that constructor does not touch x, you get whatever bytes were in memory (here the 0xABABABAB pattern I wrote into the buffer). The placement-new trick in the example is only there to make the garbage visible; in real code the same effect shows up as a member that is 0 in debug builds and random in release builds.
So the practical rules are:
- Prefer
= defaultin the class body over{}when the compiler-generated behavior is what you want. - If you default out of line (for example to keep a
unique_ptr<Impl>destructor in the .cpp for pimpl), accept that the type is no longer trivial. - Do not rely on value-initialization zeroing for anything important. Give members default member initializers (
int x = 0;), which works regardless of how the object is created.
C++20 changed what an aggregate is
Before C++20, a class with a user-declared but not user-provided constructor could still be an aggregate. That allowed this odd piece of code:
struct NoDefault {
NoDefault() = delete;
int x;
};
int main() {
NoDefault n{42}; // aggregate init in C++17, bypassing the deleted ctor
return n.x;
}
With -std=c++17 this compiles and returns 42: the deleted constructor was simply ignored because brace initialization of an aggregate never calls a constructor. With -std=c++20:
agg.cpp:6:16: error: no matching function for call to 'NoDefault::NoDefault(<brace-enclosed initializer list>)'
P1008 made any user-declared constructor (including = default and = delete) disqualify a class from being an aggregate. The C++17 behavior was a hole: deleting the default constructor did not actually prevent construction. The fix is correct, but it breaks code that relied on T{a, b} for structs that had T() = default; sprinkled in “for clarity”. If you hit that error after switching the standard flag, delete the redundant = default line (the struct gets an implicit default constructor anyway) or add a real constructor.
= delete participates in overload resolution
A deleted function is still declared. Overload resolution can pick it, and only then does the program become ill-formed. That is the whole trick behind blocking conversions:
#include <cstdio>
void setVolume(int percent) { std::printf("volume %d\n", percent); }
void setVolume(double) = delete;
int main() {
setVolume(80);
setVolume(0.8);
}
conv.cpp:6:18: error: use of deleted function 'void setVolume(double)'
6 | setVolume(0.8);
| ^
conv.cpp:3:6: note: declared here
3 | void setVolume(double) = delete;
Without the deleted overload, setVolume(0.8) quietly truncates to 0. explicit cannot help here because it only applies to constructors and conversion functions, not to the built-in double to int conversion at a call site.
Two details are worth knowing:
floatarguments also hit the deleted overload, becausefloattodoubleis a promotion, which beatsfloattoint.setVolume(80L)now fails withcall of overloaded 'setVolume(long int)' is ambiguous, sincelongtointandlongtodoubleare both conversions of the same rank. Adding a deleted overload changes the overload set for every argument type, so check the callers you did not have in mind.
When you want “exactly this type and nothing else”, delete a catch-all template:
void takesInt(int v) { std::printf("%d\n", v); }
template <class T> void takesInt(T) = delete; // everything except exact int
takesInt(1); // OK: non-template is an exact match and wins the tie
takesInt('a'); // error: use of deleted function 'void takesInt(T) [with T = char]'
takesInt(1L); // error: use of deleted function 'void takesInt(T) [with T = long int]'
The template deduces an exact match for every type, so it beats any conversion to int. For int itself, the non-template wins because the standard prefers a non-template when both are exact matches.
The same approach works for constructors (Index(double) = delete; next to explicit Index(int)) and is the standard way to keep std::ref and std::cref from binding to temporaries: the library declares void cref(const T&&) = delete;.
The implicit move trap: declaring a destructor turns moves into copies
This is the pitfall I see most often in code review, because nothing fails to compile. Start with a member that reports whether it was copied or moved:
#include <cstdio>
#include <utility>
struct Tracker {
Tracker() = default;
Tracker(const Tracker&) { std::puts(" Tracker copied"); }
Tracker(Tracker&&) noexcept { std::puts(" Tracker moved"); }
};
struct Plain { Tracker t; };
struct Logged { Tracker t; ~Logged() {} }; // just a destructor
struct Fixed { Tracker t; ~Fixed() {}
Fixed() = default;
Fixed(Fixed&&) = default;
Fixed& operator=(Fixed&&) = default; };
int main() {
std::puts("Plain:"); Plain p; Plain p2 = std::move(p);
std::puts("Logged:"); Logged l; Logged l2 = std::move(l);
std::puts("Fixed:"); Fixed f; Fixed f2 = std::move(f);
}
Plain:
Tracker moved
Logged:
Tracker copied
Fixed:
Tracker moved
A user-declared destructor suppresses the implicit move constructor and move assignment. The copy constructor is still generated (that generation is deprecated, but compilers do it silently), and an rvalue const Logged& binding is a perfectly valid match, so std::move(l) copies. If Tracker were a std::vector<std::string> holding a large payload, every “move” would now be a deep copy.
I have made this mistake myself in the most innocent way: adding ~Session() { log("closed"); } to a class for debugging and forgetting to remove it. The class kept working and every test passed; the only symptom was that pushing it into a std::vector got slower, because reallocation was copying every element. There is no warning by default. -Wdeprecated-copy-dtor exists in GCC but is not part of -Wall or -Wextra, so turn it on explicitly if you want the compiler to flag this.
The Fixed version shows the other half of the rule: once you declare move operations, the copy constructor and copy assignment become deleted.
fixedcopy.cpp:2:33: error: use of deleted function 'constexpr Fixed::Fixed(const Fixed&)'
fixedcopy.cpp:1:8: note: 'constexpr Fixed::Fixed(const Fixed&)' is implicitly declared as deleted because 'Fixed' declares a move constructor or move assignment operator
That is usually what you want for a resource-owning type, but if the class should also be copyable you have to = default the copy operations too. This is why the “Rule of Five” advice exists: once you declare any one of destructor, copy constructor, copy assignment, move constructor or move assignment, declare all five and state for each one whether it is defaulted or deleted.
Deleting copy does not give you a movable type
The mirror image of the trap above:
#include <utility>
struct Handle {
Handle() = default;
Handle(const Handle&) = delete;
Handle& operator=(const Handle&) = delete;
};
Handle make() { Handle h; return h; }
int main() { Handle a; Handle b = std::move(a); }
nomove.cpp:7:34: error: use of deleted function 'Handle::Handle(const Handle&)'
nomove.cpp:8:46: error: use of deleted function 'Handle::Handle(const Handle&)'
Declaring the copy constructor (deleted counts as declared) means no move constructor is generated. std::move(a) then resolves to the deleted copy constructor. Note that line 7 fails too: returning a named local is not guaranteed elision (NRVO is optional), so the compiler must have a usable move or copy constructor. return Handle{}; compiles because C++17 guarantees elision for prvalues, which is sometimes why the bug hides until someone refactors a function to use a named variable.
A move-only type therefore needs both halves written out:
class FileHandle {
std::FILE* f_ = nullptr;
public:
explicit FileHandle(const char* path) : f_(std::fopen(path, "r")) {}
~FileHandle() { if (f_) std::fclose(f_); }
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
FileHandle(FileHandle&& o) noexcept : f_(std::exchange(o.f_, nullptr)) {}
FileHandle& operator=(FileHandle&& o) noexcept {
if (this != &o) {
if (f_) std::fclose(f_);
f_ = std::exchange(o.f_, nullptr);
}
return *this;
}
};
The move operations are hand-written here because a raw FILE* has no move semantics of its own; = default would just copy the pointer and you would get a double fclose. They are noexcept because std::vector only moves elements during reallocation when the move constructor cannot throw (otherwise it copies to keep the strong exception guarantee, and for a move-only type that copy is not even available).
Rule of Zero: let members decide
The cleanest way to avoid all of the above is to not write special members at all. If every member already has correct copy/move/destroy behavior, the compiler-generated versions are correct too:
class Resource {
std::unique_ptr<int> data_;
std::string name_;
public:
explicit Resource(int v) : data_(std::make_unique<int>(v)) {}
// no destructor, no copy/move declarations
};
Resource is automatically move-only (because unique_ptr is), its moves are noexcept, and there is no destructor to accidentally suppress anything. With FileHandle above, the Rule of Zero version would be std::unique_ptr<std::FILE, decltype(&std::fclose)> as the member. Reach for explicit = default / = delete only when a member’s behavior is not what the class needs, or when a polymorphic base needs virtual ~Base() = default;.
That last case also triggers the move trap: a base class with virtual ~Base() = default; has no implicit moves. If derived classes are meant to be movable, add the four copy/move declarations as = default (often protected, to avoid slicing through base references).
Blocking heap allocation, and its limits
Deleting the class-level allocation function prevents new T:
struct StackOnly {
static void* operator new(std::size_t) = delete;
static void* operator new[](std::size_t) = delete;
};
auto* p = new StackOnly;
// error: use of deleted function 'static void* StackOnly::operator new(std::size_t)'
It is not airtight. ::new StackOnly calls the global allocation function and compiles fine, and std::vector<StackOnly> works because the allocator obtains raw memory through ::operator new and constructs objects in place. std::make_unique<StackOnly>() is blocked because it uses plain new. This is useful as a guard against accidents, for example on lock guards and scope timers that are meaningless on the heap, but do not treat it as a security boundary.
= default in practice
Some patterns that come up repeatedly:
- Restoring the default constructor. Declaring any constructor removes the implicit default one.
T() = default;brings it back without giving up triviality. - Virtual destructors.
virtual ~IPlugin() = default;is the idiomatic polymorphic base. Remember it suppresses implicit moves. noexcepton defaulted moves. A defaulted move constructor is alreadynoexceptif all members’ moves are. WritingBuffer(Buffer&&) noexcept = default;documents the intent, but be careful: since P1286 (C++20, and g++ 10.3 accepts it in-std=c++17too) an explicitnoexcepton a defaulted function simply wins, even if a member’s move can throw.static_assert(std::is_nothrow_move_constructible_v<Buffer>)then passes, and if that member ever does throw during a move, the program callsstd::terminate.- Defaulted comparisons (C++20).
auto operator<=>(const Point&) const = default;andbool operator==(const Point&) const = default;generate member-wise comparison, the same idea extended beyond the classic special members.
Deleted functions have no code in the binary; they exist only to make overload resolution fail. = default special members that are trivial compile to nothing or to a plain memcpy, which is one reason keeping types trivial matters for hot containers.