Rule of 3 / 5 / 0 - copy, move, destructor

RoadmapsC++

Scenario

You write a class that holds a raw pointer, copy one object into another, and both destructors run at scope end. Your program crashes with 'double free'.

You never called delete twice — so who did?

Mental model

The compiler silently writes a copy-constructor for you, and its copy is SHALLOW: it duplicates the pointer, not what it points to. Now two objects own the same memory — and both will free it.

If your class manages a raw resource (heap memory, a file handle, a socket), the compiler-generated copy operations do the wrong thing. The Rule of 3 says: if you need a custom destructor, you almost certainly need a custom copy-constructor and copy-assignment too. C++11 adds move-constructor and move-assignment (Rule of 5). The Rule of 0 is the escape hatch: own nothing raw (use std::vector / std::unique_ptr) and you need to write NONE of them.

Explanation

A class that owns a raw pointer is a trap, because C++ gives every class a free copy-constructor and copy-assignment operator — and they copy member-by-member. For a pointer member that means copying the ADDRESS, so the original and the copy point at the same heap block. When both objects go out of scope, both destructors call delete on that one block: undefined behaviour, usually a crash.

The Rule of 3 (pre-C++11): if you define any one of destructor, copy-constructor, or copy-assignment, you need all three, because their presence signals you're managing a resource the defaults can't handle.

C++11 adds move operations, making it the Rule of 5: destructor, copy-ctor, copy-assign, move-ctor, move-assign. A move transfers ownership (steal the pointer, null out the source) instead of duplicating — cheap, and correct for temporaries.

The modern answer is the Rule of 0: don't manage raw resources at all. Hold a std::vector or std::unique_ptr, whose own copy/move semantics are already correct, and let the compiler-generated special members just work. Reach for Rule of 5 only when you're writing the resource-owning wrapper itself.

When writing a move-constructor, you should almost always mark it `noexcept`. Standard library containers like `std::vector` will only use your move-constructor during reallocation if they are guaranteed it won't throw exceptions; otherwise, they fall back to the slower copy-constructor.

Code examples

The bug: shallow copy → double free

struct Buf {
  int* data;
  Buf(int n) : data(new int[n]) {}
  ~Buf() { delete[] data; }   // custom dtor, but NO custom copy
};

int main() {
  Buf a(10);
  Buf b = a;   // shallow copy: b.data == a.data
}                // both dtors run -> delete[] the SAME pointer twice -> crash

Buf defines a destructor but relies on the compiler's shallow copy-constructor. `b = a` copies the pointer value, so a.data and b.data alias the same block. At the closing brace both are destroyed and delete[] is called twice on one address — a double free.

The fix: Rule of 5 (deep copy + move)

struct Buf {
  int* data; int n;
  Buf(int n) : data(new int[n]), n(n) {}
  ~Buf() { delete[] data; }
  Buf(const Buf& o) : data(new int[o.n]), n(o.n) {      // copy: allocate + duplicate
    std::copy(o.data, o.data + o.n, data);
  }
  Buf& operator=(Buf o) { std::swap(data, o.data); std::swap(n, o.n); return *this; } // copy-and-swap
  Buf(Buf&& o) noexcept : data(o.data), n(o.n) { o.data = nullptr; o.n = 0; } // move: steal
};

The copy-constructor allocates its OWN block and duplicates the contents (deep copy), so each object frees a distinct pointer. The move-constructor steals the pointer and nulls the source, so the moved-from object's delete[] is a harmless delete[] nullptr. The copy-and-swap assignment handles both cleanly and is self-assignment-safe.

The best fix: Rule of 0

struct Buf {
  std::vector<int> data;   // owns nothing raw
  Buf(int n) : data(n) {}
  // no dtor, no copy, no move — the compiler's defaults are already correct
};

By delegating ownership to std::vector (whose copy/move are correct), Buf needs none of the five special members. This is the idiom to reach for by default — write the Rule of 5 only inside the resource wrapper itself.

Key points

Common mistakes

Recall questions

Questions & answers

A candidate's String class double-frees on copy. What's missing and how do you fix it three ways?

It has a destructor but relies on the shallow default copy. Fix: (1) define a deep copy-ctor + copy-assign (Rule of 3), (2) add move ops (Rule of 5), or (3) replace the raw char* with std::string (Rule of 0).

Approach: Recognize the destructor-without-copy signature as the tell.

A candidate implements a move constructor for their class but forgets the `noexcept` keyword. Why might this cause a performance drop when using `std::vector`?

When `std::vector` resizes, it needs to move elements to a new memory block. If the move constructor is not marked `noexcept`, `std::vector` will fall back to using the copy constructor to ensure strong exception safety (so it doesn't lose data if an exception is thrown mid-resize). This silently pessimizes performance.

Approach: Understand the interaction between move semantics, exception safety, and standard library containers.

Continue learning

Previous: Classes, constructors, destructors & this

Next: Move semantics & rvalue references

Return to C++ Roadmap