5.2.8 · D3C++ Programming

Worked examples — Copy constructor and copy assignment — Rule of Three

4,104 words19 min readBack to topic

This page is the exhaustive drill for the Rule of Three. The parent note built why three members belong together. Here we hit every case a copy operation can throw at you — construction vs assignment, self-assignment, empty strings, chained assignment, the crash from the compiler-default version, exception safety when new[] fails, and the exam twist about counting how many times memory is allocated and freed.

Before we start, one word we'll lean on constantly:


The exact class we will step through

Every example below runs against this one file. Read it once now so that when an example says "Step 3 — free the old buffer", you can point at the literal line. This is a class (not an aggregate): its members are private, so the only way to touch them is through the constructors, destructor, and operator= shown here.

#include <cstring>   // strlen, strcpy
#include <cstddef>   // size_t
#include <utility>   // swap
 
class MyString {
    char*       data;   // owned resource: address of a heap buffer
    std::size_t len;    // length, NOT counting the '\0'
 
public:
    // (0) normal constructor
    MyString(const char* s = "") {
        len  = std::strlen(s);
        data = new char[len + 1];        // +1 for the '\0' terminator
        std::strcpy(data, s);
    }
 
    // (1) destructor — how the resource dies
    ~MyString() {
        delete[] data;                   // [] pairs with new[]
    }
 
    // (2) copy constructor — born as a deep copy
    MyString(const MyString& other) {
        len  = other.len;
        data = new char[len + 1];        // OWN, independent buffer
        std::strcpy(data, other.data);
    }
 
    // (3) copy assignment — overwrite an existing object
    MyString& operator=(const MyString& other) {
        if (this == &other) return *this;   // guard self-assignment
        char* tmp = new char[other.len + 1];// allocate FIRST (may throw)
        std::strcpy(tmp, other.data);
        delete[] data;                      // only now free the old buffer
        data = tmp;
        len  = other.len;
        return *this;                       // reference → chaining
    }
};

Now, three terms this page uses as headings — defined before we use them:


The scenario matrix

Copying an owning object has only a few moving parts, but they combine into distinct cases. Every worked example below is tagged with the cell it covers. The reader should never meet a situation we did not drill.

# Case class What's special Example
C1 Birth-by-copy (copy constructor) new object, no old buffer to free Ex 1
C2 Rebirth-by-copy (copy assignment, distinct objects) old buffer must be freed Ex 2
C3 Self-assignment a = a guard must fire, else copy-from-freed Ex 3
C4 Indirect self-assignment v[i] = v[j], i==j aliasing hides the a = a Ex 3b
C5 Degenerate input — empty string "" len == 0, still allocate 1 byte for '\0' Ex 4
C6 Chained assignment a = b = c why operator= returns a reference Ex 5
C7 The broken default (no user copy ops) shallow copy → double free crash Ex 6
C8 new[] throws (exception safety) object must survive an allocation failure Ex 6b
C9 Copy-and-swap rewrite one idiom covers C2, C3, C4 at once Ex 7
C10 Exam twist — count allocations & frees ledger must balance Ex 8

The class's layout, restated compactly so the notation is unambiguous:


C1 — Birth-by-copy (the copy constructor)

Figure s01 (deep copy): alt-text — "Left: object a.data (magenta) and b.data (violet). Right: two separate orange buffers each holding cat\0." The magenta arrow is member (2) line data = new char[len+1] for a; the violet arrow is that same line running for b. Because each arrow ends in its own box, a.data != b.data. Trace them — they never meet.

Figure — Copy constructor and copy assignment — Rule of Three

C2 — Rebirth-by-copy (copy assignment, distinct objects)

Figure s02 (reassignment): alt-text — "b.data (violet) left; old grey elephant\0 buffer struck out top-right; fresh orange cat\0 buffer bottom-right." The magenta strike-through is Step 4's delete[] data. The violet arrow into the orange box is Step 4's data = tmp (whose contents came from Step 3's new char[len+1] + strcpy). Read top-to-bottom: free the old grey box, then repoint b at the new orange box.

Figure — Copy constructor and copy assignment — Rule of Three

C3 / C4 — Self-assignment (direct and hidden)


C5 — Degenerate input: the empty string

Recall New angle: what if a caller passes

nullptr instead of ""? strlen(nullptr) is undefined — it is a stricter degenerate case than "". Our default parameter = "" only helps when the caller writes MyString x;; a caller who explicitly writes MyString x(nullptr) still crashes. ::: The empty-string case ("") is safe, but nullptr is a separate, harsher edge that our current constructor does not defend against — a real class would add if (!s) s = ""; as the first line.


C6 — Chained assignment


C7 — The broken compiler default (the crash)

Figure s03 (shallow crash): alt-text — "a.data (magenta) and b.data (violet) both point at a single orange hi\0 buffer via arrows labelled 'same address'." Both arrows land in one box — that is Step 1's member-wise pointer copy. The caption delete[] runs TWICE → CRASH is Steps 2 and 3. Compare with s01 (two boxes): here there is only one box with two owners.

Figure — Copy constructor and copy assignment — Rule of Three

C8 — When new[] throws (exception safety)


C9 — Copy-and-swap rewrite (one idiom, three cases)


C10 — Exam twist: balance the ledger


Active recall

What decides copy-constructor vs copy-assignment for x = y?
Whether x is being created on that line (constructor) or already exists (assignment).
In Ex 2, why must operator= free b's old buffer?
b already owned the "elephant" buffer; skipping the free orphans it (memory leak).
Why must the self-assignment guard compare addresses, not values?
Two different objects can hold equal strings in different buffers; only this == &other detects true aliasing.
For MyString(""), why is new char[len+1] still safe?
len == 0 gives new char[1], one byte for the '\0', so strcpy stays in bounds.
Why does operator= return MyString&?
To support chaining a = b = c and to avoid an unnecessary by-value copy of the result.
In Ex 6, what makes the default copy crash?
Shallow pointer copy means two objects share one buffer, so delete[] runs twice on the same address.
In Ex 6b, why allocate into tmp before delete[] data?
If new[] throws, the object is left unchanged (strong exception guarantee) instead of holding a dangling pointer.
In copy-and-swap, where is the old buffer freed?
When the by-value parameter other (now holding the old guts) destructs at the end of the function.
In Ex 8, why does line 5 (a = a) add zero allocations and zero frees?
The self-assignment guard returns early before any delete[] or new[].

Recall Feynman: the ledger, in one sentence

Every time you say new[] you owe the universe one delete[] — no more, no less. A correct copy constructor and copy assignment just make sure that when objects copy each other, nobody double-pays, nobody skips the bill, and even if the shop runs out of memory mid-purchase, you still walk out owning exactly what you walked in with.