C++ Module 7 – Classes, objects and RAII
The rule of zero, three and five in C++
See when the compiler's copy of a C++ class is wrong, write the five special member functions for a raw buffer, and prefer the rule of zero.
What you will learn
- Explain when the compiler-generated copy is wrong
- Implement the five special member functions for a resource-owning class
- Apply the rule of zero by composing standard types
- Predict which special member functions the compiler declares
Before you start
What you’ll learn · Before you start
What you will learn
- Explain when the compiler-generated copy is wrong
- Implement the five special member functions for a resource-owning class
- Apply the rule of zero by composing standard types
- Predict which special member functions the compiler declares
Before you start
On this page
Copy a class that owns an array from new[] with the compiler’s own copy, and two objects end up deleting the same
array: AddressSanitizer stops the program at the second delete[] with attempting double-free. Five
special member function: One of the member functions the compiler can write for a class by itself: the default constructor, the destructor, the copy constructor, copy assignment, the move constructor and move assignment. The compiler's versions work member by member. decide what happens when an object is copied,
moved and destroyed: the destructor, the copy constructor, the copy assignment operator, the move constructor and
the move assignment operator. If you do not write them, the compiler does, member by member,
which is exactly right for members that manage themselves, such as std::string, std::vector and
std::unique_ptr, and wrong for a raw owning pointer.
Three rules say which of the five to write: the rule of zero: Build a class from members that manage themselves, such as std::vector, std::string and std::unique_ptr, and declare none of the five copy, move and destruction functions: the compiler's member-by-member versions are then correct., the
rule of three: A class that needs its own destructor, usually because it owns a raw resource, also needs its own copy constructor and copy assignment operator, because the compiler's versions would share the resource. and the rule of five: The rule of three plus the two move operations of C++11: a class that declares any of the destructor, the copy operations or the move operations defines or deletes all five, since declaring one changes the others.. This lesson
reproduces the double free with Apple clang 21 and GCC 16, fixes it by hand, measures what noexcept on a move
changes for std::vector, and shows the empty destructor that turns every move into a copy.
The compiler’s copy copies the pointer
Buffer owns an array of int made with new[] and deletes it in its destructor, as the previous lesson taught. It
does not say how to copy itself, so the compiler writes a copy constructor that copies size_ and data_, a
shallow copy: A copy that duplicates a pointer but not what it points to, so two objects share one resource. The compiler's copy of a class that owns memory through a raw pointer is one, and both copies later delete that memory.:
// Buffer owns an array made with new[], but leaves copying to the compiler. The
// compiler's copy copies the pointer, not the array.
#include <cstddef>
#include <cstdio>
#include <print>
class Buffer {
public:
explicit Buffer(std::size_t size) : size_{size}, data_{new int[size]{}} {}
~Buffer() { delete[] data_; }
int& operator[](std::size_t i) { return data_[i]; }
std::size_t size() const { return size_; }
private:
std::size_t size_;
int* data_;
};
int main() {
Buffer a{3};
Buffer b = a; // the implicit copy constructor: b.data_ = a.data_
b[0] = 99;
std::println("a[0] = {} after writing b[0]", a[0]);
std::println("main ends: b is destroyed, then a");
std::fflush(stdout); // print now: the sanitizer stops the program before the usual flush
} // ~Buffer runs twice on the same array: a double free from session import run
# Built with AddressSanitizer (-fsanitize=address), which stops the program at the
# second delete[]. abort_on_error=0 makes it exit with status 1 instead of aborting.
# Its report goes to a file; grep shows the main lines, and sed replaces the
# addresses, which change from run to run, with 0x...
run("""
clang++ -std=c++23 -Wall -Wextra -g -fsanitize=address shallow_copy.cpp -o shallow_copy
ASAN_OPTIONS=abort_on_error=0 ./shallow_copy 2> asan.txt
grep -E 'ERROR|freed by|previously allocated|shallow_copy.cpp|SUMMARY' asan.txt | sed -E 's/0x[0-9a-f]+/0x.../g; s/==[0-9]+==//'
g++-16 -std=c++23 -Wall -Wextra -g -fsanitize=address shallow_copy.cpp -o shallow_copy-gcc
ASAN_OPTIONS=abort_on_error=0 ./shallow_copy-gcc 2> asan-gcc.txt
grep -E 'ERROR|freed by|previously allocated|shallow_copy.cpp|SUMMARY' asan-gcc.txt | sed -E 's/0x[0-9a-f]+/0x.../g; s/==[0-9]+==//'
""") Output
$ clang++ -std=c++23 -Wall -Wextra -g -fsanitize=address shallow_copy.cpp -o shallow_copy
$ ASAN_OPTIONS=abort_on_error=0 ./shallow_copy 2> asan.txt
a[0] = 99 after writing b[0]
main ends: b is destroyed, then a
[exit status 1]
$ grep -E 'ERROR|freed by|previously allocated|shallow_copy.cpp|SUMMARY' asan.txt | sed -E 's/0x[0-9a-f]+/0x.../g; s/==[0-9]+==//'
ERROR: AddressSanitizer: attempting double-free on 0x... in thread T0:
#1 0x... in Buffer::~Buffer() shallow_copy.cpp:10
#2 0x... in Buffer::~Buffer() shallow_copy.cpp:10
#3 0x... in main shallow_copy.cpp:27
freed by thread T0 here:
#1 0x... in Buffer::~Buffer() shallow_copy.cpp:10
#2 0x... in Buffer::~Buffer() shallow_copy.cpp:10
#3 0x... in main shallow_copy.cpp:27
previously allocated by thread T0 here:
#1 0x... in Buffer::Buffer(unsigned long) shallow_copy.cpp:9
#2 0x... in Buffer::Buffer(unsigned long) shallow_copy.cpp:9
#3 0x... in main shallow_copy.cpp:21
SUMMARY: AddressSanitizer: double-free shallow_copy.cpp:10 in Buffer::~Buffer()
$ g++-16 -std=c++23 -Wall -Wextra -g -fsanitize=address shallow_copy.cpp -o shallow_copy-gcc
$ ASAN_OPTIONS=abort_on_error=0 ./shallow_copy-gcc 2> asan-gcc.txt
a[0] = 99 after writing b[0]
main ends: b is destroyed, then a
[exit status 1]
$ grep -E 'ERROR|freed by|previously allocated|shallow_copy.cpp|SUMMARY' asan-gcc.txt | sed -E 's/0x[0-9a-f]+/0x.../g; s/==[0-9]+==//'
ERROR: AddressSanitizer: attempting double-free on 0x... in thread T0:
#1 0x... in Buffer::~Buffer() shallow_copy.cpp:10
#2 0x... in main shallow_copy.cpp:27
freed by thread T0 here:
#1 0x... in Buffer::~Buffer() shallow_copy.cpp:10
#2 0x... in main shallow_copy.cpp:27
previously allocated by thread T0 here:
#1 0x... in Buffer::Buffer(unsigned long) shallow_copy.cpp:9
#2 0x... in main shallow_copy.cpp:21
SUMMARY: AddressSanitizer: double-free shallow_copy.cpp:10 in Buffer::~Buffer()
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shallow_copy_session.py
Step through the program and watch the one array that both objects point to:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Buffer a{3}. The constructor runs new int[3]{}, which makes three zeros on the free store, and data_ holds their address.
- Buffer b = a. Buffer declares no copy constructor, so the compiler's copies each member: size_ is 3, and data_ is the same address. No second array is made.
- b[0] = 99. Writing through b changes the one array, so a sees it too: the program printed a[0] = 99 after writing b[0].
- b is destroyed. At the closing brace of main, b is destroyed first, because it was made last. ~Buffer runs delete[] data_, and the array is freed.
- a deletes it again. Then ~Buffer runs for a, whose data_ still holds the freed address, and calls delete[] on it a second time. AddressSanitizer stops the program: attempting double-free, at line 10.
- Three stacks. The report gives the bad free, the first free (the same line, in b's destructor) and the allocation on line 9. Without the sanitizer this second delete[] is undefined behaviour.
The first line of output shows the sharing: writing b[0] changed a[0], because both pointers hold the same
address. The report shows the cost. When main ends, b is destroyed and deletes the array, then a is destroyed
and deletes it again. AddressSanitizer, which -fsanitize=address builds into the program with both compilers, stops
at the second delete[] and prints three stacks: where the bad free happened, where the array was freed the first
time (the same line, in another object’s destructor) and where it was allocated. Apple clang’s report shows two
~Buffer() frames where GCC’s shows one, because Clang emits two destructor functions for the class, the
complete-object and the base-object destructor that the Itanium C++ ABI defines, and the program passes through both. The ASAN_OPTIONS=abort_on_error=0 setting only changes how the program stops: on macOS the
sanitizer aborts by default, and with this flag it exits with status 1 instead.
The bug is in every build. Without the sanitizer, the second delete[] is undefined behaviour: the program may crash
at that line, crash much later in unrelated code, or appear to work. The program calls std::fflush(stdout) before
main ends because the sanitizer stops it without the usual flush of buffered output, which would lose the two lines
it printed. The diagram puts the compiler’s copy beside the one the next section writes:
A shallow copy shares the array; a deep copy duplicates it
Text description of the diagram
The diagram has two panels.
- The first panel, "Implicit copy: Buffer b = a;", shows two Buffer objects, a and b, each with size_ = 3 and a data_ pointer. Both pointers lead to the same heap array holding 0, 0, 0. Writing b[0] changes what a sees, and when both objects are destroyed, the same array is deleted twice.
- A dashed arrow labelled "fix" leads to the second panel, "Deep copy: Buffer(const Buffer&)". Here a's pointer leads to a's array and b's pointer leads to a second array of its own, made with new int[3] and filled with a copy of a's values. Each destructor deletes only its own array.
Checkpoint
Check: The compiler's copy of a raw pointer
The rule of three: copies with their own array
A class that owns a resource has to say what copying means. For Buffer, a copy should get its own array with the
same values: a deep copy: A copy that duplicates the resource itself, as a copy constructor does when it allocates a new array and copies the values into it, so the two objects own separate resources.. The rule of three names the functions involved, because they
come as a set: a class that needs a destructor to release something also needs a copy constructor and a copy
assignment operator that duplicate it. Here is Buffer with all five special member functions; numbers 1 to 3 are
the rule of three:
// Buffer again, now with all five special member functions written by hand: a copy
// gets its own array, and a move hands the array over.
#include <algorithm>
#include <cstddef>
#include <print>
#include <utility>
class Buffer {
public:
explicit Buffer(std::size_t size) : size_{size}, data_{new int[size]{}} {}
~Buffer() { delete[] data_; } // 1. destructor
Buffer(const Buffer& other) // 2. copy constructor
: size_{other.size_}, data_{new int[other.size_]} {
std::copy(other.data_, other.data_ + size_, data_);
}
Buffer& operator=(const Buffer& other) { // 3. copy assignment
Buffer copy{other}; // copy first: if new[] throws, *this is unchanged
swap(copy); // take the copy's array; copy leaves with the old one
return *this;
}
Buffer(Buffer&& other) noexcept // 4. move constructor
: size_{std::exchange(other.size_, 0)}, data_{std::exchange(other.data_, nullptr)} {}
Buffer& operator=(Buffer&& other) noexcept { // 5. move assignment
Buffer moved{std::move(other)};
swap(moved);
return *this;
}
void swap(Buffer& other) noexcept {
std::swap(size_, other.size_);
std::swap(data_, other.data_);
}
int& operator[](std::size_t i) { return data_[i]; }
std::size_t size() const { return size_; }
private:
std::size_t size_;
int* data_;
};
int main() {
Buffer a{3};
Buffer b = a; // copy constructor: a second array
b[0] = 99;
std::println("copy: a[0] = {}, b[0] = {}", a[0], b[0]);
Buffer& same = a;
a = same; // self-assignment through a reference
std::println("self: a.size() = {}, a[0] = {}", a.size(), a[0]);
Buffer c = std::move(b); // move constructor: c takes b's array
std::println("move: c[0] = {}, b.size() = {}", c[0], b.size());
b = c; // a moved-from object can be assigned again
std::println("reuse: b.size() = {}, b[0] = {}", b.size(), b[0]);
} - The copy constructor allocates a new array of the same size and copies the values into it.
- The copy assignment operator uses the copy-and-swap: A way to write copy assignment: make a complete copy of the source, swap its members with this object's, and let the copy's destructor free the old resource. It needs no test for self-assignment and changes nothing if the copy throws. idiom. It first makes a
complete copy of
other, then swaps its own members with the copy’s, and lets the copy’s destructor delete the old array. Ifnew int[]throws while the copy is made, nothing in*thishas changed yet, which is the strong exception guarantee. And self-assignment needs no special test:a = acopiesa, swaps, and frees the old array, leaving the same values. Rule C.62 of the C++ Core Guidelines asks for exactly that safety.
The main function assigns through a reference, Buffer& same = a; a = same;, because that is how self-assignment
happens in real code: two names for one object meet, as in v[i] = v[j] when i and j happen to be equal. Step
through that assignment:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- Two names, one object. Buffer& same = a; gives a a second name, so a = same; assigns an object to itself, as v[i] = v[j] does when i equals j.
- Copy first. Buffer copy{other}; runs the copy constructor: a second array of 3 ints, with the values copied in. If new int[] threw here, a would not have changed at all.
- Swap. swap(copy) exchanges size_ and data_: a now owns the second array, and copy owns the first.
- The copy takes the old array. At the end of operator=, copy is destroyed, and its destructor deletes the first array. a keeps the same values in its new array: the program printed a.size() = 3, a[0] = 0.
Checkpoint
Check: Deep copies and copy-and-swap
The rule of five: moves that hand the array over
Copying an array is wasted work when the source is about to disappear, such as a temporary or a local variable being returned. C++11 added two more special member functions for that case. A move takes the source’s resources and leaves the source empty, but still valid, as rule C.64 of the Core Guidelines requires:
- The move constructor takes
other’s pointer and size withstd::exchange, from<utility>, which returns the old value and stores a new one:otheris left with size 0 and a null pointer, which its destructor can delete safely. - The move assignment operator moves
otherinto a temporary and swaps with it, so the old array of*thisis freed by the temporary’s destructor.
std::move(b) itself moves nothing. It marks b as an object whose resources may be taken, so the compiler chooses
the move constructor instead of the copy constructor; the module on move semantics explains the rules behind it.
Built with AddressSanitizer again, the program has nothing to report:
from session import run
# Built with AddressSanitizer, as before: this time it has nothing to report.
run("""
clang++ -std=c++23 -Wall -Wextra -g -fsanitize=address rule_of_five.cpp -o rule_of_five && ./rule_of_five
g++-16 -std=c++23 -Wall -Wextra -g -fsanitize=address rule_of_five.cpp -o rule_of_five-gcc && ./rule_of_five-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra -g -fsanitize=address rule_of_five.cpp -o rule_of_five && ./rule_of_five copy: a[0] = 0, b[0] = 99 self: a.size() = 3, a[0] = 0 move: c[0] = 99, b.size() = 0 reuse: b.size() = 3, b[0] = 99 $ g++-16 -std=c++23 -Wall -Wextra -g -fsanitize=address rule_of_five.cpp -o rule_of_five-gcc && ./rule_of_five-gcc copy: a[0] = 0, b[0] = 99 self: a.size() = 3, a[0] = 0 move: c[0] = 99, b.size() = 0 reuse: b.size() = 3, b[0] = 99
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rule_of_five_session.py
Step through the move and the reuse of the moved-from b:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Before the move. b owns an array of 3 ints whose first value is 99, from the copy earlier in main.
- std::move(b). std::move(b) moves nothing by itself. It marks b as an object whose resources may be taken, so Buffer c = std::move(b); picks the move constructor instead of the copy constructor.
- The pointer changes hands. std::exchange takes b's pointer and size for c and leaves b with 0 and nullptr. No array is made and no value is copied: c[0] is 99, and b.size() is 0.
- Empty, but still valid. A moved-from Buffer is empty but valid, as rule C.64 asks: its destructor may run delete[] on nullptr, which does nothing.
- Reused. b = c; is a copy assignment into the moved-from b, which makes a new array of 3 ints for it: the program printed b.size() = 3, b[0] = 99.
The copy is independent, the self-assignment kept the values, the move left b empty, and the moved-from b could be
assigned again, which is all that “valid but empty” promises.
Why the moves are noexcept
Both move operations are marked noexcept: A specifier that promises a function lets no exception out, as in Buffer(Buffer&&) noexcept. If one tries to leave such a function, std::terminate ends the program. A destructor is noexcept unless it says otherwise. More in “Destructors and RAII in C++”, and rule C.66 of the Core Guidelines asks for
it. The reason is std::vector. When it runs out of room, it allocates a larger array and transfers its elements. If
a transfer threw halfway, the vector could not undo it, so it moves elements only when their move constructor cannot
throw, and copies them otherwise (a type that cannot be copied at all is moved anyway). The difference is easy to
measure:
// When a std::vector runs out of room, it moves its elements to a bigger array, but
// only if their move constructor is noexcept. Otherwise it copies them.
#include <cstddef>
#include <print>
#include <vector>
struct SafeMove {
static inline int copies = 0;
static inline int moves = 0;
SafeMove() = default;
SafeMove(const SafeMove&) { ++copies; }
SafeMove(SafeMove&&) noexcept { ++moves; }
};
struct RiskyMove {
static inline int copies = 0;
static inline int moves = 0;
RiskyMove() = default;
RiskyMove(const RiskyMove&) { ++copies; }
RiskyMove(RiskyMove&&) { ++moves; } // not noexcept
};
int main() {
std::vector<SafeMove> safe;
std::vector<RiskyMove> risky;
std::vector<std::size_t> capacities; // each capacity safe had, in order
for (int i = 0; i < 1000; ++i) {
safe.emplace_back();
risky.emplace_back();
if (capacities.empty() || capacities.back() != safe.capacity()) capacities.push_back(safe.capacity());
}
std::println("capacities: {}", capacities);
std::println("noexcept move: {} copies, {} moves", SafeMove::copies, SafeMove::moves);
std::println("throwing move: {} copies, {} moves", RiskyMove::copies, RiskyMove::moves);
} from session import run
# 1,000 elements added one by one, with libc++ and with libstdc++.
run("""
clang++ -std=c++23 -Wall -Wextra growth.cpp -o growth && ./growth
g++-16 -std=c++23 -Wall -Wextra growth.cpp -o growth-gcc && ./growth-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra growth.cpp -o growth && ./growth capacities: [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024] noexcept move: 0 copies, 1023 moves throwing move: 1023 copies, 0 moves $ g++-16 -std=c++23 -Wall -Wextra growth.cpp -o growth-gcc && ./growth-gcc capacities: [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024] noexcept move: 0 copies, 1023 moves throwing move: 1023 copies, 0 moves
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 growth_session.py
The first line shows how both standard libraries grew the vector: its capacity doubled from 1 to 1,024, so elements
were transferred 1 + 2 + 4 + … + 512 = 1,023 times in total. With a noexcept move constructor, all 1,023 transfers
were moves; without it, all 1,023 were copies, and for a type like Buffer each copy allocates and fills a new array.
Key idea
Mark move operations noexcept: std::vector moves its elements when it grows only if their move constructor
cannot throw, and copies every one of them otherwise.
Checkpoint
Check: Moves, and why they are noexcept
Declaring one special member changes which others you get
The compiler does not always write the move operations. The standard declares an implicit move constructor and move
assignment only for a class that declares no copy operation, no move operation and no destructor. Declaring a
destructor, even an empty one or one written = default, is enough to lose them, and then std::move quietly falls
back to the copy constructor:
// Declaring a destructor, even one that does nothing, stops the compiler from
// writing the move operations. std::move then quietly copies.
#include <print>
#include <utility>
struct Payload {
static inline int copies = 0;
static inline int moves = 0;
Payload() = default;
Payload(const Payload&) { ++copies; }
Payload(Payload&&) noexcept { ++moves; }
};
struct Plain { // no special member functions declared
Payload data;
};
struct WithDestructor {
Payload data;
~WithDestructor() {} // user-declared: no implicit moves
};
struct WithDefaultedDestructor {
Payload data;
~WithDefaultedDestructor() = default; // still user-declared
};
void report(const char* name) {
std::println("{:<24} {} copies, {} moves", name, Payload::copies, Payload::moves);
Payload::copies = 0;
Payload::moves = 0;
}
int main() {
Plain p;
Plain p2 = std::move(p);
report("Plain");
WithDestructor w;
WithDestructor w2 = std::move(w);
report("WithDestructor");
WithDefaultedDestructor d;
WithDefaultedDestructor d2 = std::move(d);
report("WithDefaultedDestructor");
(void)p2, (void)w2, (void)d2; // not used again: keeps the compilers from warning
} from session import run
# lost_moves.cpp built and run with both compilers; then each compiler is asked to
# warn about the implicit copy (-Wdeprecated for Clang, -Wdeprecated-copy-dtor for GCC).
run("""
clang++ -std=c++23 -Wall -Wextra lost_moves.cpp -o lost_moves && ./lost_moves
g++-16 -std=c++23 -Wall -Wextra lost_moves.cpp -o lost_moves-gcc && ./lost_moves-gcc
clang++ -std=c++23 -Wall -Wextra -Wdeprecated -c lost_moves.cpp 2>&1 | grep 'warning:'
g++-16 -std=c++23 -Wall -Wextra -Wdeprecated-copy-dtor -c lost_moves.cpp 2>&1 | grep 'warning:'
""") Output
$ clang++ -std=c++23 -Wall -Wextra lost_moves.cpp -o lost_moves && ./lost_moves Plain 0 copies, 1 moves WithDestructor 1 copies, 0 moves WithDefaultedDestructor 1 copies, 0 moves $ g++-16 -std=c++23 -Wall -Wextra lost_moves.cpp -o lost_moves-gcc && ./lost_moves-gcc Plain 0 copies, 1 moves WithDestructor 1 copies, 0 moves WithDefaultedDestructor 1 copies, 0 moves $ clang++ -std=c++23 -Wall -Wextra -Wdeprecated -c lost_moves.cpp 2>&1 | grep 'warning:' lost_moves.cpp:20:5: warning: definition of implicit copy constructor for 'WithDestructor' is deprecated because it has a user-provided destructor [-Wdeprecated-copy-with-user-provided-dtor] lost_moves.cpp:25:5: warning: definition of implicit copy constructor for 'WithDefaultedDestructor' is deprecated because it has a user-declared destructor [-Wdeprecated-copy-with-dtor] $ g++-16 -std=c++23 -Wall -Wextra -Wdeprecated-copy-dtor -c lost_moves.cpp 2>&1 | grep 'warning:' lost_moves.cpp:40:36: warning: implicitly-declared 'WithDestructor::WithDestructor(const WithDestructor&)' is deprecated [-Wdeprecated-copy-dtor]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lost_moves_session.py
Plain moves its member. WithDestructor and WithDefaultedDestructor copy it, and with -Wall -Wextra neither
compiler says a word. The implicit copy that they fall back on is deprecated in such classes, and each compiler has an
option to report it: Clang’s -Wdeprecated flags both classes, while GCC’s -Wdeprecated-copy-dtor flags only the
class whose destructor has a body. A logging line added to a destructor can therefore make a class slower without any
warning, because every move became a copy.
| Criterion | Copies | Moves |
|---|---|---|
| You declare none of the five | Written for you | Written for you |
| You declare a destructor | Written, but deprecated | Not declared: they copy |
| You declare one copy | The other written, deprecated | Not declared: they copy |
| You declare one move | Deleted: move-only | The other not declared |
A destructor added only to log a line
An empty ~WithDestructor() {}, or one that only logs, removes the implicit moves, and every std::move of the
class silently becomes a copy. Rule C.21 of the Core Guidelines turns the table into one habit: if you define or
= delete any of the five, define or = delete all of them. = default asks for the compiler’s version on
purpose, so declare the copy and move operations = default next to such a destructor and the moves stay fast.
Checkpoint
Check: Which special members the compiler declares
The rule of zero: let the members do it
The simplest way to get all five right is to write none of them. If every member manages itself, the compiler’s member-by-member versions are correct, and rule C.20 of the Core Guidelines says to rely on them:
// The same Buffer built from a standard type: no special member functions at all,
// and copying, moving and destroying are all correct.
#include <cstddef>
#include <memory>
#include <print>
#include <type_traits>
#include <utility>
#include <vector>
class Buffer {
public:
explicit Buffer(std::size_t size) : data_(size) {} // size zeros
int& operator[](std::size_t i) { return data_[i]; }
std::size_t size() const { return data_.size(); }
private:
std::vector<int> data_; // owns, copies and frees the array
};
class Owner { // owns one int through std::unique_ptr: movable, not copyable
public:
explicit Owner(int value) : value_{std::make_unique<int>(value)} {}
int value() const { return *value_; }
private:
std::unique_ptr<int> value_;
};
int main() {
Buffer a{3};
Buffer b = a;
b[0] = 99;
std::println("copy: a[0] = {}, b[0] = {}", a[0], b[0]);
Buffer c = std::move(b);
std::println("move: c[0] = {}, c.size() = {}", c[0], c.size());
std::println("Buffer copyable: {}, movable: {}", std::is_copy_constructible_v<Buffer>,
std::is_nothrow_move_constructible_v<Buffer>);
std::println("Owner copyable: {}, movable: {}", std::is_copy_constructible_v<Owner>,
std::is_nothrow_move_constructible_v<Owner>);
Owner first{7};
Owner second = std::move(first);
std::println("second.value() = {}", second.value());
} from session import run
# Built with AddressSanitizer: nothing to report, and no special member function written.
run("""
clang++ -std=c++23 -Wall -Wextra -g -fsanitize=address rule_of_zero.cpp -o rule_of_zero && ./rule_of_zero
g++-16 -std=c++23 -Wall -Wextra -g -fsanitize=address rule_of_zero.cpp -o rule_of_zero-gcc && ./rule_of_zero-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra -g -fsanitize=address rule_of_zero.cpp -o rule_of_zero && ./rule_of_zero copy: a[0] = 0, b[0] = 99 move: c[0] = 99, c.size() = 3 Buffer copyable: true, movable: true Owner copyable: false, movable: true second.value() = 7 $ g++-16 -std=c++23 -Wall -Wextra -g -fsanitize=address rule_of_zero.cpp -o rule_of_zero-gcc && ./rule_of_zero-gcc copy: a[0] = 0, b[0] = 99 move: c[0] = 99, c.size() = 3 Buffer copyable: true, movable: true Owner copyable: false, movable: true second.value() = 7
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rule_of_zero_session.py
This Buffer holds a std::vector<int>, which copies its elements, moves its array and frees it, so the class needs
no special member function at all, and AddressSanitizer finds nothing. Owner holds a std::unique_ptr, which can be
moved but not copied, and the compiler draws the conclusion by itself: Owner’s copy constructor is deleted and its
move is noexcept, as the type traits print. Read the lesson’s three classes side by side:
| Criterion | Written by hand | What the class can do |
|---|---|---|
| Buffer with int* | All five | Only what the five allow |
| Buffer with vector<int> | None | Copy and move |
| Owner with unique_ptr<int> | None | Move, never copy |
A raw pointer member is the case the rules of three and five exist for; standard members make it unnecessary. Write
the five by hand only in a class whose job is to manage one resource, the way std::vector manages an array, and keep
that class small. Every other class then holds such a manager as a member and follows the rule of zero.
Checkpoint
Check: The rule of zero
Practise: IntArray, by hand and then with zero
IntArray is a fixed-size array of int on the heap, with a destructor and nothing else. Its tests check deep copies,
self-assignment, moves and noexcept, and they run with AddressSanitizer:
C header · int_array.h
// Starter code for "Your turn": give IntArray its copy and move operations.
#pragma once
#include <algorithm>
#include <cstddef>
#include <utility>
// A fixed-size array of ints on the heap: IntArray{4} holds four zeros.
class IntArray {
public:
explicit IntArray(std::size_t size) : size_{size}, data_{new int[size]{}} {}
~IntArray() { delete[] data_; }
// TODO: the copy constructor, the copy assignment operator, the move constructor and
// the move assignment operator. Until you write them, the compiler's versions copy
// the pointer, and two IntArrays delete the same array.
int& operator[](std::size_t i) { return data_[i]; }
int operator[](std::size_t i) const { return data_[i]; }
std::size_t size() const { return size_; }
const int* data() const { return data_; }
private:
std::size_t size_;
int* data_;
}; C++ · int_array_tests.cpp
// Sample tests for int_array.h, built with -fsanitize=address. Each test has its own
// scope, so a double free shows up as soon as the test that causes it ends.
#include <cstdio>
#include <print>
#include <type_traits>
#include <utility>
#include "int_array.h"
int passed = 0;
int failed = 0;
void expect(bool ok, const char* what) {
if (ok) {
++passed;
} else {
++failed;
std::println("FAIL {}", what);
std::fflush(stdout); // print now, in case the program is stopped later
}
}
IntArray filled(std::size_t size, int first) {
IntArray a{size};
for (std::size_t i = 0; i < size; ++i) a[i] = first + static_cast<int>(i);
return a;
}
void copy_constructor() {
IntArray a = filled(3, 1);
IntArray b = a;
b[0] = 99;
expect(b.data() != a.data(), "a copy has its own array");
expect(a[0] == 1 && b[0] == 99 && b[2] == 3, "writing to the copy leaves the original alone");
}
void copy_assignment() {
IntArray a = filled(2, 5);
IntArray b = filled(4, 0);
b = a;
a[0] = 0;
expect(b.size() == 2 && b[0] == 5 && b[1] == 6, "assignment copies the size and the values");
const IntArray& same = b;
b = same;
expect(b.size() == 2 && b[1] == 6, "self-assignment keeps the values");
}
void move_constructor() {
IntArray a = filled(3, 7);
const int* array = a.data();
IntArray b = std::move(a);
expect(b.data() == array && b[2] == 9, "a move takes the array instead of copying it");
expect(a.size() == 0 && a.data() == nullptr, "a moved-from IntArray is empty");
}
void move_assignment() {
IntArray a = filled(3, 1);
IntArray b = filled(5, 0);
b = std::move(a);
expect(b.size() == 3 && b[0] == 1, "move assignment takes the values");
expect(a.size() == 0 && a.data() == nullptr, "the source is empty after move assignment");
}
int main() {
copy_constructor();
copy_assignment();
move_constructor();
move_assignment();
expect(std::is_nothrow_move_constructible_v<IntArray>, "the move constructor is noexcept");
expect(std::is_nothrow_move_assignable_v<IntArray>, "move assignment is noexcept");
std::println("{} of {} checks passed", passed, passed + failed);
return failed == 0 ? 0 : 1;
} from session import run
# The sample tests against the starter code, with AddressSanitizer. Its report goes to
# a file, and grep shows the summary line.
run("""
clang++ -std=c++23 -Wall -Wextra -g -fsanitize=address int_array_tests.cpp -o int_array_tests
ASAN_OPTIONS=abort_on_error=0 ./int_array_tests 2> asan.txt
grep SUMMARY asan.txt
g++-16 -std=c++23 -Wall -Wextra -g -fsanitize=address int_array_tests.cpp -o int_array_tests-gcc
ASAN_OPTIONS=abort_on_error=0 ./int_array_tests-gcc 2> asan-gcc.txt
grep SUMMARY asan-gcc.txt
""") Output
$ clang++ -std=c++23 -Wall -Wextra -g -fsanitize=address int_array_tests.cpp -o int_array_tests $ ASAN_OPTIONS=abort_on_error=0 ./int_array_tests 2> asan.txt FAIL a copy has its own array FAIL writing to the copy leaves the original alone [exit status 1] $ grep SUMMARY asan.txt SUMMARY: AddressSanitizer: double-free int_array.h:11 in IntArray::~IntArray() $ g++-16 -std=c++23 -Wall -Wextra -g -fsanitize=address int_array_tests.cpp -o int_array_tests-gcc $ ASAN_OPTIONS=abort_on_error=0 ./int_array_tests-gcc 2> asan-gcc.txt FAIL a copy has its own array FAIL writing to the copy leaves the original alone [exit status 1] $ grep SUMMARY asan-gcc.txt SUMMARY: AddressSanitizer: double-free int_array.h:11 in IntArray::~IntArray()
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 int_array_session.py
The starter fails the first test and then dies: the compiler’s copy constructor made two IntArrays share one array,
and the sanitizer stops the program when the second one deletes it. Add the four missing functions to int_array.h
until both compilers print 10 of 10 checks passed and grep finds no SUMMARY line:
- A copy constructor that allocates
new int[other.size_]and copies the values withstd::copy. - A copy assignment operator built with copy-and-swap. Add a
swapmember that swapssize_anddata_. - A
noexceptmove constructor that takes the pointer and the size withstd::exchange, leaving the source empty. - A
noexceptmove assignment operator that moves the source into a temporary and swaps with it.
Then do the opposite exercise: replace int* data_ with a std::vector<int>, delete the destructor and the four
functions you wrote, and run the tests again. With both standard libraries all 10 checks still pass, including the
ones that compare data() pointers, because a moved vector hands its array over instead of copying it.
Afterwards, see which of the five ideas you can apply to classes you have not met here. The questions come back to the idea you got wrong most often, from the shared array to the class that writes nothing, and every choice says why:
Practice that adapts to you
Practise this lesson
11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What are the rule of zero, the rule of three and the rule of five?
They are three ways to get a class’s copy, move and destruction right. The rule of three, from before C++11, says that
a class needing a hand-written destructor, usually because it owns a raw resource such as memory from new[], also
needs a hand-written copy constructor and copy assignment operator, because the compiler’s versions copy the pointer
and lead to a double free. The rule of five adds the move constructor and move assignment of C++11: declaring any of
the five suppresses or deprecates some of the others, so write all five, or = default and = delete them
explicitly. The rule of zero is the preferred design: build classes from members that manage themselves, such as
std::vector, std::string and std::unique_ptr, and write none of the five, because the compiler’s versions are
then correct.
Expect two follow-ups that test whether you can use the rules: a destructor added only for logging, and a class that should move but never copy. Decide both:
Checkpoint
Interview round: two follow-up questions
Copy control decides how a class behaves inside containers and class hierarchies, and the next two questions take one case of each: images in a vector that crash with a double free, and a polymorphic base class that copies where it should move:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
After a refactor that stores Image objects in a std::vector<Image>, the program crashes with a double free. Image holds a raw unsigned char* from new[] and deletes it in its destructor. Diagnose the crash and give two fixes, with their trade-offs.
-
Mid to senior
A Shape base class declares only virtual ~Shape() = default; and a name string. Growing a std::vector<Circle> copies every element, and Shape s = circle; compiles and loses the circle's data. Explain both problems and write the special member functions the base class should declare.
Key takeaways
- The compiler writes the five special member functions member by member, so a raw owning pointer is copied shallowly and later deleted twice, which AddressSanitizer reports with all three stacks.
- A class that manages a raw resource needs deep copies, and copy-and-swap gives its copy assignment the strong exception guarantee and safety for self-assignment.
- Moves hand the resource over and leave the source empty but valid, and they must be
noexcept: a vector of 1,000 elements made 1,023 moves with it and 1,023 copies without. - Declaring a destructor, even
= default, removes the implicit moves, sostd::movesilently copies unless the four others are declared too. - The rule of zero, members that manage themselves and none of the five written, is the default design; the five by hand belong in a small class that manages one resource.
Terms in this lesson
- copy-and-swap
- A way to write copy assignment: make a complete copy of the source, swap its members with this object's, and let the copy's destructor free the old resource. It needs no test for self-assignment and changes nothing if the copy throws.
- deep copy
- A copy that duplicates the resource itself, as a copy constructor does when it allocates a new array and copies the values into it, so the two objects own separate resources.
- rule of five
- The rule of three plus the two move operations of C++11: a class that declares any of the destructor, the copy operations or the move operations defines or deletes all five, since declaring one changes the others.
- rule of three
- A class that needs its own destructor, usually because it owns a raw resource, also needs its own copy constructor and copy assignment operator, because the compiler's versions would share the resource.
- rule of zero
- Build a class from members that manage themselves, such as std::vector, std::string and std::unique_ptr, and declare none of the five copy, move and destruction functions: the compiler's member-by-member versions are then correct.
- shallow copy
- A copy that duplicates a pointer but not what it points to, so two objects share one resource. The compiler's copy of a class that owns memory through a raw pointer is one, and both copies later delete that memory.
- special member function
- One of the member functions the compiler can write for a class by itself: the default constructor, the destructor, the copy constructor, copy assignment, the move constructor and move assignment. The compiler's versions work member by member.
References
- Working Draft, Programming Languages: C++ (N4950): [class.copy.ctor], [class.copy.assign], [depr.impldec], [vector.modifiers] (ISO/IEC JTC1/SC22/WG21)
- Itanium C++ ABI: complete-object and base-object destructors (D1, D2) (Itanium C++ ABI project)
- C++ Core Guidelines: C.20, if you can avoid defining default operations, do (Standard C++ Foundation)
- C++ Core Guidelines: C.21, if you define or =delete any copy, move, or destructor function, define or =delete them all (Standard C++ Foundation)
- C++ Core Guidelines: C.62, make copy assignment safe for self-assignment (Standard C++ Foundation)
- C++ Core Guidelines: C.64, a move operation should move and leave its source in a valid state (Standard C++ Foundation)
- C++ Core Guidelines: C.66, make move operations noexcept (Standard C++ Foundation)
- AddressSanitizer (LLVM Project)
- Sanitizer common flags (abort_on_error) (Google Sanitizers project)
- Program Instrumentation Options (-fsanitize=address) (GNU Project)
- Options Controlling C++ Dialect (-Wdeprecated-copy-dtor) (GNU Project)
- Diagnostic flags in Clang (-Wdeprecated-copy-with-dtor) (LLVM Project)
Report a problem with this lesson
Kept only in this browser. Your Learn progress