5.2.6 · D2C++ Programming

Visual walkthrough — Constructors — default, parameterized, copy, delegating

1,958 words9 min readBack to topic

Step 0 — The vocabulary, drawn before we use it

Before any code, three words. Each is just a picture.

Figure — Constructors — default, parameterized, copy, delegating

In the picture: the stack box on the left is the object a. Inside it, n = 3 is a plain number, and data is an arrow reaching into the heap field on the right, landing on a row of 3 integers. That arrow is the pointer. Hold this image — everything below is just this same picture, copied and mutated.

Our running class for the whole page:

class Buf {
    int* data;   // the arrow
    int  n;      // how many ints the arrow points at
public:
    Buf(int n) : data(new int[n]), n(n) {}   // parameterized ctor
    ~Buf() { delete[] data; }                // gives the heap chunk back
};

Step 1 — Birth: one object grabs one heap chunk

WHAT. We run Buf a(3);. WHY. We need a concrete "source" object to later copy from. Everything downstream branches off this single birth. PICTURE. The constructor's initializer list runs data(new int[3]) firstnew walks to the heap field, fences off 3 integer cells, and hands back their starting address. That address is stored in a.data, so a.data becomes an arrow. Then n(3) writes the number 3 into a.n.

Figure — Constructors — default, parameterized, copy, delegating

Term by term on data(new int[n]):

  • new int[n] — the request: "give me room for n ints, return the address." Here n = 3.
  • data( ... )store that address into the member data. Now the arrow exists.

There is exactly one arrow pointing at that chunk. Remember this count — the crash later is entirely about the arrow count going wrong.


Step 2 — The tempting shortcut: the compiler's free copy

WHAT. We now write Buf b = a; without writing any copy constructor. WHY. The parent note claims the default copy constructor is generated for us and does a "member-wise" copy. Let us watch exactly what member-wise means. PICTURE. The synthesized copy constructor copies each member's value straight across. b.n = a.n copies the number 3 — harmless. b.data = a.data copies the arrow's value, i.e. the same address. So b.data becomes an arrow to the same heap chunk a.data points at.

Figure — Constructors — default, parameterized, copy, delegating

This is the shallow copy. Notice what did not happen: no new heap chunk was created. Two stack boxes, two arrows, but they land on one chunk. The chunk now has an arrow-count of 2 while new was called only once.


Step 3 — The bomb is armed (nothing has crashed yet)

WHAT. Nothing new runs. We just look at the two objects sitting there. WHY. The danger is invisible while the program is calm. Both a and b seem fine — you can read a.data[0] or b.data[0] and get the same value, which feels correct. PICTURE. Two owners, one chunk. Each object believes it owns the chunk, because each has a live arrow into it. The destructor's contract is "I own data, so when I die I delete[] data." Both objects hold that same belief about the same chunk.

Figure — Constructors — default, parameterized, copy, delegating

The key mismatch, stated as an equation of counts:

  • The left number is how many destructors will try to free the chunk.
  • The right number is how many chunks actually exist to be freed.

When these disagree, something must go wrong at the end. Let us watch it.


Step 4 — Death #1: the first destructor fires

WHAT. The scope ends. C++ destroys objects in reverse order of creation, so b dies first. ~Buf() runs delete[] b.data. WHY. This is the destructor honouring its contract — return the heap chunk to the field. PICTURE. delete[] follows b's arrow to the chunk and returns it to the heap (the cells are now free ground). But a.data still points at that same address — its arrow is now dangling: aimed at land that no longer belongs to us.

Figure — Constructors — default, parameterized, copy, delegating

Right now the program might still look alive. The bomb goes off on the very next line.


Step 5 — Death #2: the double free (the crash)

WHAT. Now a dies. ~Buf() runs delete[] a.data. WHY. Same honest contract — but a.data points at a chunk that was already freed in Step 4. PICTURE. delete[] tries to hand back a chunk that is no longer ours. Freeing already-freed memory is the double free: the heap's bookkeeping is corrupted → crash (or worse, a silent security hole).

Figure — Constructors — default, parameterized, copy, delegating

This is the entire disaster the parent note warned about, now seen:

The root cause is one line back in Step 2: b.data = a.data shared the arrow. To fix the ending, we must fix that copy.


Step 6 — The fix: give the copy its own chunk (deep copy)

WHAT. We now write the copy constructor ourselves so it allocates fresh memory. WHY. If each object owns a private chunk, then arrow-count-per-chunk is 1 everywhere, so new-count equals delete[]-count. The mismatch of Step 3 never forms. PICTURE. The copy constructor runs new int[o.n] — a second fence in the heap field — and stores that new address in b.data. Now a's arrow and b's arrow point at different chunks. Then the loop copies the values cell by cell so the contents match.

Buf(const Buf& o) : data(new int[o.n]), n(o.n) {
    for (int i = 0; i < n; i++)
        data[i] = o.data[i];      // copy contents, not the arrow
}
Figure — Constructors — default, parameterized, copy, delegating

Term by term:

  • const Buf& o — the source, taken by reference (&) so copying the argument doesn't itself call this copy constructor → no infinite recursion; const so we promise only to read it.
  • data(new int[o.n]) — the crucial new fence: b gets its own chunk of o.n cells.
  • n(o.n) — copy the count so b knows its size.
  • the loop — copy each integer value so b's chunk holds the same numbers as a's.

Now watch the ending replay: b dies → delete[] frees b's own chunk. a dies → delete[] frees a's own, still-valid chunk. Two news, two delete[]s, no overlap. No crash.


Step 7 — The degenerate cases you must not skip

Every count above assumed n = 3. The corners matter.


The one-picture summary

Figure — Constructors — default, parameterized, copy, delegating

Top row (shallow): one new, two arrows share it, two delete[]double free. Bottom row (deep): each object gets its own new, arrows never overlap, each delete[] frees its own chunk → safe.

Recall Feynman retelling — the whole walk in plain words

Picture a stack box holding a number and an arrow; the arrow reaches into a field where new fenced off some cells (Step 1). When you say Buf b = a and never wrote a copy constructor, the compiler copies the arrow's aim, not the cells — so now two boxes point at the one fenced patch (Step 2). Everything looks fine while the program runs (Step 3): both boxes can read the cells. Trouble waits for the end. The first box to die runs its destructor and returns the patch to the field (Step 4). The second box dies and tries to return the same patch — but it's already gone. That's the double free crash (Step 5). The cure is to make the copy constructor call new a second time so the copy owns its own patch, then copy the cell values across (Step 6). Now one new per box, one delete[] per box, and they never overlap. Empty buffers (n = 0) fall out for free (Step 7). The whole idea in one line: never let two owners point at one patch.

Recall

Why does shallow copy crash at destruction time? ::: Both objects free the same heap chunk; the second free is a double free. What single line inside the copy constructor prevents the double free? ::: data(new int[o.n]) — it allocates a fresh chunk so each object owns its own memory. How many times is new called vs delete[] in the buggy shallow case? ::: new once, delete[] twice — the mismatch is the bug. Why is the copy constructor's parameter const Buf& and not Buf? ::: By value would call the copy constructor to make the argument → infinite recursion; const lets it read temporaries and const sources. Does deep copy need a special case for n = 0? ::: No — new int[0] is valid and the copy loop runs zero times.