5.2.8 · D4C++ Programming

Exercises — Copy constructor and copy assignment — Rule of Three

3,143 words14 min readBack to topic

Throughout, we reuse the MyString class from the parent note — a class that owns a char* data and a std::size_t len. "Owns" means: it did new[] in its constructor and is responsible for the matching delete[].

Figure — Copy constructor and copy assignment — Rule of Three

The figure above is our mental model for every exercise: a box on the heap (the char[] buffer) and a note (the pointer) telling an object where its box lives. Trouble starts when two notes point at one box.


Level 1 — Recognition

Goal: name which special member runs, with no code writing yet.

Recall Solution 1.1
  • (i) MyString a("cat") — the normal constructor (MyString(const char*)). Neither copy member. a is born from a const char*, not from a MyString.
  • (ii) MyString b = acopy constructor. Even though = appears, b is being created, so this is initialization, not assignment.
  • (iii) MyString cdefault/normal constructor with the default argument "". Neither copy member.
  • (iv) c = acopy assignment operator. c already exists (built on line iii), so its old buffer must be freed and replaced.
  • (v) MyString d(b)copy constructor again. d is born from an existing MyString b. This is the exact same call as (ii), just written with parentheses instead of =.
Recall Solution 1.2
  • (a) False. A constructor builds a brand-new object; there is no old resource, so it must not free anything. Freeing before allocating is a rule for the assignment operator, not the constructor.
  • (b) False. new[] must be matched with delete[]. Mismatched forms are undefined behavior.
  • (c) True. All three describe one idea — how the resource is born-by-copy, reborn-by-copy, and destroyed — so needing one strongly implies needing all.

Level 2 — Application

Goal: write the special members yourself.

Recall Solution 2.1
#include <cstddef>   // std::size_t
 
class IntArray {
    int* data;
    std::size_t size;
public:
    IntArray(std::size_t n) : size(n) {
        data = new int[n]();   // the () zero-initialises every element
    }
    ~IntArray() {
        delete[] data;         // [] because we used new[]
    }
};

Why new int[n]()? The trailing () value-initialises, so each int is 0 rather than garbage. Why delete[]? We allocated an array, so we must free with the array form — Dynamic memory new and delete.

Recall Solution 2.2
IntArray(const IntArray& other) : size(other.size) {
    data = new int[size];                 // OUR OWN buffer
    for (std::size_t i = 0; i < size; ++i)
        data[i] = other.data[i];          // copy the contents
}

Why allocate fresh? So the new object owns an independent block. If we merely copied the pointer (data = other.data), destroying one object would leave the other's note pointing at a thrown-away box — a shallow copy disaster. Why the loop? int has no strcpy; we copy element by element (or std::copy / std::memcpy).

Recall Solution 2.3
IntArray& operator=(const IntArray& other) {
    if (this == &other) return *this;     // (1) self-assignment guard
    delete[] data;                        // (2) free OUR old buffer
    size = other.size;
    data = new int[size];                 // (3) allocate new
    for (std::size_t i = 0; i < size; ++i)
        data[i] = other.data[i];          // (4) copy contents
    return *this;                         // (5) enable chaining
}

Each numbered line answers a "why": (1) if we're assigning to ourselves, skip everything (freeing first would destroy our own source). (2) the left-hand object already owns a buffer; not freeing it leaks it. (3–4) own an independent copy of the data. (5) returning *this by reference lets a = b = c chain — Operator overloading.


Level 3 — Analysis

Goal: read broken code, name the bug, predict the runtime behaviour.

Recall Solution 3.1

Buf b = a; invokes the compiler-generated copy constructor, which does a member-wise copy: it copies the pointer value p. Now a.p == b.p — one heap buffer, two owners (a shallow copy). At return 0; local objects destruct in reverse order of construction: b first, then a.

  • ~b runs delete[] p → the "hi" buffer is freed.
  • ~a runs delete[] p on the same now-freed pointer → double free, which is undefined behavior (typically a crash). Bug name: shallow-copy double-free. Fix: obey the Rule of Three — because a destructor was written, add a deep copy constructor and copy assignment. See Destructors and RAII.
Recall Solution 3.2

Crashing input: self-assignment, e.g. x = x; (or the sneaky indirect form arr[i] = arr[j] when i == j). Why: when this == &other, data and other.data are the same pointer. Line 1 does delete[] data, freeing that block. Then strcpy(data, other.data) copies from other.data, which is the block we just freed → reading freed memory → undefined behavior. Even the length copy reads from a dangling reference. Fix: add the guard as the first line: if (this == &other) return *this;. See Self-assignment and exception safety.

Recall Solution 3.3

No double-free. The deep copy assignment frees a's old "fish"... wait — trace carefully: a holds "dog", b holds "fish". In a = b: self-check passes (different objects) → delete[] frees a's "dog" buffer → a allocates a fresh buffer and copies "fish" into it. Now a.data != b.data (two independent buffers, both containing "fish"). At scope end: ~a frees a's buffer, ~b frees b's different buffer. Two distinct blocks, each freed once → safe. The old "dog" buffer was already freed inside operator=, so nothing leaks either.


Level 4 — Synthesis

Goal: build a complete, correct owning class from a spec.

Recall Solution 4.1
#include <cstddef>
#include <algorithm>   // std::copy
 
class Matrix {
    std::size_t rows, cols;
    double* data;                       // owns rows*cols doubles
public:
    Matrix(std::size_t r, std::size_t c) : rows(r), cols(c) {
        data = new double[r * c]();     // () → all zeros
    }
    ~Matrix() {
        delete[] data;
    }
    Matrix(const Matrix& o) : rows(o.rows), cols(o.cols) {
        data = new double[rows * cols];             // own buffer
        std::copy(o.data, o.data + rows*cols, data);// deep copy
    }
    Matrix& operator=(const Matrix& o) {
        if (this == &o) return *this;               // self guard
        delete[] data;                              // free old
        rows = o.rows; cols = o.cols;
        data = new double[rows * cols];             // new buffer
        std::copy(o.data, o.data + rows*cols, data);// copy
        return *this;
    }
};

Why one flat array? A single new double[r*c] means a single owned resource — exactly one thing to copy and one thing to delete[]. Element (i,j) lives at data[i*cols + j]. This keeps the Rule of Three trivial: one pointer, three members.

Recall Solution 4.2
#include <utility>   // std::swap
 
Matrix& operator=(Matrix o) {          // NOTE: by value → copies here
    std::swap(rows, o.rows);
    std::swap(cols, o.cols);
    std::swap(data, o.data);           // steal o's fresh buffer
    return *this;                      // o (holding OUR old buffer) dies now
}

How it works: taking o by value runs the copy constructor, producing an independent copy before we touch *this. We then swap our internals with the copy's. When o goes out of scope, its destructor frees whatever we swapped into it — i.e. our old buffer. Two free guarantees:

  1. Self-assignment safe: x = x first copies x into o, then swaps — no early delete, so nothing is destroyed before it's read.
  2. Exception safe: if new throws, it throws inside the copy of the parameter, before any swap — *this is untouched, so it's still valid (the strong exception guarantee). See Self-assignment and exception safety.

Level 5 — Mastery

Goal: the subtle, senior-level cases.

Recall Solution 5.1

Why: the copy operations always allocate and copy the whole buffer. But when the source is a temporary about to die (e.g. MyString s = makeString();), copying is wasteful — we could just steal its buffer, since it won't be missed. A move does exactly that: take the pointer, null the source, no allocation.

MyString(MyString&& other) noexcept        // rvalue reference &&
    : data(other.data), len(other.len) {   // steal the pointer
    other.data = nullptr;                  // leave source safe to destruct
    other.len  = 0;
}

Why other.data = nullptr;? After the steal, other's destructor will still run delete[] other.data. delete[] nullptr is a harmless no-op, so nulling prevents a double-free of the stolen buffer. Why noexcept? So standard containers (like std::vector) will move rather than copy on reallocation. See Move semantics and Rule of Five.

Recall Solution 5.2

Principle: the Rule of ZeroRule of Zero and smart pointers. Why it works: the compiler-generated copy constructor, copy assignment, destructor, and move members all operate member-wise. If every member is itself an RAII type (std::string, std::vector, std::unique_ptr) that already knows how to deep-copy, move, and clean itself up, then the member-wise defaults do exactly the right thing. There is no raw owning pointer left to mismanage, so there is no double-free, leak, or aliasing risk. You write none of the five and let correctness compose from the members.

Recall Solution 5.3

Count new[] (allocations) and delete[] (frees):

  • A MyString a("x") → normal ctor: 1 new.
  • B MyString b("yy") → normal ctor: 1 new.
  • C MyString c = a → copy ctor: allocates its own buffer: 1 new.
  • D b = c → copy assignment: self-check passes → delete[] frees b's "yy" (1 delete) → allocates new (1 new).
  • Scope end, reverse order: ~c (1 delete), ~b (1 delete), ~a (1 delete).

Totals: new[] = 4 (A, B, C, D-alloc). delete[] = 4 (D-free, then ~c, ~b, ~a). Every buffer is freed exactly once (no double-free) and none is orphaned (no leak). The "yy" buffer from B is freed inside D; the buffer B ends up holding at scope-end is the one allocated in D.


Active recall

Recall Quick self-quiz

Which member runs in MyString d(b);? ::: The copy constructor — d is being created from an existing object. In operator=, why free before allocate? ::: The target already owns a buffer; freeing it after the self-check prevents a memory leak. What single input breaks a self-assignment-unguarded operator=? ::: x = x (or arr[i]=arr[j] with i==j) — you delete the buffer then copy from it. Why does copy-and-swap not need an explicit self-check? ::: The by-value parameter copies first; nothing of *this is destroyed before the swap, so self-assignment is safe (just slightly wasteful). Why must a move constructor null the source pointer? ::: So the moved-from object's destructor does delete[] nullptr (a no-op) instead of double-freeing the stolen buffer. State the Rule of Zero in one line. ::: Make every member an RAII type so the compiler-generated special members are already correct and you write none.


See also: Copy constructor and copy assignment — Rule of Three · Deep vs shallow copy · Move semantics and Rule of Five · Rule of Zero and smart pointers · Self-assignment and exception safety · Operator overloading · Dynamic memory new and delete · Destructors and RAII