Language[lang]
Rule of zero: the special members C++ writes for you
- Section
- Language [lang]
- Standard
- C++20
- Compilers
- MSVC 19.44, Clang 19.1
- Published
- Length
- 13 min read
In this answer9 clauses
- 1The six special members, and what counts as user-declared
- 2What declaring one does to the others
- 3The rule of zero: let members own the resources
- 4When you need all five: a class that owns a raw handle
- 5When the compiler’s move is wrong
- 5.1Fix 1: write the moves, and default the rest
- 5.2Fix 2: take the moves away
- 6Writing move assignment: member-wise, swap, and self-move
- 7Polymorphic bases: a virtual destructor and five defaults
- 8Wrapper templates: conditionally trivial members (C++20)
- 9Pin it down with static_assert and clang-tidy
Abstract
Let members such as std::string, std::vector and std::unique_ptr own the resources and declare no destructor, copy or move operation: the compiler then writes all of them, member by member, and gets them right. Write all five only in a class that owns a raw handle or keeps an invariant across members, because declaring any one of them, even as = default, changes which of the others the compiler declares.

Give a class members that already own their resources (std::string, std::vector, std::unique_ptr) and declare none of the destructor, copy or move operations: the compiler writes all of them, member by member, and gets them right. That is the rule of zero. Write those operations yourself only in a class that owns something the library has no type for, such as a raw handle, or whose members must agree with each other. Then declare all five, because declaring any one of them, even as = default, changes which of the others the compiler writes.
This guide is written for C++20. The rules for which special members the compiler declares are C++11’s and have not changed since. C++11 also deprecated the implicit copy in a class with a user-declared destructor or copy operation, and the current working draft still lists that deprecation. C++20 added one thing: a class template can give a special member several constrained versions, so a wrapper can be trivial exactly when its contents are. Every sample was compiled with MSVC 19.44 (/std:c++20 /EHsc /W4 /permissive-) and Clang 19.1 (-std=c++20 -Wall -Wextra, on Microsoft’s standard library) on one Windows x64 machine, without optimization unless the text says otherwise; the outputs and errors shown are what those builds produced there, and every output is what both printed. The C++11 table of Since When has the first compiler versions: move constructors and move assignment from GCC 4.6, Clang 3.0 and Visual Studio 2015, = default from GCC 4.4, Clang 3.0 and Visual Studio 2015, and = delete from GCC 4.4, Clang 2.9 and Visual Studio 2015.
What changed: a class that manages a resource declares three special members, the destructor and both copies all five, the two moves included, or none at all when its members own the resource.
since C++11
The six special members, and what counts as user-declared
For a class T the compiler can write:
- the default constructor,
T(); - the destructor,
~T(); - the copy constructor,
T(const T&), and copy assignment,T& operator=(const T&); - since C++11, the move constructor,
T(T&&), and move assignment,T& operator=(T&&).
Two words decide everything that follows. A member is user-declared if the class declares it at all: a hand-written body, = default and = delete all count. It is user-provided if it is user-declared and not defaulted or deleted on its first declaration ([dcl.fct.def.default]). Which members the compiler declares depends only on what is user-declared, so ~T() = default; removes the moves exactly as a hand-written destructor does. Triviality depends on what is user-provided, so the defaulted destructor stays trivial when the members’ destructors are.
Three details catch people. A constructor template is never a copy or move constructor, so a template <class U> T(U&&) leaves the implicit ones in place, though like any constructor it removes the default constructor. An implicit member is defined only when something uses it. And it is defined as deleted when a base or member cannot do the operation: a std::unique_ptr member deletes the implicit copy constructor without any declaration from you.
What declaring one does to the others
Read a row as “the class declares this one member and nothing else”. In the cells, yes means implicitly declared and defaulted, none means not declared at all, deleted means implicitly declared as deleted, deprecated means declared and defaulted but deprecated, and yours is the one you wrote.
| You declare | Default ctor | Destructor | Copy ctor | Copy assign | Move ctor | Move assign |
|---|---|---|---|---|---|---|
| Nothing | yes | yes | yes | yes | yes | yes |
| Any other constructor | none | yes | yes | yes | yes | yes |
| Default constructor | yours | yes | yes | yes | yes | yes |
| Destructor | yes | yours | deprecated | deprecated | none | none |
| Copy constructor | none | yes | yours | deprecated | none | none |
| Copy assignment | yes | yes | deprecated | yours | none | none |
| Move constructor | none | yes | deleted | deleted | yours | none |
| Move assignment | yes | yes | deleted | deleted | none | yours |
The cells come from the draft ([class.default.ctor], [class.copy.ctor], [class.copy.assign], [depr.impldec]) and cppreference’s pages on each member, and every row was compiled: type traits for the deleted and missing members, and a member type that prints which of its constructors ran for the moves.
The three kinds of cell fail in different ways:
- none for a move is the quiet one. The class still compiles and
std::move(x)still compiles, but an rvalue binds toconst T&, so overload resolution picks the copy. In the check, every row that declares a destructor or a copy operation printed “copy” forT b = std::move(a);, the~T() = defaultrow included. Whatstd::movedoes and doesn’t do is in move semantics, std::move and rvalue references. - deleted is loud. Declare either move and the implicit copies are deleted, so copying fails to compile until you
= defaultthem back. - deprecated means the implicit copy is still there and still works, but [depr.impldec] deprecates it and says a future standard may define it as deleted. Clang 19 reports each use with
-Wdeprecated(-Wextracovers only the copy-operation rows, not the destructor rows). MSVC 19.44 has the same diagnostic as warning C5267, off by default;/w45267under/W4turns it on.
The rule of zero: let members own the resources
The C++ Core Guidelines put it as C.20, “If you can avoid defining default operations, do”, and cppreference’s rule of three/five/zero page states the reason: classes with custom destructors, copies or moves should deal only with ownership, and other classes shouldn’t have them.
struct CloseFile {
void operator()(std::FILE* f) const noexcept { std::fclose(f); }
};
class Report {
public:
explicit Report(std::string title) : title_(std::move(title)) {}
// No destructor and no copy or move members: the compiler writes
// them, member by member, and each member cleans up after itself.
private:
std::string title_;
std::vector<std::string> rows_;
std::unique_ptr<std::FILE, CloseFile> out_; // fclose on destruction
};
static_assert(!std::is_copy_constructible_v<Report>); // unique_ptr
static_assert(std::is_nothrow_move_constructible_v<Report>);
static_assert(std::is_nothrow_move_assignable_v<Report>);— end example]
The implicit copy and move constructors copy or move the bases and then the members, in the order a constructor initializes them ([class.copy.ctor]), and the destructor destroys them in reverse order. The members decide what the class can do: the std::unique_ptr makes Report move-only, with no declaration from you. The implicit moves are also noexcept, because every member’s move is, and that is what lets a std::vector<Report> move its elements instead of copying them when it grows; when to mark a move constructor noexcept covers that.
A pointer handle from a C library fits std::unique_ptr with a deleter, as CloseFile shows. A handle that isn’t a pointer, such as an integer descriptor, doesn’t, and that is the class where you write the five.
When you need all five: a class that owns a raw handle
// From a C library: integer handles, -1 for "none".
int chan_open(const char* name);
void chan_close(int handle);
class Channel {
public:
explicit Channel(const char* name) : h_(chan_open(name)) {}
~Channel() { if (h_ != -1) chan_close(h_); }
Channel(const Channel&) = delete; // one owner per handle
Channel& operator=(const Channel&) = delete;
Channel(Channel&& other) noexcept
: h_(std::exchange(other.h_, -1)) {}
Channel& operator=(Channel&& other) noexcept {
Channel old(std::move(other)); // take other's handle
std::swap(h_, old.h_); // old now holds ours...
return *this; // ...and closes it here
}
int get() const noexcept { return h_; }
private:
int h_;
};— end example]
Copying would close one handle twice, so the copies are deleted; if the library could duplicate a handle, the copies would call that instead. The move constructor leaves -1 behind, which the destructor checks, so a moved-from Channel is valid and safe to destroy, as C.64 asks. Move assignment moves other into a local and swaps: the local’s destructor closes the handle this object used to hold. A harness that counts the C calls opened two channels, moved and move-assigned them, assigned one to itself through a reference, and saw exactly two closes.
If closing can fail, the destructor still reports it some other way: since C++11 a destructor is noexcept unless it says noexcept(false) or a member’s or base’s destructor isn’t noexcept, and what happens when a destructor throws covers the alternatives. Every class that holds a Channel goes back to the rule of zero.
When the compiler’s move is wrong
The rule of zero assumes each member is correct on its own. This class keeps an invariant across two members:
class Playlist {
public:
void add(std::string song) { songs_.push_back(std::move(song)); }
void next() {
if (!songs_.empty()) now_ = (now_ + 1) % songs_.size();
}
const std::string& current() const { return songs_.at(now_); }
std::size_t size() const { return songs_.size(); }
std::size_t position() const { return now_; }
private:
std::vector<std::string> songs_;
std::size_t now_ = 0; // invariant: now_ < size(), or 0 when empty
};— end example]
The implicit move constructor moves songs_ and copies now_, since moving a std::size_t is copying it:
Playlist a;
a.add("Intro");
a.add("Verse");
a.add("Chorus");
a.next();
a.next(); // playing "Chorus", position 2
Playlist b = std::move(a); // b plays "Chorus"
a.add("Outro"); // reuse the moved-from playlist
std::printf("%zu song(s), position %zu\n", a.size(), a.position());— end example]
Both builds printed 1 song(s), position 2. A moved-from std::vector is in a valid but unspecified state ([lib.types.movedfrom]); Microsoft’s STL leaves it empty, and the index stayed at 2. a.current() then threw std::out_of_range; with operator[] it would have been undefined behavior. The same thing happens to any class whose members must agree: a cached count, an index, or a pointer into another member.
Fix 1: write the moves, and default the rest
class Playlist {
public:
Playlist() = default;
Playlist(const Playlist&) = default;
Playlist& operator=(const Playlist&) = default;
~Playlist() = default;
Playlist(Playlist&& other) noexcept
: songs_(std::move(other.songs_)),
now_(std::exchange(other.now_, 0)) {
other.songs_.clear(); // empty, whatever the vector left
}
Playlist& operator=(Playlist&& other) noexcept {
if (this != &other) { // self-move would clear our own songs
songs_ = std::move(other.songs_);
now_ = std::exchange(other.now_, 0);
other.songs_.clear();
}
return *this;
}
// add(), next(), current(), size() and position() as before
private:
std::vector<std::string> songs_;
std::size_t now_ = 0;
};— end example]
The moved-from playlist is now empty at position 0, the same as a default-constructed one, which is what C.64 calls ideal; after a.add("Outro") both builds printed 1 song at position 0. The defaulted default constructor and copies are not decoration: per the table, declaring a move constructor deletes the implicit copies and removes the default constructor. The defaulted destructor changes nothing and is there so that all five are visible in one place, as C.21 asks.
Fix 2: take the moves away
If moving a Playlist isn’t worth the code, declare the copies and nothing else:
class Playlist {
public:
Playlist() = default;
Playlist(const Playlist&) = default; // declaring these
Playlist& operator=(const Playlist&) = default; // removes the moves
// add(), next(), current(), size() and position() as before— end example]
Now std::move(a) copies, and the source keeps its songs and its position, so it stays consistent. The price is that every move is a copy, and the copy may throw, so a std:: copies when it grows.
Don’t use = delete for this. A deleted function still takes part in overload resolution, so it is chosen for rvalues and then rejected:
class Playlist {
public:
Playlist() = default;
Playlist(const Playlist&) = default;
Playlist(Playlist&&) = delete; // takes part in overload resolution
// ...— end example]
Both compilers then reject Playlist b = std:: and return p; for a local p (MSVC error C2280, Clang “call to deleted constructor”). Only code that needs no move still compiles, such as return Playlist{};, which C++17 constructs in place.
Writing move assignment: member-wise, swap, and self-move
Nobody writes x = std::move(x) on purpose, but C.65 points out that std::swap(a, b) with a and b naming the same object performs one, and asks that move assignment survive it. For its own types, the standard library promises only that self-move-assignment leaves a valid but unspecified state ([lib.types.movedfrom]).
There are two ways to write it. Fix 1 is member-wise with a test: without the if, a self-move would clear the songs and leave now_ at 2. The other way moves into a local and swaps:
Playlist& operator=(Playlist&& other) noexcept {
Playlist tmp(std::move(other)); // other is now empty
songs_.swap(tmp.songs_);
std::swap(now_, tmp.now_);
return *this; // tmp frees our old songs
}— end example]
On a self-move, tmp takes this object’s songs and the swap gives them back, so no test is needed; both forms kept 3 songs at position 2 after a self-move. The swap form costs one extra move construction and destruction per assignment, and the member-wise form costs the test.
Both forms are noexcept honestly: std::vector’s move assignment with std::allocator, clear(), swap() and the std::size_t operations cannot throw. A move that can’t throw should say so, and the noexcept checklist explains why std::vector depends on it.
Polymorphic bases: a virtual destructor and five defaults
A class whose objects are deleted through a pointer to its base needs a public virtual destructor in that base; C.35 asks for public and virtual, or protected and non-virtual. Declaring it is declaring a destructor, so the table applies:
struct Noisy { // prints which constructor ran
Noisy() = default;
Noisy(const Noisy&) { std::puts("copy"); }
Noisy(Noisy&&) noexcept { std::puts("move"); }
};
class Shape {
public:
virtual ~Shape() = default; // so delete through Shape* works
virtual double area() const = 0;
private:
Noisy id_; // stands in for the base class's data
};
class Circle : public Shape {
public:
double area() const override { return std::numbers::pi * r_ * r_; }
private:
double r_ = 1.0;
};
Circle a;
Circle b = std::move(a); // prints "copy"— end example]
Circle declares nothing, so it has an implicit move constructor, but that constructor moves its base through Shape’s constructors, and Shape has no move constructor: the base part is copied. Both builds printed copy, and Clang with -Wdeprecated flagged the deprecated implicit copy of Shape. Default all five, and make them protected:
class Shape {
public:
virtual ~Shape() = default;
virtual double area() const = 0;
protected: // derived classes copy and move; Shape& = Shape& doesn't
Shape() = default;
Shape(const Shape&) = default;
Shape(Shape&&) = default;
Shape& operator=(const Shape&) = default;
Shape& operator=(Shape&&) = default;
private:
Noisy id_;
};— end example]
Now Circle b = std:: prints move, and Circle still follows the rule of zero. Protected is C.67’s advice for a polymorphic class with data: copying or assigning through Shape& would slice off the derived part, and with these members protected, x = y on two Shape& fails to compile (MSVC C2248, Clang “‘operator=’ is a protected member of ‘Shape’”). For a base with no data, C.67 says to = delete them instead, which makes the derived classes non-copyable too, so deep copies go through a virtual clone().
Wrapper templates: conditionally trivial members (C++20)
Before C++20 a class had one destructor, so the usual way to make a wrapper trivially destructible exactly when its contents are was a base class template specialized on std::. C++20 (P0848R3) lets the class declare both and picks the one whose constraints hold:
template <class T>
class Slot { // holds one T or nothing
public:
Slot() noexcept {}
~Slot() requires std::is_trivially_destructible_v<T> = default;
~Slot() { if (full_) value_.~T(); }
// emplace(), reset(), copy and move left out
private:
union { T value_; };
bool full_ = false;
};
static_assert(std::is_trivially_destructible_v<Slot<int>>);
static_assert(!std::is_trivially_destructible_v<Slot<std::string>>);— end example]
Both compilers accept it with /std:c++20 and -std=c++20; under C++17 both stop at requires. The same works for the copy and move members, and it matters only to wrapper and container templates: in ordinary classes the rule of zero gets triviality right for you.
Pin it down with static_assert and clang-tidy
A static_assert after the class turns “the moves are still there” into something the build checks. One trap: std:: is true whenever an rvalue can construct the type, through a copy constructor too, so it cannot see the moves disappear. std:: usually can, because a copy that allocates is not noexcept:
struct Entry {
std::string text;
~Entry() { std::puts("bye"); } // added later: the moves are gone
};
static_assert(std::is_move_constructible_v<Entry>); // passes
static_assert(std::is_nothrow_move_constructible_v<Entry>); // fails— end example]
Both compilers stop at the second line (MSVC C2607, Clang “static assertion failed”). For a class of plain scalars the check can’t tell copy from move, and there is nothing to lose: copying an int is moving it.
The tools catch the rest. clang-tidy’s cppcoreguidelines-special-member-functions enforces C.21: run with clang-tidy 19.1.5 over Entry it reported “class ‘Entry’ defines a non-default destructor but does not define a copy constructor, a copy assignment operator, a move constructor or a move assignment operator”, and it flagged the first Shape for its lone defaulted destructor. Leave its AllowSoleDefaultDtor option at the default, false: set to true, it stopped reporting Shape, which is the class that lost its moves. cppcoreguidelines-virtual-class-destructor flags a class with virtual functions and a public non-virtual destructor (C.35). For the deprecated copies, add Clang’s -Wdeprecated or MSVC’s /w45267. The reasoning behind all of it is in C++ Core Guidelines C.21, clang-tidy and the other analyzers are on Toolchain, and the rest of the core language is in Language.
Questions
Which special member functions does the compiler generate?
Up to six: the default constructor (only when the class declares no constructor at all), the destructor, the copy constructor and copy assignment, and, since C++11, the move constructor and move assignment (only when the class declares no copy operation, no destructor and neither move). Each is defined only when it is used, and it is defined as deleted when a base or member cannot do the operation, which is what a std::unique_ptr member does to the copies.
Does declaring a destructor disable the move constructor?
Yes. A user-declared destructor, ~T() = default included, means the compiler declares no move constructor and no move assignment, so std::move(x) quietly copies, and that implicit copy is deprecated. Remove the destructor, or declare all five, defaulting the ones you do not need to write.
Does = default count as user-declared?
Yes. = default and = delete both make a member user-declared, so they suppress the same implicit members a hand-written body would. What = default on the first declaration keeps is the compiler’s definition: the member stays trivial when the members’ operations are, and its exception specification is worked out from the members.
What is the difference between the rule of three, five and zero?
The rule of three dates from C++98: a class that needs a destructor, a copy constructor or a copy assignment almost certainly needs all three. The rule of five, C++ Core Guidelines C.21, adds the two moves, because since C++11 declaring any of the three removes the implicit moves. The rule of zero, C.20, says most classes should declare none of them and leave ownership to members such as std::vector and std::unique_ptr.
Do I need a virtual destructor if I follow the rule of zero?
Only in a base class whose objects are deleted through a pointer to the base, such as a std:: holding a derived object; C.35 asks for a base destructor that is public and virtual, or protected and non-virtual. Declaring it removes the base's implicit moves, so default the other four, protected as C.67 suggests. Classes that are not used polymorphically need no virtual destructor, and derived classes can still follow the rule of zero.
Sources
- The rule of three/five/zerocppreference.com
- Move constructorscppreference.com
- [class.copy.ctor] Copy/move constructorsC++ standard working draft
- C++ Core Guidelines: Constructors, assignments, and destructorsC++ Core Guidelines