Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 11 of 12

C++11 added move operations, which extended the Rule of Three:

Rule of Five — if a class needs any of these, it probably needs all five:

  1. Destructor
  2. Copy constructor
  3. Copy assignment
  4. Move constructor
  5. Move assignment

The reason is unchanged: all five exist because the class owns a resource, and owning a resource raises all five questions at once.

The subtle part: writing one suppresses others

The compiler generates these for you, but declaring some stops it generating others:

| If you declare | You lose | |---|---| | a destructor | the move operations | | a copy constructor | the move operations | | a copy assignment | the move operations | | a move constructor | the copy operations (they become deleted) | | a move assignment | the copy operations (they become deleted) |

The first three rows are the trap, and they are silent:

#include <iostream>
#include <utility>
#include <vector>

class HasDestructor {
    std::vector<int> data;

public:
    HasDestructor() : data(1000, 0) {}

    // This destructor does nothing useful — but declaring it suppresses the
    // move operations, so this class is now copy-only.
    ~HasDestructor() {}

    std::size_t size() const { return data.size(); }
};

class Clean {
    std::vector<int> data;

public:
    Clean() : data(1000, 0) {}
    std::size_t size() const { return data.size(); }
};

int main() {
    HasDestructor a;
    HasDestructor b = std::move(a);      // silently a COPY: 1000 ints duplicated
    std::cout << "HasDestructor moved-from size: " << a.size() << "\n";

    Clean c;
    Clean d = std::move(c);              // a real move
    std::cout << "Clean moved-from size:         " << c.size() << "\n";
    return 0;
}
HasDestructor moved-from size: 1000
Clean moved-from size:         0

Read the two numbers. a still holds 1000 elements after being "moved" from, because it was copied. c is empty, because it was genuinely moved.

An empty destructor — the kind people add out of habit — silently turned every move into a copy. Nothing warned. The program is correct, just quietly slower, in a way that scales with the data.

The Rule of Zero

Which leads to the advice that actually matters:

Rule of Zero — design classes so you write none of the five. Let members that manage themselves do the managing.

#include <iostream>
#include <string>
#include <utility>
#include <vector>

// No destructor, no copy operations, no move operations. All five are
// generated correctly, because every member already behaves correctly.
class Student {
    std::string name;
    std::vector<int> grades;

public:
    Student(std::string n, std::vector<int> g)
        : name(std::move(n)), grades(std::move(g)) {}

    const std::string& get_name() const { return name; }
    std::size_t grade_count() const { return grades.size(); }
    double average() const {
        if (grades.empty()) {
            return 0.0;
        }
        int total = 0;
        for (int g : grades) {
            total += g;
        }
        return static_cast<double>(total) / static_cast<double>(grades.size());
    }
};

int main() {
    Student a("Sokha", {90, 85, 77});

    Student b = a;                       // deep copy, generated
    Student c = std::move(a);            // real move, generated

    std::cout << b.get_name() << " avg " << b.average() << "\n";
    std::cout << c.get_name() << " grades " << c.grade_count() << "\n";
    std::cout << "moved-from name is empty: " << a.get_name().empty() << "\n";
    return 0;
}
Sokha avg 84
Sokha grades 3
moved-from name is empty: 1

Copying, moving, and destruction all work correctly, and the class contains none of that code.

Note the constructor takes its parameters by value and std::moves them into the members. That is the modern idiom: a caller passing a temporary gets a move, a caller passing an lvalue gets one copy, and there is one constructor instead of four overloads.

When you may not use the Rule of Zero

You need the Rule of Five when your class is the wrapper around something raw:

  • A C library handle: FILE*, a socket, an OS resource.
  • A type where you are implementing the ownership itself.

That is a small and specialised category. In application code the answer is nearly always to hold a std::vector, std::string, std::unique_ptr, or an existing RAII wrapper, and write none of the five.

And even then, prefer wrapping the raw thing in a std::unique_ptr with a custom deleter (Module 10) over writing five functions by hand.

The decision in one place

Does your class directly own a raw resource?
│
├── No  ──> write none of the five.               (Rule of Zero — the usual answer)
│
└── Yes ──> can a smart pointer own it instead?
            │
            ├── Yes ──> use it, then write none.  (still Rule of Zero)
            │
            └── No  ──> write all five.           (Rule of Five)

Try it

Take the Student class above and add an empty destructor ~Student() {}. Measure the effect on moving, using a counter or a large vector. Then delete the destructor again.

Checkpoint

  • Rule of Five: destructor, copy constructor, copy assignment, move constructor, move assignment — need one, need all.
  • Declaring a destructor or a copy operation suppresses the generated move operations.
  • An empty destructor therefore turns every move into a copy, silently.
  • Rule of Zero: hold self-managing members and write none of the five.
  • Take constructor parameters by value and std::move them into members.
  • Write the five only when your class is the wrapper around a raw resource.

Questions about this lesson

Log in to ask a question.