C++ Copy Constructors and Rule of Three Five Zero Complete Guide
|

C++ Copy Constructors & Rule of 3/5/0: Complete Guide

Back to C++ RoadmapC++ Programming Course • 65 Lessons

Why Copy Semantics Matter

When you write String b = a;, what happens? If a holds a pointer to heap memory, does b get its own copy of that memory, or does it share the same pointer? The answer depends on whether you have written a copy constructor — and getting it wrong causes crashes, memory leaks, and data corruption.

This is one of the most important lessons in C++. The default compiler-generated copy does a member-by-member (shallow) copy. For classes that manage resources — heap allocations, file handles, network sockets — this default is wrong and dangerous. Understanding when and how to write custom copy and move operations is what separates C++ beginners from intermediate programmers.

Shallow Copy vs Deep Copy

Consider a class that owns a heap-allocated array:

#include <iostream>
using namespace std;

class BadArray {
public:
    int* data;
    int size;

    BadArray(int n) : size(n), data(new int[n]()) {}
    ~BadArray() { delete[] data; }
};

int main() {
    BadArray a(3);
    a.data[0] = 42;

    BadArray b = a;  // Shallow copy: b.data == a.data (same pointer!)

    b.data[0] = 99;
    cout << a.data[0] << endl;  // 99! Modifying b changed a!

    // When b is destroyed, it calls delete[] on data
    // When a is destroyed, it calls delete[] on the SAME data → double free → CRASH
    return 0;
}

This is a classic bug. The compiler-generated copy constructor copies the pointer value, not the data it points to. Both objects think they own the same memory. When either modifies it, the other sees the change. When both destructors run, the same memory is freed twice — undefined behavior that usually crashes the program.

The fix is a deep copy: allocate new memory and copy the contents.

The Copy Constructor

A copy constructor creates a new object as a copy of an existing one. Its signature is ClassName(const ClassName& other):

#include <iostream>
#include <cstring>
using namespace std;

class MyString {
    char* data;
    int len;

public:
    MyString(const char* s = "") {
        len = strlen(s);
        data = new char[len + 1];
        strcpy(data, s);
        cout << "Constructor: "" << data << """ << endl;
    }

    // Copy constructor — deep copy
    MyString(const MyString& other) {
        len = other.len;
        data = new char[len + 1];     // allocate new memory
        strcpy(data, other.data);     // copy contents
        cout << "Copy constructor: "" << data << """ << endl;
    }

    ~MyString() {
        cout << "Destructor: "" << data << """ << endl;
        delete[] data;
    }

    void print() const { cout << data << endl; }
};

int main() {
    MyString a("Hello");
    MyString b = a;        // calls copy constructor
    MyString c(a);         // also calls copy constructor

    a.print();  // Hello
    b.print();  // Hello (independent copy)
    return 0;
}

Each object now owns its own memory. Destroying one does not affect the others. The parameter must be a const reference — if it were passed by value, you would need a copy to make the copy, creating infinite recursion.

The Copy Assignment Operator

The copy assignment operator is called when you assign to an already-existing object. It must handle an extra step: releasing the old resources before copying new ones.

MyString& operator=(const MyString& other) {
    if (this == &other) return *this;  // self-assignment check

    delete[] data;                     // free old memory

    len = other.len;
    data = new char[len + 1];         // allocate new
    strcpy(data, other.data);         // copy

    cout << "Copy assignment: "" << data << """ << endl;
    return *this;
}

The self-assignment check (this == &other) prevents disaster when someone writes a = a;. Without it, you would delete the data, then try to copy from deleted memory.

A safer approach is the copy-and-swap idiom, which we covered in the operator overloading lesson:

MyString& operator=(MyString other) {  // pass by value → copy constructor runs
    swap(data, other.data);
    swap(len, other.len);
    return *this;
    // old data freed when 'other' is destroyed
}

Copy-and-swap is automatically exception-safe and handles self-assignment correctly. It is the preferred pattern in modern C++.

The Rule of Three

The Rule of Three states: if you define any one of these, you should define all three:

  1. Destructor
  2. Copy constructor
  3. Copy assignment operator

The reasoning is simple: if your class needs a custom destructor (because it manages a resource), the default copy constructor and assignment operator are almost certainly wrong. They will do a shallow copy, leading to double-free bugs.

class IntBuffer {
    int* data;
    int size;

public:
    // Constructor
    IntBuffer(int n) : size(n), data(new int[n]()) {}

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

    // 2. Copy constructor
    IntBuffer(const IntBuffer& other) : size(other.size), data(new int[other.size]) {
        for (int i = 0; i < size; i++) data[i] = other.data[i];
    }

    // 3. Copy assignment (copy-and-swap)
    IntBuffer& operator=(IntBuffer other) {
        swap(data, other.data);
        swap(size, other.size);
        return *this;
    }

    int& operator[](int i) { return data[i]; }
    int getSize() const { return size; }
};

If you see a class with a destructor but no copy constructor or assignment operator, that is a bug waiting to happen. Modern compilers can warn about this with -Wdeprecated-copy.

Move Semantics (C++11)

Copying is expensive when objects hold large amounts of data. Consider returning a vector of 10 million elements from a function — the copy constructor would duplicate all 10 million elements. C++11 introduced move semantics to solve this.

Moving transfers ownership of resources from one object to another instead of copying them. The source object is left in a valid but unspecified state (typically empty). This is cheap — just swapping a few pointers.

#include <iostream>
#include <utility>
using namespace std;

class HugeData {
    int* data;
    int size;

public:
    HugeData(int n) : size(n), data(new int[n]()) {
        cout << "Constructed " << size << " elements" << endl;
    }

    // Move constructor — steal resources
    HugeData(HugeData&& other) noexcept
        : data(other.data), size(other.size) {
        other.data = nullptr;  // leave source in valid state
        other.size = 0;
        cout << "Moved " << size << " elements (no copy!)" << endl;
    }

    ~HugeData() { delete[] data; }
};

HugeData createData() {
    HugeData local(1000000);
    return local;  // move, not copy
}

int main() {
    HugeData d = createData();  // move constructor called
    return 0;
}

The && in the parameter is an rvalue reference — it binds to temporary objects that are about to be destroyed. The move constructor “steals” the pointer from the temporary instead of copying a million integers. The temporary’s destructor then runs on a nullptr, which delete[] handles safely.

Move Constructor

The move constructor takes an rvalue reference and transfers resources:

MyString(MyString&& other) noexcept
    : data(other.data), len(other.len) {
    other.data = nullptr;
    other.len = 0;
}

Key points:

  • noexcept — always mark move constructors noexcept. The standard library containers (like std::vector) will only use move operations during reallocation if they are noexcept. Without it, they fall back to copying for exception safety.
  • Set source to valid state — the moved-from object must be destructible. Setting pointers to nullptr ensures the destructor does not double-free.
  • No allocation — the entire operation is O(1), just copying a pointer and an integer.

Move Assignment Operator

MyString& operator=(MyString&& other) noexcept {
    if (this == &other) return *this;

    delete[] data;          // free our old resources

    data = other.data;      // steal theirs
    len = other.len;

    other.data = nullptr;   // leave source valid
    other.len = 0;

    return *this;
}

Alternatively, use swap for a cleaner implementation:

MyString& operator=(MyString&& other) noexcept {
    swap(data, other.data);
    swap(len, other.len);
    return *this;
    // other now holds our old data, freed when other is destroyed
}

The Rule of Five

C++11 extends the Rule of Three to the Rule of Five: if you define any one of these five, you should define all five:

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

Here is a complete class following the Rule of Five:

#include <iostream>
#include <cstring>
#include <utility>
using namespace std;

class String {
    char* data;
    int len;

public:
    // Constructor
    String(const char* s = "") : len(strlen(s)), data(new char[strlen(s) + 1]) {
        strcpy(data, s);
    }

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

    // 2. Copy constructor
    String(const String& other) : len(other.len), data(new char[other.len + 1]) {
        strcpy(data, other.data);
    }

    // 3. Copy assignment (copy-and-swap)
    String& operator=(String other) {
        swap(data, other.data);
        swap(len, other.len);
        return *this;
    }

    // 4. Move constructor
    String(String&& other) noexcept : data(other.data), len(other.len) {
        other.data = nullptr;
        other.len = 0;
    }

    // 5. Move assignment
    // Note: copy assignment with pass-by-value already handles moves!
    // The parameter 'other' will be move-constructed when an rvalue is passed.

    friend ostream& operator<<(ostream& os, const String& s) {
        return os << (s.data ? s.data : "(null)");
    }
};

int main() {
    String a("Hello");
    String b = a;                   // copy constructor
    String c = std::move(a);        // move constructor (a is now empty)
    String d("World");
    d = b;                          // copy assignment
    d = String("Temporary");        // move assignment (temporary is rvalue)

    cout << "a: " << a << endl;    // (null) — moved from
    cout << "b: " << b << endl;    // Hello
    cout << "c: " << c << endl;    // Hello
    cout << "d: " << d << endl;    // Temporary

    return 0;
}

Notice that the copy-and-swap idiom for operator= handles both copy and move assignment in one function. When called with an lvalue, the parameter is copy-constructed. When called with an rvalue, it is move-constructed. This is a widely used simplification.

The Rule of Zero

The best approach in modern C++ is the Rule of Zero: design your classes so that the compiler-generated defaults are correct. This means using smart pointers and RAII wrappers instead of raw pointers:

#include <iostream>
#include <memory>
#include <vector>
#include <string>
using namespace std;

class User {
    string name;                        // string handles its own memory
    vector<string> emails;             // vector handles its own memory
    unique_ptr<int[]> scores;          // smart pointer handles its own memory
    int numScores;

public:
    User(string n, int nScores)
        : name(move(n)), scores(make_unique<int[]>(nScores)), numScores(nScores) {}

    // No destructor needed — unique_ptr cleans up automatically
    // No copy constructor needed — compiler generates correct one (except unique_ptr)
    // No assignment operator needed

    // unique_ptr is move-only, so User is automatically move-only too
    // If you need copying, implement it explicitly for the unique_ptr member

    void addEmail(const string& email) { emails.push_back(email); }
    void print() const {
        cout << name << " (" << emails.size() << " emails)" << endl;
    }
};

int main() {
    User alice("Alice", 10);
    alice.addEmail("alice@example.com");
    alice.print();

    User bob = std::move(alice);  // move works automatically
    bob.print();

    return 0;
}

By using string, vector, and unique_ptr, the User class needs no special member functions. Every member manages its own resources. This is the recommended approach for modern C++ — write the Rule of Five only when you are implementing a low-level resource wrapper.

When Are Copy/Move Operations Called?

String a("Hello");           // constructor
String b = a;                // copy constructor (initialization)
String c(a);                 // copy constructor (explicit)
String d;
d = a;                       // copy assignment (already exists)

String e = std::move(a);     // move constructor
String f;
f = std::move(b);            // move assignment

String g = createString();   // move constructor (returned temporary)
passString(a);               // copy constructor (pass by value)
passString(std::move(a));    // move constructor (pass by value, explicit move)

std::move does not actually move anything — it is a cast that converts an lvalue to an rvalue reference, enabling the move constructor or move assignment to be called instead of the copy versions. After std::move(a), a is in a valid but unspecified state — do not use its value.

Deleting Copy/Move Operations

Sometimes you want to prevent copying entirely. Use = delete:

class Singleton {
    static Singleton* instance;
    Singleton() {}  // private constructor

public:
    // Prevent copying and moving
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
    Singleton(Singleton&&) = delete;
    Singleton& operator=(Singleton&&) = delete;

    static Singleton& getInstance() {
        static Singleton s;
        return s;
    }

    void doWork() { cout << "Working..." << endl; }
};

int main() {
    Singleton& s = Singleton::getInstance();
    s.doWork();

    // Singleton copy = s;                // ERROR: deleted
    // Singleton moved = std::move(s);    // ERROR: deleted
    return 0;
}

Common use cases for deleting copy operations: singletons, mutex wrappers, unique resource handles, and classes representing physical hardware or network connections that cannot be duplicated.

Real-World Example: Dynamic Buffer

#include <iostream>
#include <cstring>
#include <algorithm>
#include <utility>
using namespace std;

class Buffer {
    uint8_t* data;
    size_t size_;
    size_t capacity_;

public:
    explicit Buffer(size_t cap = 64)
        : data(new uint8_t[cap]()), size_(0), capacity_(cap) {}

    // Rule of Five
    ~Buffer() { delete[] data; }

    Buffer(const Buffer& other)
        : data(new uint8_t[other.capacity_]), size_(other.size_), capacity_(other.capacity_) {
        memcpy(data, other.data, size_);
    }

    Buffer& operator=(Buffer other) {
        swap(data, other.data);
        swap(size_, other.size_);
        swap(capacity_, other.capacity_);
        return *this;
    }

    Buffer(Buffer&& other) noexcept
        : data(other.data), size_(other.size_), capacity_(other.capacity_) {
        other.data = nullptr;
        other.size_ = 0;
        other.capacity_ = 0;
    }

    // Write data to buffer
    void write(const uint8_t* src, size_t len) {
        if (size_ + len > capacity_) {
            size_t newCap = max(capacity_ * 2, size_ + len);
            uint8_t* newData = new uint8_t[newCap];
            memcpy(newData, data, size_);
            delete[] data;
            data = newData;
            capacity_ = newCap;
        }
        memcpy(data + size_, src, len);
        size_ += len;
    }

    void write(const string& s) {
        write(reinterpret_cast<const uint8_t*>(s.data()), s.size());
    }

    size_t size() const { return size_; }
    size_t capacity() const { return capacity_; }

    string toString() const {
        return string(reinterpret_cast<const char*>(data), size_);
    }

    friend ostream& operator<<(ostream& os, const Buffer& b) {
        return os << "Buffer[" << b.size_ << "/" << b.capacity_ << "]";
    }
};

int main() {
    Buffer buf(16);
    buf.write("Hello, ");
    buf.write("World!");
    cout << buf << " → " << buf.toString() << endl;
    // Buffer[13/16] → Hello, World!

    Buffer copy = buf;                // deep copy
    Buffer moved = std::move(buf);    // move (buf is now empty)

    cout << "copy:  " << copy << " → " << copy.toString() << endl;
    cout << "moved: " << moved << " → " << moved.toString() << endl;
    cout << "buf:   " << buf << endl;  // Buffer[0/0]

    return 0;
}

Practice Exercises

Exercise 1: Write a UniquePtr class template that owns a heap-allocated object. It should be move-only (delete copy operations). Implement the destructor, move constructor, move assignment, operator*, and operator->.

Exercise 2: Create an Image class that stores a 2D pixel array. Implement all Rule of Five members. Measure the time difference between copying and moving a 1920×1080 image.

Exercise 3: Refactor the Buffer class above to follow the Rule of Zero by using std::vector<uint8_t> instead of raw new/delete.

Exercise 4: Create a FileHandle class that wraps a C-style FILE*. It should be move-only. The destructor calls fclose. Test by opening a file, moving the handle to another variable, and verifying the file is closed exactly once.

Summary

Copy constructors and move semantics are fundamental to writing correct C++ classes. Shallow copies of resource-owning classes cause double-free crashes. The Rule of Three says: define destructor, copy constructor, and copy assignment together. C++11 extends this to the Rule of Five with move constructor and move assignment. But the best approach is the Rule of Zero — use string, vector, and smart pointers so you never need to write any of these manually. When you do need custom resource management, always use copy-and-swap for exception safety and mark move operations noexcept. In the next lesson, you will learn about friend functions and static members — two features that give you finer control over class design.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *