9  Copy Constructors and the Rule of Three

10 Copy Constructors and the Rule of Three

Week 4, Session 1. CSS 342.

10.1 Warmup

Reverse String (#344). Reverse a vector<char> in place with two pointers.

class Solution {
 public:
  void reverseString(vector<char>& s) {
    for (int lo = 0, hi = s.size() - 1; lo < hi; ++lo, --hi) {
      swap(s[lo], s[hi]);
    }
  }
};

Two pointers moving inward — the “squeeze” pattern. You will see it again in palindrome checks, container-with-most-water problems, and string compression.

10.2 Learning objectives

  1. Explain what a shallow copy is and why it causes problems for classes that own heap memory.
  2. Implement a deep-copy copy constructor.
  3. Implement operator= with a self-assignment guard that returns *this.
  4. State the Rule of Three and identify when each of the three special functions must be user-defined.
  5. Apply const correctly to copy constructor and assignment operator signatures.

10.3 The disaster: a shallow copy

Consider a class that owns a heap array:

class IntList {
 public:
  IntList(int size) : size_(size) {
    data_ = new int[size];
    for (int i = 0; i < size; ++i) data_[i] = 0;
  }
  ~IntList() {
    delete[] data_;
  }
  void set(int i, int v) { data_[i] = v; }
  int get(int i) const { return data_[i]; }

 private:
  int* data_;
  int size_;
};

What does this do?

int main() {
  IntList a(3);
  a.set(0, 10);
  a.set(1, 20);
  a.set(2, 30);

  IntList b = a;          // <-- COPY
  b.set(0, 999);

  cout << a.get(0) << endl;   // 999. WAIT, WHAT.
}                              // CRASH on second destructor

We never told the compiler how to copy IntList. So it synthesized one — the default copy constructor — which just copies each field. data_ is a pointer, so the pointer is copied, not the array it points to. Now a.data_ and b.data_ are the same address. Modifying b modifies a. And when both go out of scope, both destructors run delete[] on the same memory. Double-free. Crash.

This is the most important code in chapters 4 through 8. Let it sink in.

10.4 The fix: deep copy

A copy constructor takes a const reference to the same type:

class IntList {
 public:
  IntList(const IntList& other) : size_(other.size_) {
    data_ = new int[size_];
    for (int i = 0; i < size_; ++i) {
      data_[i] = other.data_[i];
    }
  }
  // ... rest of class
};

Now IntList b = a; does a real copy: new heap allocation, element-by-element copy of values. Modifying b no longer affects a. Both destructors free their own memory.

The pattern is: allocate fresh, then copy the contents. Never share the pointer.

10.5 Copy assignment — the = for already-existing objects

IntList a(3);
IntList b(5);
b = a;        // <-- not a constructor — both already exist

The copy constructor runs when a new object is being created from an existing one. The copy assignment operator runs when an existing object is being assigned to.

IntList& IntList::operator=(const IntList& other) {
  if (this == &other) return *this;   // self-assignment guard

  delete[] data_;                     // free old memory
  size_ = other.size_;
  data_ = new int[size_];
  for (int i = 0; i < size_; ++i) {
    data_[i] = other.data_[i];
  }
  return *this;
}

Four pieces to memorize:

  1. const IntList& — take the other by const reference.
  2. if (this == &other) return *this; — self-assignment guard. a = a; is legal and must not crash.
  3. delete[] data_; then new — free old, allocate fresh, copy contents.
  4. return *this; — by reference, so a = b = c; works.

Skipping the self-assignment guard is the classic bug. If you forget it and someone writes a = a;, you delete[] data_; then copy from the same data_ you just deleted. Undefined behavior.

10.6 The Rule of Three

If your class needs any of (destructor, copy constructor, copy assignment), it almost certainly needs all three.

The reasoning is symmetric:

  • You wrote a destructor because your class owns a resource.
  • That resource needs deep copying — otherwise the synthesized copy duplicates the pointer.
  • And assignment too, for the same reason.

In modern C++ (post-C++11) this is sometimes the Rule of Five (add move constructor, move assignment). We are sticking with the Rule of Three in 342 — moves are a 343 / advanced topic.

10.7 A complete, correct class

// IntList.h
#pragma once

class IntList {
 public:
  IntList(int size = 0);                       // default + parameterized
  IntList(const IntList& other);               // copy constructor
  IntList& operator=(const IntList& other);    // copy assignment
  ~IntList();                                  // destructor

  int size() const { return size_; }
  int get(int i) const;
  void set(int i, int v);

 private:
  int* data_;
  int size_;
};
// IntList.cpp
#include "IntList.h"
#include <stdexcept>

IntList::IntList(int size) : data_(nullptr), size_(size) {
  if (size > 0) data_ = new int[size]();   // () zero-initializes
}

IntList::IntList(const IntList& other) : size_(other.size_) {
  data_ = (size_ > 0) ? new int[size_] : nullptr;
  for (int i = 0; i < size_; ++i) data_[i] = other.data_[i];
}

IntList& IntList::operator=(const IntList& other) {
  if (this == &other) return *this;
  delete[] data_;
  size_ = other.size_;
  data_ = (size_ > 0) ? new int[size_] : nullptr;
  for (int i = 0; i < size_; ++i) data_[i] = other.data_[i];
  return *this;
}

IntList::~IntList() {
  delete[] data_;
}

int IntList::get(int i) const { return data_[i]; }
void IntList::set(int i, int v) { data_[i] = v; }

Test it:

int main() {
  IntList a(3);
  a.set(0, 10); a.set(1, 20); a.set(2, 30);

  IntList b = a;        // copy ctor
  IntList c(5);
  c = a;                // copy assignment
  c = c;                // self-assignment (should not crash)

  b.set(0, 999);
  cout << a.get(0) << endl;     // 10 (a is unchanged!)
}

Compile with -fsanitize=address. Zero leaks. Zero double-frees. This is the goal state.

10.8 Copy-and-swap idiom (mentioned, not required)

There is an elegant alternative for operator=:

IntList& IntList::operator=(IntList other) {     // pass BY VALUE
  std::swap(data_, other.data_);
  std::swap(size_, other.size_);
  return *this;
}

By taking other by value, the copy constructor runs automatically. Then we swap our fields with the new copy’s fields. When other goes out of scope, its destructor frees the old data. Self-assignment is handled automatically. It is beautiful. It is also more advanced than 342 expects — I am mentioning it here so you recognize it if you see it elsewhere.

10.9 Try it

Take the IntList class above. Comment out the copy constructor. Run the test program with -fsanitize=address. What happens? Restore the copy constructor and run again. Now you’ve felt the bug viscerally — that is the only way it sticks.

10.10 Self-check

1. The Rule of Three says:
d. They go together because they all manage the same resource.
2. Why must operator= include if (this == &other) return *this;?
b. Without the guard, delete[] data_ kills the very memory you are about to copy from.

10.11 Challenges

  1. Implement IntList exactly as shown. Compile with -fsanitize=address. Verify a test program with copies, self-assignments, and chained assignments runs clean.
  2. Implement a Stack class wrapping a heap array. Apply the Rule of Three.
  3. Take an existing class that already uses new and apply the Rule of Three to it. Confirm with sanitizer.

10.12 Where this fits

Three weeks of language mechanics culminating in this chapter. Everything from here is applying this machinery — to templates, to linked lists, to trees.

You are here Coming up
Deep copy, self-assignment guard, Rule of Three Chapter 8: templates — make your class generic