C++ / MOVE SEMANTICS AND SPECIAL MEMBER FUNCTIONS
The rule of three, five, and zero
Decide which of the five special members a class must declare, predict which ones the compiler then stops generating, and design most classes to need none.
What you will learn
- Spot when a destructor doing real work forces you to write both copy operations
- Predict which special members stop being generated once you declare one of the five
- Use self-managing members so the implicit five are already correct (rule of zero)
- Re-default the other four when a virtual destructor kills the move operations
Understanding The rule of three, five, and zero
The five special member functions - destructor, copy constructor, copy assignment, move constructor, move assignment - are not five independent features; together they state what ownership means for a class. If the destructor has real work to do, such as releasing memory or closing a handle, then the compiler's memberwise copy is wrong by definition: it duplicates the handle without duplicating the resource, and two objects end up releasing the same thing. That is the rule of three: a class that needs a non-trivial destructor almost always needs a hand-written copy constructor and copy assignment operator as well.
The generation rules are entangled too, and that is where the rule of five comes from. A user-declared destructor or copy operation stops the compiler from implicitly declaring the move constructor and move assignment operator, so a perfectly correct rule-of-three class silently copies everywhere a move was intended; it still compiles, it is just slower. In the other direction, declaring either move operation defines both copy operations as deleted. Once you declare any one of the five, spell out all five, with a body, with = default, or with = delete, instead of letting the leftovers be decided by rules nobody remembers.
The rule of zero is the conclusion that the right number of special members is usually none. Hand ownership to a member that already implements all five correctly - std::vector, std::string, std::unique_ptr - and the implicit destructor, copies and moves are generated for you and cannot contradict each other; a unique_ptr member even makes the whole class move-only for free. The case that drags you back in is a polymorphic base needing virtual ~Base() = default, because that single declaration suppresses the moves and you must default the other four to get them back.
<iostream>
<utility>
// Owns a raw allocation, so the rule of three applies:
// destructor, copy constructor, copy assignment.
struct Three {
int* p;
explicit Three(int v) : p(new int(v)) {}
Three(const Three& o) : p(new int(*o.p)) { std::cout << "Three: copied\n"; }
Three& operator=(const Three& o) {
if (this != &o) *p = *o.p;
std::cout << "Three: copy-assigned\n";
return *this;
}
~Three() { delete p; }
};
// Same resource, plus the two move operations: the rule of five.
struct Five {
int* p;
explicit Five(int v) : p(new int(v)) {}
Five(const Five& o) : p(new int(*o.p)) { std::cout << "Five: copied\n"; }
Five& operator=(const Five& o) {
if (this != &o) *p = *o.p;
std::cout << "Five: copy-assigned\n";
return *this;
}
Five(Five&& o) noexcept : p(o.p) { o.p = nullptr; std::cout << "Five: moved\n"; }
Five& operator=(Five&& o) noexcept {
std::swap(p, o.p);
std::cout << "Five: move-assigned\n";
return *this;
}
~Five() { delete p; }
};
int main() {
Three a{1};
Three b = std::move(a); // no move constructor exists: this copies
b = std::move(a); // and this copy-assigns
Five c{2};
Five d = std::move(c); // real move
d = Five{3}; // real move assignment
std::cout << *b.p << " " << *d.p << "\n";
}
The five special members form one interlocking set: declaring any of them changes which others the compiler generates, so declare all five or none.
Worked examples
Rule of zero
A class whose members manage themselves needs none of the five and still copies and moves correctly.
<iostream>
<string>
<type_traits>
<utility>
<vector>
struct Config { // no special member is declared at all
std::string name;
std::vector<int> ports;
};
int main() {
Config a{"api", {80, 443}};
Config b = a; // implicit copy: each member copies deeply
Config c = std::move(a); // implicit move: each member moves
b.ports.push_back(8080);
std::cout << b.name << " " << b.ports.size() << "\n";
std::cout << c.name << " " << c.ports.size() << "\n";
std::cout << std::boolalpha
<< std::is_copy_assignable<Config>::value << " "
<< std::is_move_assignable<Config>::value << "\n";
}
Example explained
Line 1Config declares nothing, so all five members are generated and each one simply runs the matching operation on name and ports.
Line 2Config b = a deep-copies because std::string and std::vector deep-copy themselves, which is why push_back on b cannot disturb c.
Line 3Config c = std::move(a) moves both members, so c owns the two port values without a new allocation.
Line 4Both traits print true: nothing was suppressed, because nothing was declared.
Declaring a move deletes the copies
Adding move operations to a class removes its copy constructor and copy assignment operator, not just replaces them.
<iostream>
<type_traits>
<utility>
struct Handle {
int* p = nullptr;
Handle() = default;
explicit Handle(int v) : p(new int(v)) {}
Handle(Handle&& o) noexcept : p(o.p) { o.p = nullptr; }
Handle& operator=(Handle&& o) noexcept { std::swap(p, o.p); return *this; }
~Handle() { delete p; }
};
int main() {
std::cout << std::boolalpha
<< "copy constructible: " << std::is_copy_constructible<Handle>::value << "\n"
<< "copy assignable: " << std::is_copy_assignable<Handle>::value << "\n"
<< "move constructible: " << std::is_move_constructible<Handle>::value << "\n";
Handle h{7};
Handle g = std::move(h);
std::cout << "value now in g: " << *g.p << "\n";
}
Example explained
Line 1Declaring Handle(Handle&&) is enough on its own to define both copy operations as deleted, which the first two output lines show.
Line 2Handle() = default is needed because declaring any constructor removes the implicit default constructor.
Line 3is_move_constructible is true here, but it would also be true for a copy-only class, since a copy constructor binds to rvalues.
Line 4The move constructor nulls o.p so the destructor of h has nothing left to delete.
A virtual destructor costs you the moves
One defaulted destructor suppresses the implicit move operations; defaulting all five brings them back.
<iostream>
<utility>
struct Payload {
Payload() = default;
Payload(const Payload&) { std::cout << "payload copied\n"; }
Payload(Payload&&) noexcept { std::cout << "payload moved\n"; }
Payload& operator=(const Payload&) { std::cout << "payload copy-assigned\n"; return *this; }
Payload& operator=(Payload&&) noexcept { std::cout << "payload move-assigned\n"; return *this; }
};
struct Silent { // only a destructor is declared
Payload data;
virtual ~Silent() = default;
};
struct Restored { // all five are declared
Payload data;
Restored() = default;
virtual ~Restored() = default;
Restored(const Restored&) = default;
Restored& operator=(const Restored&) = default;
Restored(Restored&&) = default;
Restored& operator=(Restored&&) = default;
};
int main() {
Silent s1;
Silent s2 = std::move(s1);
Restored r1;
Restored r2 = std::move(r1);
(void)s2;
(void)r2;
}
Example explained
Line 1Silent declares virtual ~Silent() = default, which stops the move constructor from being implicitly declared, so std::move(s1) selects the still-generated copy constructor.
Line 2Restored spells out all five, and the defaulted move constructor moves its Payload member.
Line 3Restored() = default is required because declaring the copy constructor removed the implicit default constructor.
Line 4GCC's -Wdeprecated-copy-dtor flags exactly the Silent case; without that flag the accidental copy is invisible.
Important notes
= default still counts as user-declared: ~Widget() = default suppresses the implicit move constructor and move assignment exactly as a hand-written destructor does.
std::is_move_constructible<T> is true whenever T can be built from an rvalue, and a copy constructor qualifies, so the trait can never prove that a move constructor exists.
Common mistakes
Adding ~Widget() {} or even ~Widget() = default and assuming moves still work: every std::move on a Widget silently deep-copies, with no error and no warning by default.
Writing a destructor that deletes a raw pointer while keeping the compiler's copy constructor: two objects end up owning one allocation and it is freed twice.
Adding only a move constructor to make a type movable, then being surprised that Widget b = a; no longer compiles, because declaring a move operation deletes both copy operations.
Try it yourself
Change, predict, then run
Write a struct that owns an int* and declares only a destructor, print a line from its copy constructor, and confirm that T b = std::move(a); calls it. Then add a move constructor and move assignment operator and watch the same line switch to the move.
Open the C++ workspaceCheck your understanding
A class declares one special member, a destructor, and nothing else. Which operations does the compiler supply?
- All four copy and move operations, since only the destructor was declared
- The move constructor and move assignment only; the copy operations are deleted
- The copy constructor and copy assignment but no move operations, so std::move on it copies
- Nothing more, so the class can be neither copied nor moved
Show answer
A user-declared destructor blocks the implicit move constructor and move assignment, while the copy operations are still generated (deprecated, but present), so std::move quietly selects the copy constructor and the code compiles. Option 1 is the tempting one because copies really do disappear in the mirror-image case, but only declaring a move operation deletes the copies; a destructor never does.