Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

💻Virtual Lab: Smart Pointers

In the previous lab you saw the three classic heap bugs in raw-pointer C++ (leaks, dangling pointers, and double-deletes) and watched each of them happen, live, by misusing aliased Account* pointers around new and delete. As promised, this lab covers smart pointers, which turn this entire category of bugs into compile errors and runtime impossibilities.

The mechanism is RAII (Resource Acquisition Is Initialisation), which is a wrapper object whose destructor calls delete on the heap allocation it owns. When the wrapper goes out of scope at the end of its enclosing function, its destructor runs automatically; the heap allocation is freed; the leak window slams shut. You don’t write delete at all. The wrapper does it for you, in exactly the right place, every time.

The C++ standard library provides three such wrappers, each with different ownership semantics. std::unique_ptr<T> is a single-owner wrapper: 8 bytes of pointer on the stack, copy-disabled at compile time, free-on-destruction. std::shared_ptr<T> is a multi-owner wrapper: 16 bytes on the stack, with reference counting in a separate control block on the heap, free-when-last-owner-dies. std::weak_ptr<T> is a non-owning observer: 16 bytes, can be queried via lock() to safely access the object if it’s still alive. Together these three solve the leak, dangling, and double-delete problems entirely — at the cost of one new problem of their own, the reference cycle, which weak_ptr exists to break.

In this virtual lab you’ll use the same three classes you already know (Account, DepositAccount, and CurrentAccount) but now wrapped in smart pointers instead of held by raw Account*. The heap objects are layout-identical to the previous lab. The vtable mechanism is unchanged. What changes is who calls delete, and when.


If any of the words used in this lab feel hazy, here’s a short reference. Skim it now, come back as needed.

TermMeaning
RAII (Resource Acquisition Is Initialisation)The C++ idiom of tying a resource’s lifetime to an object’s lifetime: the resource is acquired in the constructor and released in the destructor. When the object goes out of scope, the destructor runs automatically and the resource is freed.
std::unique_ptr<T>A smart pointer that owns its heap allocation exclusively. 8 bytes on the stack — exactly the same footprint as a raw pointer. Copy is a compile error. Move transfers ownership. Calls delete automatically when destroyed.
std::shared_ptr<T>A smart pointer that shares ownership through reference counting. 16 bytes on the stack: an object pointer plus a control-block pointer. Copy bumps the strong count; destruction decrements it; the object is destroyed when the count reaches zero.
std::weak_ptr<T>A non-owning observer paired with a shared_ptr. 16 bytes on the stack. Doesn’t keep the object alive, but can be queried via lock() to obtain a temporary shared_ptr if the object hasn’t been destroyed yet.
Control blockA small heap allocation, distinct from the object itself, that holds a shared_ptr’s strong count, weak count, and deleter. Every group of shared_ptrs pointing at the same object shares the same control block.
Strong countThe number of shared_ptrs currently owning a particular object. When the strong count hits zero, the object’s destructor runs and its memory is freed.
Weak countThe number of weak_ptrs currently observing a particular object’s control block. When both strong and weak counts are zero, the control block itself is freed.
make_unique<T>(...)Factory function that allocates a T on the heap and wraps it in a unique_ptr. Equivalent to unique_ptr<T>(new T(...)) but with stronger exception safety.
make_shared<T>(...)Factory function that allocates the object and the control block in a single heap allocation (a fused block). Faster and more cache-friendly than the two-allocation alternative.
std::moveA cast that converts an lvalue into an rvalue reference, signalling that the source is allowed to be “stolen from”. For smart pointers, b = std::move(a) transfers ownership: b takes over, a becomes a moved-from empty pointer.
Moved-fromThe state a smart pointer is left in after std::move transfers its contents. The object is in a valid but unspecified state. You can assign to it or destroy it, but reading from it (other than via reset/reassign) is a logic bug.
reset()Member function that drops the smart pointer’s current ownership: decrements counts as appropriate, runs the destructor if the count hits zero, and leaves the smart pointer empty.
Reference cycleTwo or more shared_ptrs mutually owning each other (directly or via fields), so that no strong count ever reaches zero. The whole cycle leaks because no one is left to free it.
lock()Member function on weak_ptr that attempts to promote it back to a shared_ptr. Returns a non-empty shared_ptr if the object is still alive, or an empty one if the object has already been destroyed.
ExpiredThe state a weak_ptr is in once its observed object has been destroyed (strong count reached zero) but the control block is still alive. lock() on an expired weak_ptr returns an empty shared_ptr.

Below the guide is the interactive lab itself. The interface has three colour-coded memory regions and a row of slot controls:

  1. The stack region (emerald) represents four smart-pointer slots labelled a, b, c, and d. Each slot’s kind is chosen the moment you first assign to it: an 8-byte unique_ptr slot, or a 16-byte shared_ptr or weak_ptr slot. Until then the slot is shown as <unused>.
  2. The heap region (amber) is initially empty. Three kinds of block can appear here: ordinary object blocks (with vptr, inherited part, derived part — exactly as in the heap lab), separate control blocks (with strong, weak, and deleter slots), and fused blocks (a single allocation containing both halves, with a visible internal divider — i.e., what make_shared produces).
  3. The static region (slate) is initially empty. Vtables appear here for each class you allocate (the same way they did in the heap lab), and now also deleters , which has one default_delete<T> entry per class, in the program’s .text section. Every control block has an arrow pointing back to its deleter.
  4. The slot controls is one row per slot (a, b, c, d) with:
    • an assign dropdown offering make_unique<T>(...), make_shared<T>(...), shared_ptr<T>(new T(...)), = std::move(other_slot), = other_slot (copy), weak_ptr = other_slot (downgrade), and = other_slot.lock(). The available options are filtered by the slot’s current kind and the kinds of the other slots.
    • a .reset() button
    • ->display() and ->checkBalance() buttons that simulate calling the virtual method through the smart pointer

Below the controls there’s a transcript that records every statement you’ve executed in your program so far. Two further buttons sit between the controls and the transcript:

  • End program — return from main() pops the stack frame. Smart-pointer destructors run in reverse declaration order (d, c, b, a), each one decrementing counts and freeing what reaches zero.
  • Reset clears everything and returns to the starting state.

You’ll also see live hazard counters (use-after-move x N and expired-deref x N ) appear next to the lifecycle buttons as soon as either bug occurs.

The lab opens with all four slots <unused>, nothing on the heap, and no vtables in the static region. The walkthrough below builds up an interesting program from there.


Before you tackle the exercises, take a quick walk through the lab so you know what every part of the picture means.

Step 1: Allocate with make_shared and watch the fused block appear

Section titled “Step 1: Allocate with make_shared and watch the fused block appear”

Look at the lab below. Four slots a, b, c, d sit in the stack region, all reading <unused>. The heap and static regions are empty.

In the row for slot a, open the dropdown and choose a = make_shared<DepositAccount>(...). Several things happen at once:

  • A single heap block appears in the heap region. Its header reads fused: DepositAccount + ctrl_block, and its size is 56 bytes , made up of 32 for the object plus 24 for the control block. Inside, you’ll see two halves separated by a horizontal divider labelled “single allocation boundary”. The top half is the control block: a large strong counter showing 1, a weak counter showing 0, and a deleter slot. The bottom half is the DepositAccount object: vptr, accountNumber, balance, interestRate.
  • A new vtable appears in the static region, labelled DepositAccount::vtable. This is the same one you saw in the heap lab. Right below it, a new default_delete<DepositAccount> entry appears in .text. The control block has a dashed arrow pointing back to that deleter.
  • Two arrows leave slot a in the stack: one solid sky-blue arrow to the object half of the fused block, and one solid sky-blue arrow to the control-block half. That’s why a shared_ptr is 16 bytes as it stores both addresses.
  • The transcript adds: auto a = std::make_shared<DepositAccount>(12345, 100.00, 2.50);

Look closely at the slot for a. Its kind pill reads shared_ptr. Its body shows two pointers (obj_ptr and cb_ptr) because that’s the literal in-memory layout of a shared_ptr<T>: two 8-byte fields side by side.

Step 2: Copy a → b, watch the strong count climb

Section titled “Step 2: Copy a → b, watch the strong count climb”

In slot b’s dropdown, choose b = a. The transcript adds b = a; with the comment // ++strong → strong=2.

What changed on the heap? Look at the fused block: the strong counter is now 2. No second heap allocation appeared as b = a is a copy of the smart pointer, not the object. Both a and b now have arrows pointing at the same fused block, and they share the same control block. The reference count is the bookkeeping that lets the runtime know how many owners are currently alive.

Now click b.reset(). The strong count drops back to 1. Slot b becomes empty (its kind pill is still visible (it remembers it was a shared_ptr) but its body shows shared_ptr<Account> {} (empty)). Slot a is unaffected: it still owns the object, the count just dropped because one of two owners gave up its claim.

This is the central mechanic of shared_ptr. You can hand it to as many functions as you like; each copy bumps the count; each destruction decrements it; the object is destroyed exactly once, when the last owner goes away.

Step 3: Move a → c, observe a becoming “moved-from”

Section titled “Step 3: Move a → c, observe a becoming “moved-from””

In slot c’s dropdown, choose c = std::move(a). The transcript adds c = std::move(a); with the comment // shared ownership transferred · a is now moved-from (unspecified state).

Look at slot a. Its body now reads moved-from in red, struck through. Its kind pill is still shared_ptr. The type didn’t change but the wrapper is empty. Slot c now holds the object pointer and the control-block pointer that a had a moment ago.

Look at the strong count on the fused block. It’s still 1. Move did not bump the count. That’s the whole point of move: a copy would have gone to strong=2 then back to 1 when a was destroyed; a move skips the bookkeeping entirely by transferring ownership atomically. Move is faster than copy because it has less work to do.

Try clicking a->display() now. The transcript records the attempt in red and the use-after-move hazard counter ticks up. Calling on a moved-from smart pointer is undefined behaviour — the wrapper is empty, the underlying pointer is null, so you’d be dereferencing nullptr.

You’re ready for the exercises.


Modern C++ Smart Pointer Lab

unique_ptr, shared_ptr and weak_ptr — RAII ownership, control blocks, and the cycle problem.

Stack — main()up to 4 smart-pointer slots
0x7FFFFFB0
0x7FFFFFE0aunused
— never assigned —
8B reserved
0x7FFFFFD0bunused
— never assigned —
8B reserved
0x7FFFFFC0cunused
— never assigned —
8B reserved
0x7FFFFFB0dunused
— never assigned —
8B reserved
Heapobjects · control blocks · fused
0x005580A0
Heap is empty. Allocate via make_unique / make_shared.
.rodata / .textvtables · deleters
0x004012F0
No vtables yet — they appear when their class is first used.

Slot Actions — the program is live; each click is one statement

<unused> aempty
<unused> bempty
<unused> cempty
<unused> dempty
main.cpp — transcript
// no statements yet — pick an action above
Try this:Make a = std::make_shared<DepositAccount>(...) and watch the heap show ONE fused block (object + control block). Copy b = a — the strong count climbs to 2, no second allocation. Now move c = std::move(a): the count stays at 2 but a becomes moved-from (calling on it is UB). Downgrade d = weak_ptr(b) and reset b.reset(), c.reset() — strong hits zero, the object dies, but the cb survives because d still observes it. d.lock() now returns null.

For each task: read the question, make a prediction, then use the lab to check. Only expand the explanation once you’ve done both. Predicting before checking is what builds intuition; reading the answer first builds none.

Reset the lab. Set a = make_shared<Account>(...). Look closely at slot a in the stack region and at the heap.

  • How many bytes does the slot occupy on the stack?
  • How many distinct addresses leave slot a (count the arrows)?
  • Where do they point to?
Show explanation

Slot a is 16 bytes wide. Two arrows leave it: the object pointer (top, going to the object half of the fused heap block) and the control-block pointer (bottom, going to the control-block half of the same allocation).

A shared_ptr<T> is not just a counted raw pointer. It’s a struct of two fields — an object pointer and a control-block pointer:

template<class T> class shared_ptr {
T* ptr_; // 8 bytes — the object
control_block* ctrl_; // 8 bytes — the strong/weak/deleter trio
};

The control block is what carries the strong count, the weak count, and the deleter. It lives on the heap, not inside the shared_ptr itself. Multiple shared_ptrs sharing ownership of the same object all point at the same control block — that’s how they stay in sync about the count.

Why two pointers and not one? Because the object and the control block are sometimes at different addresses (when you wrote shared_ptr<T>(new T) directly — Task 4 covers this) and sometimes at the same address (when you used make_shared<T> — Task 2 covers this). Either way the wrapper carries both. The cost is fixed: 16 bytes per shared_ptr, regardless of the object’s size or the number of co-owners.

For comparison: a raw T* is 8 bytes, and a unique_ptr<T> is also 8 bytes — same as a raw pointer, no overhead. The 16-byte cost of shared_ptr is the price you pay for shared ownership and automatic reference counting.

Task 2: make_shared vs shared_ptr(new T): one allocation or two?

Section titled “Task 2: make_shared vs shared_ptr(new T): one allocation or two?”

Reset the lab. Set a = make_shared<DepositAccount>(...). Look at the heap. Now reset the lab again and instead set a = shared_ptr<Account>(new DepositAccount(...)). Look at the heap a second time.

  • How many heap blocks appear in each case?
  • What’s the total size in each case?
  • Which arrangement seems preferable, and why does the language provide both?
Show explanation

In the make_shared case there is one heap block — a fused 56-byte allocation containing both the control block (24 bytes) and the DepositAccount object (32 bytes), separated by an internal divider. The slot’s two pointers both point into this single block: the object pointer at the object half, the control-block pointer at the control-block half.

In the shared_ptr(new ...) case there are two heap blocks: a 24-byte control block and a separate 32-byte DepositAccount object, at different addresses. The slot’s object pointer points at one block, the control-block pointer at the other. Total size is the same — 56 bytes — but spread across two separate allocations.

make_shared is generally preferred for three reasons:

  1. One trip to the allocator instead of two. Each call to the allocator (operator new under the hood) takes time and can fail. Halving the number of trips speeds up the common path and removes a failure mode.
  2. Better cache locality. When the runtime needs to bump the strong count and you also dereference through the object pointer, both addresses are in the same cache line — one fetch from main memory instead of two.
  3. Stronger exception safety in pre-C++17 code. The two-allocation form has a subtle bug: if the second allocation throws after the first succeeds, the first leaks. C++17 fixed this through evaluation-order rules, but make_shared was always safe.

So why does the language provide both? Because sometimes you don’t have a choice. If you already have a raw pointer from a C library, or if you need a custom deleter that make_shared can’t accommodate, you fall back to shared_ptr(new T). Also: make_shared can’t free the object’s memory until the last weak_ptr is gone too — because the cb and the object live in the same allocation. If you have a tiny object and many long-lived weak observers, the two-allocation form lets the object’s memory be freed earlier (Task 9 covers this).

The transcript flags the difference explicitly: make_shared lines say “ONE allocation”, shared_ptr(new T) lines say “TWO allocations · prefer make_shared”.

Reset the lab. Set a = make_unique<CurrentAccount>(...).

  • How many bytes does slot a occupy on the stack?
  • How many heap blocks appear?
  • What does the layout of the heap block look like?
  • How does this compare to a raw Account* allocated with new?
Show explanation

Slot a is 8 bytes — exactly the same size as a raw Account*. There is one heap block, a CurrentAccount of 32 bytes, with the now-familiar layout: vptr at offset 0, then the inherited Account part, then overdraftLimit at offset 24. No control block. No fused block. Just an object on the heap with a single pointer to it from the stack.

The footprint is identical to what you’d get from raw Account* a = new CurrentAccount(...);. That’s the whole point of unique_ptr<T> — it’s a wrapper that costs nothing at runtime compared to a raw pointer, but the compiler enforces correct cleanup. The destructor of unique_ptr calls delete on the wrapped pointer, automatically, when the wrapper goes out of scope. You don’t pay for what you don’t use: there’s no reference count because there’s no shared ownership; there’s no control block because there’s nothing to put in one.

This is one of C++‘s rare zero-cost abstractions. make_unique<T>(...) is to Account* = new T(...) what a seatbelt is to driving without one — you get the safety, you pay zero performance penalty, and on top of that the compiler refuses to let you skip it (try Task 5 to see the compile-error in action).

The asymmetry with shared_ptr is worth internalising: shared ownership has a real cost (control block, atomic increments and decrements on the count, larger pointer), but unique ownership has no cost at all. If you don’t need shared ownership, don’t pay for it. That’s why the C++ Core Guidelines say: prefer unique_ptr first; reach for shared_ptr only when you actually need to share.

Reset the lab. Set a = make_unique<DepositAccount>(...). Now in slot b’s dropdown, find the option b = a under the copy group — it’s marked COMPILE ERROR in the dropdown text. Click it anyway.

  • What appears in the transcript?
  • Did anything change in the heap?
  • Did anything change in slot b?
Show explanation

The transcript shows the offending line in red and struck through, with a comment: // ⚠ error: call to deleted constructor of std::unique_ptr — use std::move. Nothing changes in the heap. Slot b remains <unused>.

The lab is faithfully simulating what would happen at compile time. unique_ptr<T>’s copy constructor is explicitly deleted (using = delete in the standard library’s source), so the line b = a; simply does not compile when a is a unique_ptr. Real C++ compilers reject the program at compile time with an error like:

error: call to deleted constructor of 'std::unique_ptr<DepositAccount>'

This is the language enforcing single ownership. By construction there can be at most one unique_ptr for any given heap allocation. That single rule is what makes the dangling-pointer and double-delete bugs from the previous lab literally impossible with unique_ptr: if there can only be one owner, only one call to delete can ever happen, and there can’t be a second alias to leave dangling.

If you legitimately need to transfer ownership — for example, returning a unique_ptr from a factory function, or moving it into a container — you use std::move instead, which Task 6 covers. If you need shared ownership instead of transfer, you use shared_ptr, not unique_ptr. The compile error here is doing you a favour: it’s forcing you to express your intent.

This is the C++ type system at its most useful — using deleted functions to make whole categories of bugs unrepresentable. The same trick appears all over modern C++: std::mutex is non-copyable for the same reason (you can’t have two mutexes for the same critical section), std::thread is non-copyable (you can’t have two thread handles for one OS thread), and so on.

Reset the lab. Set a = make_unique<DepositAccount>(...). Now in slot b’s dropdown, choose b = std::move(a) under the std::move group.

  • What is the new state of slot a?
  • What is the new state of slot b?
  • How many heap blocks are there?
  • Click a->display(). What happens, and what does the hazard counter show?
Show explanation

Slot a is now in the moved-from state — its body reads “moved-from” in red, struck through, and its state badge says moved-from. Slot b now owns the DepositAccount: kind pill unique_ptr, state live, with an arrow to the same heap block that a used to point at. The heap is unchanged — still one DepositAccount block. The object didn’t move; ownership moved.

std::move is, despite its name, not a function that moves anything. It’s a cast that converts an lvalue (a thing with a name) into an rvalue reference (a thing the compiler can treat as temporary). When the move-assignment operator of unique_ptr sees an rvalue, it knows the source is allowed to be “stolen from”, so it pilfers the source’s pointer field, leaves the source empty (with internal pointer set to nullptr), and walks away without freeing anything. Total work: copy one pointer, set another to null. Faster than copying — but more importantly, it preserves the single-owner invariant.

Calling a->display() now ticks the use-after-move hazard counter and the transcript shows the call in red. After std::move, the source unique_ptr is in a valid but unspecified state. The standard says you can:

  • destroy it (the destructor of an empty unique_ptr is a no-op — no crash, no double-delete)
  • assign a new value to it (e.g. a = make_unique<...>(...) — this is fine; Task 6 demonstrates)
  • call .reset() on it (also fine — a no-op for an empty unique_ptr)

You may not dereference it. a->display() is undefined behaviour because the underlying pointer is null. In practice this gives an immediate segfault when the runtime tries to read the vptr from address 0x0.

Two consequences worth pinning down:

  1. Move makes single ownership compose with containers and function returns. Without move, you couldn’t put a unique_ptr into a std::vector, you couldn’t return one from a function, and you couldn’t transfer ownership between objects. Move makes all of those work without ever copying — and therefore without ever violating the single-owner invariant.
  2. Use-after-move is a logic bug, not a memory bug. The pointer is null, not dangling. Dereferencing nullptr is well-defined as a segfault on most platforms, which is much friendlier than dereferencing a freed pointer (which can silently corrupt data). The smart pointer turned the unsafe bug into the safe-ish one.

Task 6: Reassigning a smart pointer frees the old one

Section titled “Task 6: Reassigning a smart pointer frees the old one”

Reset the lab. Set a = make_shared<DepositAccount>(...). Note the address of the fused block.

Now, without calling a.reset(), set a = make_shared<CurrentAccount>(...) (the same slot, a fresh allocation). Look at the heap.

  • How many heap blocks are there now?
  • What state are they in?
  • What was the strong count of the old block right before it was freed?
Show explanation

There are two heap blocks visible: a CurrentAccount fused block (the new one, strong=1, attached to slot a), and the original DepositAccount fused block — but the old one is greyed out and labelled freed. Slot a no longer points at it.

What happened mechanically: the assignment a = make_shared<CurrentAccount>(...) is implemented in three logical steps inside shared_ptr’s assignment operator:

  1. Allocate a new CurrentAccount and a new control block (in this case fused). New strong count: 1.
  2. Decrement the old control block’s strong count from 1 to 0. Because it hit zero, run the destructor on the old object and free the old fused block.
  3. Update a’s two internal pointers to the new object and new control block.

The crucial point: you didn’t write delete. You didn’t even write reset. The reassignment alone caused the old allocation to be freed, because reassignment necessarily means “I’m done with what I had”. The shared_ptr notices the strong count dropping to zero and triggers cleanup automatically.

This is how you’d write code in real C++. You almost never call .reset() explicitly on a smart pointer — you just assign a new value (or let the smart pointer go out of scope), and the old allocation cleans itself up. The behaviour is the same for unique_ptr: reassigning to a unique_ptr runs the destructor of whatever it currently owns, then takes ownership of the new value.

Compare this with the equivalent raw-pointer code from the previous lab:

// raw pointers
Account* a = new DepositAccount(...);
a = new CurrentAccount(...); // ← LEAK! the DepositAccount is now unreachable
// smart pointers
auto a = make_shared<DepositAccount>(...);
a = make_shared<CurrentAccount>(...); // ← old DepositAccount freed automatically

The raw-pointer version leaks every time you reassign without first deleting. The smart-pointer version cannot leak under reassignment, by construction.

Task 7: weak_ptr: observing without owning

Section titled “Task 7: weak_ptr: observing without owning”

Reset the lab. Set a = make_shared<Account>(...). Note the strong count: 1, weak: 0.

Now in slot b’s dropdown, choose weak_ptr b = a under the downgrade group.

  • What is the new strong count? Weak count?
  • How many heap blocks are there?
  • What kind of arrow leaves slot b in the diagram?

Now call a.reset(). Watch what happens to:

  • The fused block’s strong count
  • The fused block’s “object” half (does it get freed? Does the control-block half get freed?)
  • Slot b’s state badge

Finally, choose c = b.lock() under the lock() group in slot c’s dropdown.

  • What happens? Does c end up with a live object?
Show explanation

After the downgrade weak_ptr b = a: strong is still 1 (weak doesn’t bump strong), weak is now 1, slot b’s kind pill reads weak_ptr and a dashed violet arrow leaves it pointing only at the control-block half — not at the object. There’s no object arrow because weak_ptr doesn’t store an object pointer in the way that matters for ownership; it stores a control-block pointer and consults it before letting you near the object.

After a.reset(): strong drops to 0. The fused block’s object half is greyed out and labelled “destructor ran (cb survives)”. The control-block half is still alive, with strong=0, weak=1. Slot b’s state badge changes from live to expired — the dashed arrow is now drawn in red, with the object-pointer side struck through.

This is the key insight about weak_ptr. A weak pointer keeps the control block alive (because that’s where the strong count lives, and you need to be able to read the strong count to know whether the object has been destroyed). It does not keep the object alive. So when the last shared_ptr is gone, the object is destroyed and its memory reclaimed (or, in the fused case, marked destroyed-but-not-yet-freed since it shares an allocation with the cb), but the control block survives until the last weak_ptr is also gone.

The b.lock() call asks the control block: “is the object still alive?” The strong count is zero, so the answer is no, and lock() returns an empty shared_ptr. Slot c becomes a shared_ptr slot, but in the empty state — its body reads shared_ptr<Account> {} (empty) and no count is bumped. The transcript notes: lock() returned null shared_ptr — object already destroyed. If you’d called lock() before a.reset(), the strong count would have gone to 2 and c would have been a live shared owner of the object.

This mechanism — observe without keeping alive, then ask permission before accessing — solves a real problem: situations where you want to refer to an object that someone else owns, without imposing a lifetime requirement on it. Caches, observer lists, parent-of-child references in a tree where the children own each other but the parent doesn’t, all use weak_ptr. And, most importantly for the next task, it solves the reference cycle problem.

End the program now and watch the cleanup: the weak destructor fires (weak: 1 → 0), and because both counts are now zero the entire fused block is freed. No leaks. Smart pointers handled it.

Reset the lab. Set:

  • a = make_unique<Account>(...)
  • b = make_shared<DepositAccount>(...)
  • c = b (copy — strong becomes 2)
  • d = weak_ptr(b) (downgrade — weak becomes 1)

Without calling .reset() on any of them, click End program — return from main().

  • How many leaks does the lab report?
  • In what order do the destructors run?
  • What does the transcript say happens at each step?
Show explanation

The lab reports 0 leaks — a clean exit. The banner reads: “Clean exit — no leaks, no double-free, no dangling derefs”.

Stack frame teardown runs each slot’s destructor in reverse declaration orderd, c, b, a. The transcript shows each one:

~weak_ptr() : weak 1 → 0
~shared_ptr() : strong 2 → 1
~shared_ptr() : strong 1 → 0, dtor runs, obj destroyed (cb survives if weak>0)
~unique_ptr() : delete obj@0x...
} // end of main()

(After the last weak_ptr and the last shared_ptr are both gone, the fused block is freed too — see the end-banner.)

Walk through what happened:

  1. ~d (weak_ptr) runs: weak drops 1 → 0. Strong is still 2, so the cb is not freed yet — but weak is now zero.
  2. ~c (shared_ptr) runs: strong drops 2 → 1. Object still has another owner.
  3. ~b (shared_ptr) runs: strong drops 1 → 0. The object’s destructor runs. Both strong and weak are now zero, so the fused block is fully freed.
  4. ~a (unique_ptr) runs: delete is called on the Account heap block. That block is freed.

Compare this with the equivalent raw-pointer program from the heap lab: there you would have had to write a matching delete for every new before main returned, and forgetting any one of them would have leaked. Here you wrote no delete at all. The destructors did all the work — and they’re guaranteed to run, because they’re triggered by the language’s normal scope-exit machinery, which works whether your function returns normally, throws, or does anything else.

This is the win that justifies the whole apparatus. RAII isn’t just a technique; it’s the foundation of how memory and other resources are supposed to be managed in modern C++. “If you find yourself writing delete, ask whether you really had to.” The answer is almost always: no, a smart pointer would have done it for you.

This is the one bug that smart pointers, used carelessly, do not fix. To set it up in the lab, we need to fake mutual ownership manually.

Reset. Set a = make_shared<Account>(...). Set b = make_shared<Account>(...). Now copy each into the other slot’s “back-edge”: choose c = a (so c is now a second strong owner of a’s object) and d = b (so d is now a second strong owner of b’s object). This simulates a situation where the object pointed to by a has a shared_ptr member referring to b’s object, and vice versa.

Click End program.

  • How many leaks does the lab report?
  • What are the strong counts on the two control blocks at the moment of exit?
  • What’s the fix?
Show explanation

The lab reports 2 heap blocks never freed and the banner explains: “Even with smart pointers, a strong reference cycle leaks: each shared_ptr keeps the other’s control block from reaching strong=0. Break the cycle with weak_ptr on one of the back-edges.”

Walking through the destruction order: ~d, ~c, ~b, ~a. Each one decrements the strong count of its target’s control block. But because we set up four owners for two objects, after all four destructors run, each control block still has strong=1 — the “back-edge” we manually added is still there, pretending to be a member field that owns the other object.

In a real cycle, the situation looks like this:

struct Node {
std::shared_ptr<Node> peer; // owns the peer
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->peer = b; // a's peer owns b: b's strong count is 2
b->peer = a; // b's peer owns a: a's strong count is 2

When main() returns: a is destroyed → a’s strong drops 2 → 1, but b->peer still holds an owning reference to a’s object, so a’s object stays alive. Similarly b is destroyed → b’s strong drops 2 → 1, but a->peer still holds an owning reference to b’s object. Neither object’s destructor ever runs. Both leak. They will continue to leak until the program exits.

The fix is to make one of the two back-edges a weak_ptr instead of a shared_ptr:

struct Node {
std::shared_ptr<Node> peer; // owns
std::weak_ptr<Node> observer; // does not own
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->peer = b; // a strongly owns b
b->observer = a; // b weakly observes a

Now a’s strong count is just 1 (only the stack a owns it). When main() returns, a is destroyed → a’s strong drops to 0 → a’s object is destroyed, which destroys a->peer, which decrements b’s strong count to 1. Then b is destroyed → b’s strong drops to 0 → b’s object is destroyed, including b->observer, which decrements a’s weak count. Both objects are gone. No leak.

The general rule for smart-pointer object graphs: edges that mean “I own this” are shared_ptr. Edges that mean “I refer to this for non-ownership reasons” are weak_ptr. Trees use shared_ptr from parent to child and weak_ptr (or a raw pointer) from child to parent. Doubly-linked lists with shared ownership need a weak_ptr somewhere. Observer patterns use weak_ptr for the observer list.

This is the one bug that requires you to think about your design when using smart pointers. The compiler can’t catch it because the cycle is a runtime property of the object graph, not a compile-time property of the types. The good news: cycles are usually obvious in practice (tree-with-back-edges, doubly-linked list, observer list), and once you know to look for them, the fix is mechanical.

Without using the lab, work out on paper: suppose you have a function

void process(std::shared_ptr<DepositAccount> p) {
p->display();
}

and you call it 1000 times in a loop:

auto a = std::make_shared<DepositAccount>(...);
for (int i = 0; i < 1000; i++) {
process(a);
}
  • How many heap allocations happen across the whole loop?
  • How many times does the strong count change?
  • How would the answers differ if process took its argument by const std::shared_ptr<DepositAccount>&?
Show explanation

Heap allocations: exactly one. The make_shared call allocates a single fused block (object + control block, 56 bytes). Inside the loop, process(a) makes a copy of the shared_ptr — that copy lives on process’s stack and is destroyed when process returns. The copy doesn’t allocate anything; it just bumps the strong count of the existing control block.

Strong count changes: the count starts at 1. Each process(a) call: bumps it to 2 on entry (parameter copy), drops it back to 1 on exit (parameter destruction). So 2000 changes across 1000 iterations — 1000 increments and 1000 decrements.

Why does this matter? In multithreaded code, the strong count is std::atomic<int> — and atomic operations are expensive. Each increment/decrement requires a hardware-level memory barrier. On many architectures that’s roughly 20-40 nanoseconds per operation. Across 1000 iterations that’s tens of microseconds, all of it pure overhead.

With const shared_ptr&: the parameter is now a reference to a, not a copy. No shared_ptr copy is made, so the strong count is not bumped. Across 1000 iterations: still one heap allocation, zero strong count changes (the count stays at 1 the whole time).

This is the standard advice for shared_ptr parameters:

  • If the callee will store the pointer somewhere (e.g. into a member), pass by value — the callee will need its own copy regardless, so you might as well do the copy at the call site.
  • If the callee just needs to read the object during the call, pass by const T& (a reference to the object, not the smart pointer) — there’s no reason to even mention shared_ptr in the function’s signature.
  • If the callee needs to access the smart pointer itself but won’t store it, pass by const shared_ptr<T>& — but this is unusual.

For unique_ptr, the rules are stricter: you almost always either pass by value (transfers ownership) or by reference to the underlying object. Passing a unique_ptr by const& is technically possible but usually a code smell — it suggests the function has views about ownership that it has no business having.

The general lesson: smart pointers are not free, but their costs are predictable. Allocation happens at make_* calls and at copies only when those copies need their own control block, which they don’t; count manipulation happens whenever a shared_ptr is copied or destroyed; destruction happens automatically at scope exit. You can usually arrange your interfaces so the costs only happen where they’re necessary.

Bonus Task: When would you choose unique_ptr over shared_ptr?

Section titled “Bonus Task: When would you choose unique_ptr over shared_ptr?”

Suppose you’re designing a class hierarchy: a Document owns a list of Pages, and each Page owns a list of Paragraphs. Each child has exactly one parent.

  • Should the Document → Page and Page → Paragraph ownership use unique_ptr or shared_ptr?
  • What if the requirement changed: a Paragraph can be shared between multiple Pages (e.g. boilerplate text reused across chapters). What changes?
  • What if a Paragraph needs to refer back to its containing Page (to ask the page for its number)?
Show explanation

For the original tree-of-unique-children design: use unique_ptr for both Document → Page and Page → Paragraph. The hierarchy expresses single ownership: each Page belongs to exactly one Document, each Paragraph belongs to exactly one Page. unique_ptr matches this exactly:

class Document {
std::vector<std::unique_ptr<Page>> pages;
};
class Page {
std::vector<std::unique_ptr<Paragraph>> paragraphs;
};

This is cheaper (8 bytes per pointer vs 16, no atomic count operations, no control block allocations) and clearer (the type signature itself says “single owner”). When the Document is destroyed, all its Pages are destroyed, all their Paragraphs are destroyed — recursively, automatically, in the right order, no leaks. You wrote no delete.

If the requirement changes so that a Paragraph can be shared between multiple Pages, the Page → Paragraph ownership has to become shared_ptr<Paragraph>. The Document → Page relationship is still unique, so it stays as unique_ptr<Page>. You only “upgrade” the edges that actually need to be shared:

class Page {
std::vector<std::shared_ptr<Paragraph>> paragraphs;
};

Now multiple pages can hold the same Paragraph and the paragraph will live until the last page that references it goes away.

For the back-reference (Paragraph → Page), the choice is between weak_ptr<Page> and a raw Page*. Since the Page owns the Paragraph (via unique_ptr), the Page is guaranteed to outlive the Paragraph, so a raw back-pointer is perfectly safe — and idiomatic. You’d only use weak_ptr if the lifetime relationship were less clear.

The general design principles:

  1. Default to unique_ptr. Single ownership is simpler, cheaper, and more common than people think.
  2. Reach for shared_ptr only when you actually share. “Some part of the program might want to keep this alive longer” is not a good enough reason — that’s usually a sign the ownership design isn’t well thought through.
  3. Use weak_ptr (or a raw pointer) for non-owning references. Owners and observers should not be confused. When a Paragraph looks at its Page, it’s referring to it, not owning it; the type should reflect that.
  4. Trees go down with unique_ptr, up with raw or weak. This is a useful default for tree-shaped data structures.

The deeper lesson: the type of the pointer is documentation. When someone reads the class definition, the smart-pointer types tell them — at compile-time, with the compiler enforcing it — who owns what, who shares with what, and who merely observes. Raw pointers and references can’t communicate ownership; smart pointers do, by their type alone. That makes them not just a runtime tool but a design tool.


You’ve now seen, by direct manipulation of smart pointers, control blocks, and the reference-counting machinery, that:

  • unique_ptr<T> is a thin wrapper. 8 bytes on the stack, identical footprint to a raw T*, but its destructor calls delete automatically. Copy is a compile error; move transfers ownership and leaves the source moved-from. There can only ever be one owner.
  • shared_ptr<T> is two pointers. 16 bytes on the stack — one for the object, one for a separate control block on the heap that holds the strong count, weak count, and deleter. Copy bumps the strong count; destruction decrements it; the object is destroyed when the count hits zero.
  • make_shared<T> allocates the object and the control block in a single fused heap block. This is faster, more cache-friendly, and exception-safe. The two-allocation form shared_ptr<T>(new T) is occasionally necessary (custom deleter, raw pointer from a C library) but should not be the default.
  • weak_ptr<T> observes without owning. It keeps the control block alive but not the object. lock() asks the control block whether the object is still alive and returns either a fresh shared_ptr (if so) or an empty one (if not). This is the tool for caches, observer lists, and back-edges in cyclic data structures.
  • Move is not a function. It’s a cast (std::move) that lets the move-assignment operator steal from the source. The wrapper performs minimal bookkeeping (transfer pointer fields, null-out the source) and avoids the count-bump/count-drop dance that copy would require.
  • End of scope is automatic and ordered. Smart-pointer destructors run in reverse declaration order at the end of the enclosing function, decrementing counts, freeing what reaches zero. As long as your object graph is acyclic with respect to strong ownership, no leaks are possible.
  • Reference cycles are the one remaining bug. Two shared_ptrs mutually owning each other never reach strong=0 and leak forever. The fix is to make at least one edge a weak_ptr so the cycle can be broken. The compiler can’t catch this — it’s a runtime property of your object graph, not a type-system property.

The vtable mechanism you saw in the heap lab carries over unchanged: every heap object still has a vptr at offset 0, every class still has its vtable in .rodata, polymorphic dispatch still goes through two pointer hops. None of that interacts with smart pointers in any interesting way. What changed was who calls delete: in the heap lab, you did, and you sometimes forgot or did it twice. In this lab, the smart pointer’s destructor does it, and it’s correct by construction.

This is the modern way to write C++. Outside of niche performance work and interop with C libraries, you should not be writing new or delete directly. You should be writing make_unique and make_shared, and letting the type system carry the ownership semantics so the compiler can check them. The bugs from the previous lab are not just rare in modern C++ — they’re often unrepresentable.

Concept Match

Match the Smart Pointer Concepts

Drag each definition into its matching concept slot, then click Submit. Tap × to return a placed card to the pool.

RAII
drag a definition here…
unique_ptr
drag a definition here…
shared_ptr
drag a definition here…
weak_ptr
drag a definition here…
Control Block
drag a definition here…

Definition Pool

The idiom of tying resource management to object lifetime (constructor/destructor).
A multi-owner pointer that is 16 bytes on the stack and uses reference counting.
A single-owner pointer that is 8 bytes on the stack and non-copyable.
A heap allocation that stores the strong and weak reference counts.
A non-owning observer that must be 'locked' to access the object.
Quiz
Select 0/1

Why is a std::shared_ptr 16 bytes on the stack?

Quiz
Select 0/1

What is the result of attempting to copy a std::unique_ptr (e.g., b = a)?

Quiz
Select 0/1

Which advantage does std::make_shared provide over shared_ptr<T>(new T)?

Quiz
Select 0/1

What happens when a std::weak_ptr's observed object is destroyed?

Quiz
Select 0/1

How do you fix a 'reference cycle' where two objects hold shared_ptr to each other?