š»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.
Glossary
Section titled āGlossaryāIf any of the words used in this lab feel hazy, hereās a short reference. Skim it now, come back as needed.
| Term | Meaning |
|---|---|
| Heap | The 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. |
| Pointer | An 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 p | Runs 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. |
nullptr | A 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 method | A method declared with the virtual keyword that can be overridden by derived classes and is dispatched at runtime via the vtable. |
| Override | A 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. |
| Polymorphism | The 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 pointer | A pointer whose target has been freed. Reading or writing through it is undefined behaviour ā sometimes silent corruption, sometimes a crash. |
| Double-delete | Calling delete twice on the same heap address. Almost always a crash or heap corruption. |
| Memory leak | Reaching 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. |
.rodata | The read-only data section of a compiled program. Vtables live here, which is why they survive even after every heap object is freed. |
How to use the lab
Section titled āHow to use the labāBelow the guide is the interactive lab itself. The interface has three colour-coded memory regions and a row of pointer controls:
- The stack region (rose) ā three pointer slots labelled
a,b, andc, each 8 bytes wide. These are typedAccount*, so any of them can point at any object in theAccounthierarchy. They start out asnullptr. - 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
__vptrat offset 0). - 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, andavailableFunds, with the function each slot resolves to for that concrete class. - 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
deletebutton ->display()and->availableFunds()buttons that simulate calling the virtual method through the pointer
- an assign dropdown offering
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 counters ā double-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.
Guided tour (3 steps)
Section titled āGuided tour (3 steps)ā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 your first heap object
Section titled āStep 1: Allocate your first heap objectā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
DepositAccountand showing a starting address like0x005580A0. Inside it youāll see four fields in order:__vptr,accountNumber,balance, andinterestRate. The first 16 bytes (__vptr+ int + padding + double) are the inheritedAccountportion, 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, andavailableFunds ā Account::availableFunds. Notice thatavailableFundsresolves up to the base class, becauseDepositAccountdoesnā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.
Step 2: Call a virtual method
Section titled āStep 2: Call a virtual methodā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.
Step 3: Edit the objectās fields
Section titled āStep 3: Edit the objectās fieldsā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.
Exercises
Section titled āExercisesā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
aitself sit at? - What address does the object
apoints 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.
Task 2 ā How big is an Account, now?
Section titled āTask 2 ā How big is an Account, now?ā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)andsizeof(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 bytesThe 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 bytesAdding 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.
Task 3 ā Polymorphism in three pointers
Section titled āTask 3 ā Polymorphism in three pointersā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
displaycalls will print before clicking. Then verify. - Predict what the three
availableFundscalls 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.00b->display(); // ā Deposit #12345, balance $100.00, rate 2.50%c->display(); // ā Current #12345, balance $100.00, overdraft $500.00The 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::vtablehasavailableFundsāAccount::availableFundsDepositAccount::vtablehasavailableFundsāAccount::availableFunds(inherited ā not overridden)CurrentAccount::vtablehasavailableFundsāCurrentAccount::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.
Task 5 ā Creating a dangling pointer
Section titled āTask 5 ā Creating a dangling pointerā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
aafter 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.
Task 6 ā Double-delete
Section titled āTask 6 ā Double-deleteā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.
Task 7 ā Memory leak
Section titled āTask 7 ā Memory leakā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.
Task 8 ā Predict the vptr layout
Section titled āTask 8 ā Predict the vptr layoutā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 bytesSo the values youād find:
- At
0x00558100(offset 0): the bytes of the address0x004012F0ā the vptr. Eight bytes, in whatever endian-ness the platform uses. - At
0x00558108(offset 8): the bytes of the int12345ā followed by 4 bytes of padding at offset 12. - At
0x00558118(offset 24): the bytes of the doubleoverdraftLimit.
Two things to notice:
- 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. - Every
CurrentAccountyou ever allocate, anywhere in the program, has the same0x004012F0in its vptr ā they all share the same oneCurrentAccount::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.
Bonus Task: What changes with unique_ptr?
Section titled āBonus Task: What changes with unique_ptr?ā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:
- Leaks become impossible for objects owned by the
unique_ptr. Whenagoes out of scope at the end ofmain(), its destructor automatically callsdeleteon the wrapped pointer. You canāt forget ā the compiler enforces it. - Aliasing is forbidden.
auto b = a;is a compile error, becauseunique_ptris non-copyable. Youād have to explicitly transfer ownership withstd::move, after whichais empty and onlybowns the object. This rules out the dangling / double-delete bugs by construction: there can only ever be one owner, sodeletehappens exactly once. - Forgetting to delete is no longer possible. You donāt write
deleteat 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.
Wrap-up
Section titled āWrap-upā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
virtualmethod 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.
deletefrees the heap block but does not change the pointer. Any pointer pointing at the freed block ā whether it was used for thedeleteor not ā becomes a dangling pointer. Dereferencing a dangling pointer is undefined behaviour.- Calling
deletetwice 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.
Knowledge Check
Section titled āKnowledge CheckāMatch the Heap and Polymorphism Concepts
If a class has 5 virtual methods, how many vptrs (vtable pointers) are added to each object of that class?
What happens to a pointer variable after you call 'delete' on it?
Where are vtables typically stored in a compiled program?
What is the result of 'aliasing' two pointers (e.g., b = a) and then calling 'delete a'?
Why does polymorphism work even if a pointer is typed as Account* but points to a DepositAccount?
© 2026 Derek Molloy, Dublin City University. All rights reserved.