5.2.7 · D2C++ Programming

Visual walkthrough — Destructor — RAII principle

2,245 words10 min readBack to topic

Before any code, let's agree on words, because we will not use a symbol we have not first drawn.


Step 1 — Draw the problem: a resource with two ends

WHAT. Picture a resource as a rope with two ends. One end says ACQUIRE ("I grabbed it"), the other says RELEASE ("I gave it back"). A correct program must always tie both ends.

WHY. Every bug in this whole topic is the same bug: the ACQUIRE end got tied, but the RELEASE end dangled loose. Naming the two ends now means every later picture is just "did we tie the second end?".

PICTURE. Look at the rope. The left knot (magenta) is the constructor grabbing memory. The right knot (orange) is the destructor freeing it. The dashed gap in the middle is your code using the resource — and that gap is where danger lives.

Figure — Destructor — RAII principle

Step 2 — The manual way, and the trap inside it

WHAT. Here is the obvious hand-tied version. Read it top to bottom.

void f() {
    int* p = new int[100];   // ACQUIRE  (left knot)
    use(p);                  // the dashed gap
    delete[] p;              // RELEASE  (right knot)
}

Let's name every symbol so nothing is mysterious:

  • int* pp is a pointer, a variable holding the address of the first of our 100 ints. The * means "points to an int".
  • new int[100]asks the system for room for 100 ints and hands back that address.
  • delete[] preturns that room. The [] says "this was an array, free the whole block".

WHY. It looks airtight: the delete[] sits right below the new. So why do we need anything cleverer?

PICTURE. Watch the two control-flow paths. The green path (no problem) walks straight through both knots — safe. But the red path shows use(p) throwing an exception: control jumps out of the function, sailing clean over delete[] p. The right knot never gets tied. The memory is leaked.

Figure — Destructor — RAII principle

Step 3 — The one guarantee we can lean on

WHAT. The C++ standard makes exactly one iron promise we can build on:

When a local object (one that lives on the stack, born between { }) dies — by any exit path — its destructor is called.

"Any exit path" is the whole point: normal }, an early return, or an exception flying past.

WHY. If the only reliable thing is "destructors of local objects always run", then the winning move is obvious: put your RELEASE inside a destructor. Then RELEASE inherits that iron promise for free.

PICTURE. Same two paths as Step 2, but now the right knot is drawn inside a little box labelled "destructor of a local object". Both the green and the red arrow are forced to pass through that box on their way out — the exception cannot skip it, because leaving the scope is what triggers it.

Figure — Destructor — RAII principle

Step 4 — Build the RAII wrapper, one member at a time

WHAT. We wrap the raw pointer inside a class so its life is tied to an object's life.

class IntArray {
    int*   data;                       // (a) the owned resource
    size_t n;                          // (b) how many ints
public:
    IntArray(size_t size)              // (c) CONSTRUCTOR = acquire
        : data(new int[size]), n(size) {}
    ~IntArray() { delete[] data; }     // (d) DESTRUCTOR = release
    int&   operator[](size_t i) { return data[i]; }  // (e) use like an array
    size_t size() const { return n; }
};

Term by term:

  • (a) int* data — the pointer we are guarding. It is private, so no outsider can free it behind our back.
  • (b) size_t nsize_t is just "an unsigned integer big enough to count things". Stores the length.
  • (c) constructor — the : data(new int[size]), n(size) part is an initializer list: it runs before the body, setting members. This is the ACQUIRE. See Constructor — initialization.
  • (d) destructor — the guaranteed RELEASE. This is the whole reason the class exists.
  • (e) operator[] — lets us write a[0] so the wrapper feels like a normal array.

WHY. By making acquisition happen during construction and release happen during destruction, we have literally spelled out RAII: Resource Acquisition Is Initialization.

PICTURE. The class is drawn as a sealed capsule. The resource lives inside it. Birth (constructor) fills the capsule; death (destructor) empties it. The capsule's lifetime and the resource's lifetime are now the same line segment.

Figure — Destructor — RAII principle

Step 5 — Watch it survive an exception

WHAT. Now use the wrapper the naïve code couldn't survive:

void f() {
    IntArray a(100);   // acquire
    a[0] = 42;
    risky();           // <-- suppose this throws
}                      // ~IntArray runs HERE, on every path

WHY. a is a local object. By the Step 3 promise, when control leaves f — normal or via risky()'s exception — ~IntArray() runs, which runs delete[] data. No leak, and we wrote zero cleanup lines in f.

PICTURE. Replay Step 2's red exception arrow, but now it is forced through the capsule's death box before it escapes the function. The buffer is freed on its way out. Compare directly: the raw version leaked on this exact path; the RAII version cannot.

Figure — Destructor — RAII principle

Step 6 — The trap: a shallow copy makes two owners

WHAT. So far so good — for one object. Now copy it:

IntArray a(100);
IntArray b = a;   // default copy: copies the POINTER, not the buffer

WHY it bites. The compiler's free copy just duplicates the members bit for bit. So a.data and b.data now hold the same address — they point at one buffer. Two objects, one resource, two owners who each think they must free it.

PICTURE. Two capsules, two arrows, one shared buffer. Both arrows are magenta ("I own this"). When both die, delete[] runs twice on the same block — a double-free, which is undefined behaviour (usually a crash). Look at the two skulls landing on one grave.

Figure — Destructor — RAII principle

Step 7 — Destruction order is LIFO (and why order matters)

WHAT. When several local objects share a scope, they die in reverse order of birth — Last In, First Out, like a stack of plates.

void g(){ A a; B b; }   // born: a, then b
// dies: b first, then a   →  prints "B A"

WHY. A thing born later may depend on a thing born earlier (e.g. b might use a). Killing the newest first guarantees that whatever a dying object still needs is still alive underneath it. Tear the top plate off first, never the bottom.

PICTURE. A stack: a at the bottom, b on top. Green up-arrows show construction order (a then b); orange down-arrows show destruction popping from the top (b then a). The base is destroyed last because everything rests on it.

Figure — Destructor — RAII principle

Step 8 — Degenerate & edge cases (never leave the reader stranded)

WHAT / WHY. Every corner you might hit:

  • Zero-size acquire: IntArray a(0)new int[0] is legal, returns a valid non-null pointer; delete[] on it is fine. No special-casing needed.
  • delete on nullptr: delete[] nullptr; is defined and does nothing. So a moved-from or default-empty wrapper destructs safely.
  • Constructor throws mid-build: if new int[size] throws, the object was never fully constructed, so its destructor does not run — but any members already built do get destroyed. That's why the new in the initializer list is safe: nothing to clean up yet.
  • Polymorphic delete without virtual: deleting a derived object through a Base* when ~Base is not virtual calls only ~Base, skipping ~Derived — the derived resource leaks. Fix: give a polymorphic base a virtual ~Base(). See Virtual Functions & Polymorphism.

PICTURE. A two-panel comparison of polymorphic deletion. Left: non-virtual base — the delete arrow stops at ~Base, and ~Derived's buffer is stranded (leak, orange X). Right: virtual base — the arrow flows ~Derived then ~Base, buffer freed (green tick).

Figure — Destructor — RAII principle
Recall Quick self-checks

IntArray b = a; on the raw wrapper does what to the buffer? ::: A shallow copy — both point to the same buffer → double-free on destruction. Deleting a Derived via Base* with a non-virtual ~Base leaks what? ::: The resources owned by Derived, because ~Derived never runs. Is delete[] nullptr; safe? ::: Yes — it is defined to do nothing.


The one-picture summary

Everything above is one story: tie the resource's life to an object's life, and let the compiler's guaranteed destructor be your cleanup. The final picture shows the full loop — birth grabs, the dashed hazard zone (where exceptions strike), and death releasing on every exit arrow, green and red alike.

Figure — Destructor — RAII principle
Recall Feynman: the whole walkthrough in plain words

You borrow a library book — that's acquiring a resource. Instead of trusting future-you to return it, you drop it into a magic backpack the moment you take it (that's the constructor). The backpack has one rule: the instant you step out of the building's door (leave the scope), it flies the book back to the shelf (that's the destructor). It does this even if you sprint out in a panic because a fire alarm went off (an exception) — the door still triggers the return. The trap? If you photocopy your backpack so two backpacks claim the same book (a shallow copy), both try to return it and the librarian gets two copies of one book back — chaos (a double-free). So either teach your backpack how to truly duplicate the book (Rule of Three) or, smarter, only ever carry books in backpacks the library already built for you — vector, unique_ptr (Rule of Zero). And if you stack several backpacks, you empty the top one first, because the ones underneath might still be holding it up (LIFO destruction).


Connections