5.2.8 · D5C++ Programming

Question bank — Copy constructor and copy assignment — Rule of Three

1,952 words9 min readBack to topic

Prerequisite ideas live in the parent note, with deeper threads in Deep vs shallow copy, Self-assignment and exception safety, Destructors and RAII, and Rule of Zero and smart pointers.


The running class (context for every snippet)

Figure — Copy constructor and copy assignment — Rule of Three

The diagram above is the mental model behind every trap: the three special members are three arrows into and out of an object's owned buffer — birth-by-copy, rebirth-by-copy, and death.


True or false — justify

The compiler always generates a copy constructor for you.
True, but conditionally — it generates one implicitly unless you declare a move member, or unless a member/base makes it deleted (e.g. a non-copyable member); the generated one is member-wise, which is the shallow-copy trap.
If you write only a destructor, the class is still safe to copy.
False — the compiler-given copy is still shallow, so copies share the pointer and your destructor runs delete[] twice on it; needing a destructor is the signal you need all three.
MyString b = a; calls the copy assignment operator.
Falseb is being born, so this is the copy constructor; the = here is initialization, not assignment.
Copy assignment must return void since it only mutates the object.
False — it returns T& (a reference to the same object, i.e. *this) so that chains like a = b = c work, mirroring how built-in = evaluates right-to-left and yields a value.
The self-assignment guard is only needed for literal a = a; in code.
False — aliasing arrives indirectly through arr[i] = arr[j] with i == j, or two references/pointers naming the same object; the danger is runtime identity, not textual identity.
A deep copy is always more correct than a shallow copy.
False — "correct" depends on ownership semantics; a shared, reference-counted resource wants a controlled shallow-style share, but a uniquely owned raw buffer must be deep-copied.
Rule of Zero means you never write any constructors.
False — you may still write ordinary constructors; Rule of Zero specifically means you write none of the five special members by letting RAII members like std::vector/std::unique_ptr handle copy/move/destroy — see Rule of Zero and smart pointers.
Copy-and-swap is slower and should be avoided.
False in the ways that matter — but the trade-off is real: it always allocates a fresh buffer, whereas hand-written direct assignment can reuse the existing buffer when it is already large enough (skip delete[]/new); you pay that possible extra allocation in exchange for self-assignment and strong exception safety for free.

Spot the error

MyString& operator=(const MyString& o) {
    delete[] data;
    data = new char[o.len + 1];
    std::strcpy(data, o.data);
    len = o.len; return *this;
}

::: Missing self-assignment guard — on a = a, delete[] data frees the very buffer o.data points at, so the following strcpy reads freed memory (o.data == data); add if (this == &o) return *this; first.

MyString(const MyString& o) {
    data = o.data;
    len  = o.len;
}

::: Shallow copy in the copy constructor — it copies the pointer, not the bytes, so both objects own the same buffer; it must new char[o.len+1] then strcpy to make an independent buffer.

~MyString() { delete data; }

::: Wrong delete form — the buffer came from new char[...], so releasing it needs delete[] data; mismatching new[] with scalar delete is undefined behavior — see Dynamic memory new and delete.

MyString& operator=(const MyString& o) {
    if (this == &o) return *this;
    len  = o.len;
    data = new char[len + 1];
    std::strcpy(data, o.data);
    return *this;
}

::: Old buffer leaked — assignment overwrites data without delete[] data first, so the object's previous buffer is orphaned forever; free after the self-check, then allocate.

MyString& operator=(MyString other) {   // by value
    data = other.data;
    len  = other.len;
    return *this;
}

::: Broken copy-and-swap — plain assignment (data = other.data) leaks *this's old buffer and leaves other pointing at the same block, so other's destructor frees the buffer you just adopted; you must std::swap the members so other carries away the old guts to be destroyed.

MyString a("hi");
MyString b;
b = a;
// compiler-generated operator= (no user copy ops written)

::: Shallow assignment double-frees — the default operator= copies the pointer, so a and b share one buffer; both destructors run delete[] on it, plus b's original buffer (if any) leaks.


Why questions

Why does "I needed to write one of the three" imply "I need all three"?
Because all three describe the same single fact — how the object's resource is born, copied, and destroyed; if one life-event needs custom handling, the others almost certainly do too, and the compiler's defaults for the rest will be wrong.
Why must operator= free the old resource but the copy constructor must not?
The assigned-to object already owns a buffer that would leak if not released; the constructed object is brand new and owns nothing yet, so there is nothing to free.
Why do we copy the contents rather than just the pointer for a uniquely-owned buffer?
So each object owns an independent block; otherwise destroying one leaves the other with a dangling pointer, and writing through one silently corrupts the other (aliasing) — see Deep vs shallow copy.
Why is copy-and-swap automatically exception-safe?
All allocation happens while building the by-value parameter before any member of *this is touched, and the swap itself must be noexcept (swapping a pointer and a size_t cannot throw); if new throws, it throws during the copy, *this is untouched, and since the swap can never fail once reached, the object either keeps its old state or fully adopts the new one — that all-or-nothing outcome is the strong guarantee, see Self-assignment and exception safety.
Why must the swap in copy-and-swap be noexcept?
If swapping could throw, an exception between swapping data and swapping len would leave the object half-updated (length disagreeing with buffer) — a broken invariant; because swapping raw pointers and integers cannot throw, the swap is a guaranteed-to-succeed commit step, which is exactly what the strong guarantee needs.
Why does taking the parameter by value let copy-and-swap reuse the copy constructor?
A by-value parameter is initialized by copying the argument, which invokes the copy constructor, so all the deep-copy logic is written once and the operator just swaps the resulting guts.
Why does the Rule of Five extend the Rule of Three?
Move operations let a class steal the resource from an expiring temporary instead of deep-copying it, so once you manage a resource by hand you also want move constructor and move assignment for efficiency — see Move semantics and Rule of Five.
Why does RAII make the Rule of Zero possible?
If every member is itself a resource-owning RAII type, each member's own copy/move/destructor handles its resource correctly, so the enclosing class needs no special members at all — see Destructors and RAII.
Why return *this by reference and not by value from operator=?
Returning by reference names the actual left-hand object so (a = b) = c and chaining refer to the real object; returning by value would copy and break identity, and cost an extra allocation.

Edge cases

What happens when you copy-assign an object to itself with a correct guard?
The guard returns early with return *this;, leaving the object completely unchanged — the safe and intended no-op.
What if the owned pointer is nullptr (an empty object) — is delete[] nullptr a problem?
No — delete[] on a null pointer is defined to do nothing, so an empty-state object destructs and copies safely without special-casing.
What does deep-copying an empty string ("") allocate?
One byte for the null terminator (new char[len+1] with len == 0), so even the empty string owns a real 1-byte buffer that must still be delete[]-ed.
If the copy constructor's new throws mid-copy, what state is the new object in — and how do we stay leak-safe?
The object is never considered constructed, so its destructor never runs; the safe pattern allocates into a local first and only assigns the member after success, so nothing half-owned is stored:
MyString(const MyString& o) : data(nullptr), len(o.len) {
    char* tmp = new char[len + 1];   // if this throws, no member points at it
    std::strcpy(tmp, o.data);        // succeeds → now commit
    data = tmp;                      // member set only after allocation worked
}

If new char[...] throws, tmp never existed as a leak (nothing owns it), data is still nullptr, and no partial buffer is orphaned.

Two objects a and b sharing one buffer via shallow copy — is the bug visible immediately?
No — everything works until either destruction (double free) or a mutation through one object that unexpectedly changes the other, which is why the bug is dangerous and delayed.
What happens if you delete only the copy constructor but leave copy assignment defaulted?
The class becomes non-copy-constructible but still copy-assignable with a shallow default, an inconsistent state that invites the same aliasing bug through assignment — declare the whole set coherently.
If a class has a const member, why can the compiler still not give a working copy assignment?
A const member cannot be reassigned, so the implicit copy assignment is deleted; you must either drop the const, redesign, or accept that objects are copy-constructible but not assignable.

Recall One-line self-test

If you can state, for any of the five members, which life-event it handles and what the default gets wrong for a raw pointer, you have understood the Rule of Three, Five, and Zero as one idea.