Skip to content

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

🥽Virtual Lab: Class Objects on the Heap

In the previous lab you saw that a class on the stack is just a contiguous run of bytes laid out in declaration order. That’s half the story of how C++ classes work in memory. The other half (and the one where most of the interesting things happen) is the heap.

When you write Account* a = new Account(...), two things happen at once: an 8-byte pointer is created on the stack, and a separate block of memory is allocated on the heap to hold the actual object. Adding a single virtual method to the class changes the layout again. Every object grows an extra hidden pointer at offset 0, the vtable pointer, which is the mechanism that makes polymorphism work.

In this virtual lab you’ll use the same three classes from before: Account, DepositAccount, and CurrentAccount, but now allocated on the heap, with virtual methods, and through pointers. You’ll see how polymorphic dispatch actually works in memory, and you’ll meet the three classic heap bugs the language is famous for: leaks, dangling pointers, and double-deletes.


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

TermMeaning
HeapThe region of memory used for dynamic allocation. Allocations are made explicitly with new and freed explicitly with delete. Unlike the stack, the lifetime of a heap allocation isn’t tied to any function’s scope.
PointerAn 8-byte (on a 64-bit machine) variable whose value is a memory address. Account* a means “a is a variable that holds the address of an Account”.
new T(...)Allocates space for a T on the heap, runs T’s constructor on it, and returns the address of that block.
delete pRuns the destructor of the object p points at, then returns its memory to the heap manager. The pointer p itself is not changed — it still holds the same address, but now that address is invalid.
nullptrA pointer value meaning “points at nothing”. Dereferencing it is undefined behaviour, but assigning to it is safe and delete nullptr is explicitly defined as a no-op.
Virtual methodA method declared with the virtual keyword that can be overridden by derived classes and is dispatched at runtime via the vtable.
OverrideA derived-class method that replaces a base-class virtual method of the same signature.
Vtable (virtual method table)A small, read-only table — one per concrete class — containing function pointers to that class’s virtual method implementations. Stored in the .rodata section of the program.
Vptr (vtable pointer)A hidden 8-byte pointer placed at offset 0 of every object whose class has any virtual method. Each object’s __vptr points at its concrete class’s vtable.
PolymorphismThe ability for a->display() to call different code depending on the actual type of the object a points at, even though a is declared as Account*.
Dangling pointerA pointer whose target has been freed. Reading or writing through it is undefined behaviour — sometimes silent corruption, sometimes a crash.
Double-deleteCalling delete twice on the same heap address. Almost always a crash or heap corruption.
Memory leakReaching the end of the program with heap allocations that were never deleted. The OS reclaims the memory on process exit, but for a long-running program this is a real problem.
.rodataThe read-only data section of a compiled program. Vtables live here, which is why they survive even after every heap object is freed.

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

  1. The stack region (rose) — three pointer slots labelled a, b, and c, each 8 bytes wide. These are typed Account*, so any of them can point at any object in the Account hierarchy. They start out as nullptr.
  2. The heap region (amber) — initially empty. Heap blocks appear here as you allocate. Each block shows its concrete class, its starting address, its size in bytes, and the values of its fields (including the hidden __vptr at offset 0).
  3. The static region (slate) — initially empty. As soon as you allocate an object of some class, that class’s vtable appears here. Each vtable lists three slots: ~Account, display, and availableFunds, with the function each slot resolves to for that concrete class.
  4. The pointer controls — one row per slot (a, b, c) with:
    • an assign dropdown offering = new Account(...), = new DepositAccount(...), = new CurrentAccount(...), = a / = b / = c (aliasing), and = nullptr
    • a delete button
    • ->display() and ->availableFunds() buttons that simulate calling the virtual method through the pointer

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

  • End program — return from main() pops the stack frame and surfaces any unfreed heap allocations as memory leaks.
  • Reset clears everything and returns to the starting state.

You’ll also see live hazard countersdouble-delete x N and dangling-deref x N — appear next to the lifecycle buttons as soon as either bug occurs.

The lab opens with all three pointer slots set to nullptr, 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.

Look at the lab below. Three pointer slots a, b, c sit in the stack region, all reading nullptr (address 0x00000000). The heap and static regions are empty.

In the row for slot a, open the dropdown and choose a = new DepositAccount(...). Three things happen at once:

  • A new 32-byte block appears in the heap region, labelled DepositAccount and showing a starting address like 0x005580A0. Inside it you’ll see four fields in order: __vptr, accountNumber, balance, and interestRate. The first 16 bytes (__vptr + int + padding + double) are the inherited Account portion, drawn in a slightly darker shade.
  • A new vtable appears in the static region, labelled DepositAccount::vtable. It has three slots: ~Account → DepositAccount::~DepositAccount, display → DepositAccount::display, and availableFunds → Account::availableFunds. Notice that availableFunds resolves up to the base class, because DepositAccount doesn’t override it.
  • The transcript at the bottom adds a line: DepositAccount* a = new DepositAccount(12345, 100.00, 2.50);

Look closely at the slot for a in the stack region. It no longer reads 0x00000000 — it now holds the heap address of the DepositAccount you just allocated. Two pointers in the picture matter: the stack slot pointing at the heap block, and the heap block’s __vptr pointing at the vtable in the static region.

Click the a->display() button in slot a’s row.

The transcript adds: a->display(); // → Deposit #12345, balance $100.00, rate 2.50%

What just happened mechanically? The compiler took the static type of a (it’s an Account*) and looked up display in Account’s class definition — it’s virtual, so the compiler emitted code that goes through the vptr. At runtime: read the vptr from the object at offset 0, follow it to the vtable, jump to the function in the display slot. Because a actually points at a DepositAccount, its vptr leads to DepositAccount::vtable, and the display slot there points at DepositAccount::display — which is what runs.

The pointer was typed Account*. The method that ran was DepositAccount::display. That’s polymorphism.

Click on the heap block (anywhere on the DepositAccount block in the heap region). An editor opens. Change balance to 2500 and interestRate to 5.0. Click Save.

The block updates immediately. Click a->display() again — the output is now Deposit #12345, balance $2500.00, rate 5.00%. Same address, same vptr, same vtable — different field values.

Notice you didn’t move the object. Its address is still whatever it was. You changed what’s stored in some of its bytes, not where it lives.

You’re ready for the exercises.


C++ Heap Allocation Lab

new on the heap, virtual dispatch through the vtable, and the three classic heap bugs.

Stack — main()3 × Account* (8B each)
0x7FFFFFD0
0x7FFFFFE0a=nullptr
0x7FFFFFD8b=nullptr
0x7FFFFFD0c=nullptr
Heapgrows on each new
0x005580A0
Heap is empty. Use the controls below to call new.
.rodata — vtablesstatic · read-only
0x004012F0
No vtables yet — they appear when their class is first used.

One vtable per class with virtual methods. Each row is a function pointer the compiler emits at compile time. At a virtual call, the object's __vptr is followed here to pick the right implementation — that's how a->display() dispatches to the derived class even through a base-class pointer.

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

Account* anullptr
Account* bnullptr
Account* cnullptr
main.cpp — transcript
// no statements yet — pick an action above
Try this:Set a = new DepositAccount(...) and watch a->display() dispatch through the vptr to DepositAccount::display — the static type of a is Account* but the right method runs. Then alias b = a so two pointers share one block, delete a — both a and b turn red (dangling). Now click delete b for a textbook double-delete.

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.

Task 1 — Where does the pointer live, and where does the object live?

Section titled “Task 1 — Where does the pointer live, and where does the object live?”

Reset the lab. Set a = new Account(...). Look at the stack region and the heap region.

  • What address does the pointer a itself sit at?
  • What address does the object a points at sit at?
  • Are these addresses near each other, or far apart? Roughly how many bits apart?
Show explanation

The pointer a sits at a stack address like 0x7FFFFFE0 — somewhere very high in the address space, because that’s where the operating system places a thread’s stack. Stack addresses on Linux typically start near 0x7FFFFFFFFFFF (high 47 bits) and grow downward as the function pushes locals.

The heap allocation lives at an address like 0x005580A0 — many orders of magnitude lower, because the heap is placed just above the program’s data section, near the bottom of the virtual address space.

The two regions are deliberately far apart so they can each grow without colliding: the stack grows downward from the top, the heap grows upward from the bottom. In a 64-bit address space there’s roughly 17 trillion gigabytes of unused gap between them.

This separation is exactly why “the pointer is on the stack and the object is on the heap” matters as a mental model. They’re not adjacent. They’re not even close. A pointer is a self-contained 8-byte address that names the heap block — it doesn’t physically contain it. The object’s bytes live somewhere else entirely.

In the previous lab, sizeof(Account) was 16 bytes. Allocate a fresh Account in this lab and look at its heap block. Hover or click it to see the field breakdown.

  • What is sizeof(Account) now?
  • What new field has appeared, and at what offset?
  • How does this change sizeof(DepositAccount) and sizeof(CurrentAccount)?
Show explanation

sizeof(Account) is now 24 bytes, up from 16. The reason is the new __vptr field at offset 0:

+0 8 bytes `__vptr` (hidden, points at the vtable)
+8 4 bytes accountNumber
+12 4 bytes padding
+16 8 bytes balance
— total: 24 bytes

The vptr is invisible to your source code — you can’t refer to it, read it, or write it through any field name — but the compiler put it there as soon as Account gained any virtual method. Every object whose class is part of a hierarchy with virtual methods pays this 8-byte cost, regardless of which methods you actually override.

Notice that accountNumber is now at offset 8, not offset 0 — the vptr displaced it.

Both DepositAccount and CurrentAccount are now 32 bytes each:

+0 8 bytes `__vptr`
+8 4 bytes accountNumber \
+12 4 bytes padding | inherited Account part
+16 8 bytes balance /
+24 8 bytes interestRate / overdraftLimit
— total: 32 bytes

Adding virtual to a class is not free: every object of that class (and every derived class) grows by 8 bytes for the vptr. In a program with millions of small objects this can be a real cost, which is why C++ doesn’t make methods virtual by default.

Reset the lab. Set up:

  • a = new Account(...)
  • b = new DepositAccount(...)
  • c = new CurrentAccount(...)

All three pointers are statically typed Account*. Now click ->display() on each in turn, then ->availableFunds() on each in turn.

  • Predict what the three display calls will print before clicking. Then verify.
  • Predict what the three availableFunds calls will print before clicking. Then verify.
Show explanation

The three display calls dispatch differently because each object has its own vptr pointing at its own concrete class’s vtable:

a->display(); // → Account #12345, balance $100.00
b->display(); // → Deposit #12345, balance $100.00, rate 2.50%
c->display(); // → Current #12345, balance $100.00, overdraft $500.00

The three availableFunds calls behave differently still. Look at the static region: all three vtables have a display slot, but the availableFunds slot resolves differently per class:

  • Account::vtable has availableFundsAccount::availableFunds
  • DepositAccount::vtable has availableFundsAccount::availableFunds (inherited — not overridden)
  • CurrentAccount::vtable has availableFundsCurrentAccount::availableFunds (overridden, because overdraft adds available funds)

So:

a->availableFunds(); // → $100.00 (just the balance)
b->availableFunds(); // → $100.00 (also just the balance)
c->availableFunds(); // → $600.00 (balance + overdraft)

The crucial insight: b and c use the same machinery to dispatch — both have a vptr, both look up the same vtable slot. But b’s vptr leads to a vtable where the slot wasn’t overridden, so the lookup ends up at Account::availableFunds. c’s vptr leads to a vtable where the slot was overridden, so the lookup ends up at CurrentAccount::availableFunds. The dispatch mechanism is identical; the result differs because the vtables differ.

Task 4 — Aliasing: two pointers, one object

Section titled “Task 4 — Aliasing: two pointers, one object”

Reset the lab. Set a = new DepositAccount(...). Now in slot b’s dropdown, choose b = a.

  • What address is in a?
  • What address is in b?
  • How many heap blocks are there?
  • How many vtables are there in the static region?

Now click on the heap block, change the balance, and save. What happens to a->display() and b->display()?

Show explanation

a and b now hold the same address — let’s say 0x005580A0. There is one heap block, and one vtable for DepositAccount in the static region. Both pointers reference the same single object.

This is aliasing: two names for the same thing. It’s neither illegal nor unusual in C++. Function parameters routinely alias — when you pass a pointer to a function, the function’s parameter and the caller’s variable are aliases for the duration of the call. Aliasing is normal.

When you change the object’s balance, both a->display() and b->display() reflect the change — because they both go through their respective pointers to the same heap block, read the same bytes, and dispatch through the same vptr to the same vtable slot.

The reason aliasing matters here, and the reason this lab features it explicitly, is that aliasing makes the next two tasks — dangling pointers and double-delete — possible. If you can have two pointers to the same object, you can delete it through one of them and forget the other one is still pointing at it.

Continue from Task 4: you should have a and b both pointing at the same DepositAccount. Now click delete a.

  • What does the heap block look like now?
  • What address is in a after the delete?
  • What address is in b?
  • Click b->display(). What happens? Watch the hazard counter.
Show explanation

After delete a, the heap block disappears (or is shown as a labelled gap, depending on the lab’s render mode), and a is marked dangling — drawn in red with the same address it had before the delete. delete does not zero the pointer. It calls the destructor on the object, hands the memory back to the heap manager, and then walks away. The bits in a are unchanged.

Crucially, b is also shown as dangling. The lab marks every pointer that pointed at the freed block, not just the one used for the delete. This is honest about real C++: any alias of a freed pointer is a dangling pointer too. The compiler has no way to find and clear them all.

Calling b->display() on a dangling pointer is undefined behaviour. The lab simulates one possible outcome — typically a visible warning in the transcript and the dangling-deref hazard counter ticking up by one. In reality, the result depends on what the heap manager has done with that memory in the meantime: it might still contain the old object’s bytes (and the call appears to work, hiding the bug for now), it might contain a different object the heap manager handed out next, or it might be unmapped entirely (segmentation fault).

The professional habit is: after delete p, immediately p = nullptr. That doesn’t fix the aliases, but it at least makes calls through p itself fail loudly with a null dereference rather than silently corrupt memory.

Still in the same setup as Tasks 4–5: a and b were both pointing at the same block, and a has been deleted. Now click delete b.

What happens? Watch the double-delete hazard counter.

Show explanation

The lab marks the second delete as a double-delete and ticks the hazard counter. The transcript shows the offending line in red.

In real C++, double-deleting is one of the worst memory bugs because it almost always corrupts the heap manager’s internal data structures. The first delete returned the block to the free list. The second delete tries to return the same block again — but the heap manager’s bookkeeping for that block is now in a state that doesn’t expect another free. Common outcomes include immediate crash with a glibc “double free or corruption” diagnostic, silent corruption that crashes a millisecond later in some unrelated allocation, or — in adversarial settings — exploitable security vulnerabilities.

The mechanism that made this bug possible was Task 4’s aliasing combined with Task 5’s failure to clear a after the delete. Each step is innocuous on its own; combining them is the bug.

This is exactly the failure mode that smart pointers (std::unique_ptr, std::shared_ptr) eliminate — the topic of the next lab. But understanding the bare-pointer mechanics first is what makes the smart-pointer abstractions feel like a relief rather than a magic incantation.

Reset the lab. Set:

  • a = new Account(...)
  • b = new DepositAccount(...)
  • c = new CurrentAccount(...)

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

  • What does the lab show?
  • How many leaks does it report?
  • The vtables in the static region: are they reported as leaked too?
Show explanation

The lab reports 3 heap allocations never freed — one leak per unfreed heap block. The pointers a, b, c lived on the stack and were destroyed automatically when main() returned, but their heap allocations were not freed and are now unreachable.

The vtables are not reported as leaks. Why? They never lived on the heap in the first place. Vtables are emitted by the compiler into the program’s read-only data section (.rodata) and loaded once, when the program starts. They have static storage duration — the same as a global variable — and exist for the entire lifetime of the process. They cost zero per-object memory at runtime; only the per-object vptr is paid per allocation.

In a short program like this one, leaks aren’t a practical problem: when the OS reclaims the process’s memory on exit, the leaks are reclaimed too. The reason leaks matter is for long-running programs — servers, daemons, GUI applications. Each leaked allocation is bytes the program can never re-use, so over time the process grows and grows until the OS kills it for exceeding its memory quota.

The fix in textbook C++ is to match every new with a delete before all references to the allocation are lost. The fix in modern C++ is std::unique_ptr and std::shared_ptr, which call delete for you when the smart pointer goes out of scope. That’s what the next lab covers.

Without using the lab, work out on paper: suppose you have a CurrentAccount allocated at heap address 0x00558100. The CurrentAccount::vtable is at static address 0x004012F0.

  • What value would you find at 0x00558100 (i.e. the first 8 bytes of the heap block)?
  • What value would you find at 0x00558108?
  • What value would you find at 0x00558118?

Sketch the full layout. Then verify by allocating a CurrentAccount and inspecting it.

Show explanation

Working from CurrentAccount’s heap layout:

+0 8 bytes `__vptr` — value: 0x004012F0 (the vtable address)
+8 4 bytes accountNumber — e.g. 12345
+12 4 bytes padding — undefined
+16 8 bytes balance — e.g. 100.00 (as IEEE 754)
+24 8 bytes overdraftLimit — e.g. 500.00 (as IEEE 754)
— total: 32 bytes

So the values you’d find:

  • At 0x00558100 (offset 0): the bytes of the address 0x004012F0 — the vptr. Eight bytes, in whatever endian-ness the platform uses.
  • At 0x00558108 (offset 8): the bytes of the int 12345 — followed by 4 bytes of padding at offset 12.
  • At 0x00558118 (offset 24): the bytes of the double overdraftLimit.

Two things to notice:

  1. The vptr’s address is in the heap block, but the bytes it points at are in .rodata. Walking the pointer takes you out of the heap region entirely.
  2. Every CurrentAccount you ever allocate, anywhere in the program, has the same 0x004012F0 in its vptr — they all share the same one CurrentAccount::vtable. The vtable is per-class, not per-object.

This is what makes virtual dispatch cheap: each call is just two indirections (read vptr, then read function pointer at the right slot), and the table being shared means the per-object cost is exactly 8 bytes regardless of how many virtual methods the class has.

This is a preview of the smart pointer lab that follows. Suppose instead of raw Account*, you wrote:

std::unique_ptr<Account> a = std::make_unique<DepositAccount>(...);

Without changing the heap object’s layout at all, what changes about the program?

  • Can you still write a->display()?
  • Can you still alias: auto b = a;?
  • What happens if you don’t call delete a?
  • What happens to the vtable mechanism?
Show explanation

std::unique_ptr<T> is a wrapper around a T* that takes responsibility for calling delete when it goes out of scope. The heap object it manages is layout-identical to one allocated with raw new — same vptr, same fields, same size. The vtable mechanism is unchanged. Polymorphism still works: a->display() still dispatches through the vptr to DepositAccount::display.

Three things change in the program’s behaviour:

  1. Leaks become impossible for objects owned by the unique_ptr. When a goes out of scope at the end of main(), its destructor automatically calls delete on the wrapped pointer. You can’t forget — the compiler enforces it.
  2. Aliasing is forbidden. auto b = a; is a compile error, because unique_ptr is non-copyable. You’d have to explicitly transfer ownership with std::move, after which a is empty and only b owns the object. This rules out the dangling / double-delete bugs by construction: there can only ever be one owner, so delete happens exactly once.
  3. Forgetting to delete is no longer possible. You don’t write delete at all. The smart pointer does it for you when it goes out of scope.

The trade-off: shared ownership (the legitimate case where multiple parts of a program need to keep an object alive) needs a different type — std::shared_ptr<T> — which uses reference counting to delay the delete until the last shared pointer goes away. That’s the next lab.

This pattern — letting destructors do the cleanup that you’d otherwise have to remember — is called RAII (Resource Acquisition Is Initialization) and is one of C++‘s most important ideas. The same trick handles file handles, network sockets, mutexes, and pretty much every other resource. The heap-allocation case you’ve just seen is the canonical example.


You’ve now seen, by direct manipulation of pointers, objects, and vtables, that:

  • A heap-allocated object has two addresses in play: the address the pointer holds (which lives on the stack) and the address of the object itself (which lives on the heap). They are separated by trillions of bytes of address space.
  • Adding any virtual method to a class causes every object of that class to grow an 8-byte vptr at offset 0. The vptr points at a per-class vtable in the program’s read-only data section.
  • Polymorphism is implemented by two pointer hops: through the pointer to the object, then through the object’s vptr to the right vtable slot. The mechanism is identical regardless of which derived class is involved; only the vtables differ.
  • delete frees the heap block but does not change the pointer. Any pointer pointing at the freed block — whether it was used for the delete or not — becomes a dangling pointer. Dereferencing a dangling pointer is undefined behaviour.
  • Calling delete twice on the same address is a double-delete and almost always corrupts the heap. Aliasing combined with raw-pointer ownership is what makes this bug possible.
  • Reaching the end of the program with unfreed allocations is a memory leak. The vtables themselves never leak — they live in .rodata, not on the heap — but the per-object data does.

In the next lab you’ll meet std::unique_ptr and std::shared_ptr — the smart pointers that turn this entire category of bugs into compile errors and runtime impossibilities. The vtable mechanism you’ve just seen carries over unchanged; what changes is who calls delete.

Concept Match

Match the Heap and Polymorphism Concepts

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

Vptr
drag a definition here…
Vtable
drag a definition here…
Dangling Pointer
drag a definition here…
Memory Leak
drag a definition here…

Definition Pool

A per-class read-only table in .rodata containing pointers to virtual method implementations.
A hidden 8-byte pointer at offset 0 of every object whose class has virtual methods.
A pointer that still holds the address of a heap block after it has been freed.
A heap allocation that is never freed, remaining unreachable when pointers go out of scope.
Knowledge Check

If a class has 5 virtual methods, how many vptrs (vtable pointers) are added to each object of that class?

Knowledge Check

What happens to a pointer variable after you call 'delete' on it?

Knowledge Check

Where are vtables typically stored in a compiled program?

Knowledge Check

What is the result of 'aliasing' two pointers (e.g., b = a) and then calling 'delete a'?

Knowledge Check

Why does polymorphism work even if a pointer is typed as Account* but points to a DepositAccount?