Skip to Content
Course content

118: The Rule of Three, Five, and Zero

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I want to show you a piece of code that looks perfectly fine at a glance, but it's a ticking time bomb. This is a classic scenario where a developer manages a raw resource and forgets that C++ provides default behaviors that are often too simplistic for the job.

#include <iostream>
#include <cstring>

class SimpleBuffer {
    char* data;
public:
    SimpleBuffer(const char* text) {
        data = new char[std::strlen(text) + 1];
        std::strcpy(data, text);
    }

    ~SimpleBuffer() {
        delete[] data;
    }

    void print() { std::cout < data < "\n"; }
};

void printDouble(SimpleBuffer buf) {
    buf.print();
}

int main() {
    SimpleBuffer myBuf("Hello, C++!");
    printDouble(myBuf); // Pass by value
    return 0;
}

If you run this, it might work once, or it might crash immediately with a double free detected error. Why? Look at printDouble. I'm passing myBuf by value. Because I didn't define a copy constructor, C++ uses the default one, which does a "shallow copy." It just copies the data pointer address. Now, myBuf and the local buf inside the function both point to the exact same memory on the heap.

The Shallow Copy Trap

Here is where the disaster happens: when printDouble finishes, the local buf object is destroyed, and its destructor calls delete[] data. The memory is gone. But myBuf in main still thinks it owns that memory. When main ends, it tries to call delete[] data on that same address again. That's a double-free, and it's a fast track to a segmentation fault or a security vulnerability.

Implementing the Rule of Three

Since we have a custom destructor, we are now bound by the "Rule of Three." This rule says that if you need a custom destructor, you almost certainly need a custom copy constructor and a custom copy assignment operator. We need to ensure that when an object is copied, it gets its own unique piece of memory—a "deep copy."

class SimpleBuffer {
    char* data;
public:
    SimpleBuffer(const char* text) {
        data = new char[std::strlen(text) + 1];
        std::strcpy(data, text);
    }

    // 1. Destructor
    ~SimpleBuffer() {
        delete[] data;
    }

    // 2. Copy Constructor
    SimpleBuffer(const SimpleBuffer& other) {
        data = new char[std::strlen(other.data) + 1];
        std::strcpy(data, other.data);
    }

    // 3. Copy Assignment Operator
    SimpleBuffer& operator=(const SimpleBuffer& other) {
        if (this == &other) return *this; // Guard against self-assignment
        
        delete[] data; // Clean up existing resource
        data = new char[std::strlen(other.data) + 1];
        std::strcpy(data, other.data);
        return *this;
    }

    void print() { std::cout < data < "\n"; }
};

Now, when printDouble is called, a brand new buffer is allocated for the copy. When the copy is destroyed, the original remains untouched. It's safer, but it's also slower because we're constantly allocating and copying memory.

Boosting Performance with the Rule of Five

In modern C++ (C++11 and later), we have "move semantics." If we're copying a temporary object that's about to be destroyed anyway, why bother allocating new memory? We can just "steal" the pointer from the temporary object. This expands the Rule of Three into the Rule of Five by adding the Move Constructor and the Move Assignment Operator.

// 4. Move Constructor
SimpleBuffer(SimpleBuffer&& other) noexcept {
    data = other.data;    // Steal the pointer
    other.data = nullptr; // Leave the other object in a valid, empty state
}

// 5. Move Assignment Operator
SimpleBuffer& operator=(SimpleBuffer&& other) noexcept {
    if (this == &other) return *this;
    delete[] data;
    data = other.data;
    other.data = nullptr;
    return *this;
}


I'll be honest: writing all five of these is tedious and error-prone. You have to handle self-assignment, null pointers, and exception safety. This is why experienced engineers try to avoid this entirely.

The Rule of Zero

The Rule of Zero is the gold standard of modern C++. It states that you should design your classes so that you don't have to write any of the special member functions. How? By using components that already manage their own memory.

If we replace our raw char* with a std::string or a std::vector<char>, the compiler-generated copy and move operations will just call the string's own copy and move operations. Everything "just works" automatically.

#include <string>

class SimpleBuffer {
    std::string data; // The string manages its own memory!
public:
    SimpleBuffer(const std::string& text) : data(text) {}
    
    // No destructor, copy constructor, or move operators needed.
    // The Rule of Zero in action.
    
    void print() { std::cout < data < "\n"; }
};

This is the most robust way to write C++. You eliminate the possibility of memory leaks and double-frees because you've delegated the resource management to the Standard Library, which has already been battle-tested by millions of developers.




📋 Practical Task

Fixing the Leaky StringManager

You've been handed a StringManager class that manages a dynamic character array. Currently, it only has a constructor and a destructor. It is suffering from memory leaks and crashes when objects are assigned to one another.

Your Task: Implement the Rule of Three for the StringManager class. You must provide a copy constructor and a copy assignment operator to ensure that the class performs deep copies of its internal buffer. Ensure you handle self-assignment in the assignment operator to prevent deleting the buffer before you try to copy it.

#include <iostream>
#include <cstring>

class StringManager {
private:
    char* buffer;
public:
    StringManager(const char* str) {
        buffer = new char[std::strlen(str) + 1];
        std::strcpy(buffer, str);
    }

    ~StringManager() {
        delete[] buffer;
    }

    // TODO: Implement Copy Constructor
    
    // TODO: Implement Copy Assignment Operator

    void display() const {
        if (buffer) std::cout < buffer < "\n";
    }
};

int main() {
    StringManager s1("Original");
    StringManager s2 = s1; // Should trigger copy constructor
    StringManager s3("Temporary");
    s3 = s1;              // Should trigger copy assignment
    
    s1.display();
    s2.display();
    s3.display();
    
    return 0;
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.