🥽Virtual Lab: Objects on the Stack
This virtual lab explores how class instances (objects) are managed in memory, comparing stack allocation with dynamic heap allocation.
As described in this chapter, every object you declare is, in the end, just a contiguous run of bytes on the stack (or somewhere else in memory) with each field stored in declaration order. Understanding that simple fact unlocks the rest of the language: why classes have a size, why inheritance “is just” laying one struct after another, why some fields appear with strange gaps next to them.
In this virtual lab you’ll explore three classes: Account, DepositAccount, and CurrentAccount, by dragging instances of them onto a simulated stack and observing what happens to the bytes. By the end you’ll be able to predict the size, layout, and starting
address of any object made from these classes.
The three classes used throughout the lab are kept simple. Your focus should be on the memory layout, not the behaviour:
class Account {public: int accountNumber; // an identifier for the account double balance; // current balance};
class DepositAccount : public Account {public: double interestRate; // annual interest rate (%)};
class CurrentAccount : public Account {public: double overdraftLimit; // permitted overdraft};Exercise: Map the Account Hierarchy
Section titled “Exercise: Map the Account Hierarchy”Before we look at how these classes are laid out in memory, use the tool below to build the inheritance hierarchy for these three classes.
Build the Account Hierarchy
There are no virtual methods in these classes. That comes in the heap lab that follows. Without virtual, the layout is the simplest possible: just the fields, in order, plus alignment padding.
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 |
|---|---|
| Object | A specific instance of a class. A region of memory containing all the fields the class declares. |
| Field | (Member Variable) A named variable that lives inside an object; for example, balance inside an Account. |
| Stack | The region of memory where local variables in a function live. Allocation is automatic when the function starts; deallocation is automatic when it returns. |
| Address | The location in memory where something lives written as a hexadecimal number like 0x7FFFFFC0. Every object has a starting address. |
| Byte | The smallest addressable unit of memory, i.e., 8 bits. An int typically takes 4 bytes; a double typically takes 8. |
| Byte offset | How far from the start of an object a particular field sits. For example, in Account the accountNumber is at offset +0 and balance is at offset +8. |
| Alignment | A hardware rule that says certain types must begin at addresses that are multiples of their size. A double is 8-byte aligned: it must start at an address divisible by 8. |
| Padding | Unused bytes the compiler inserts inside an object so that the next field starts at a properly aligned address. |
| Inheritance | The mechanism by which one class (the derived class) extends another (the base class). The derived class includes everything from the base, plus any new fields it adds. |
| Base class | (Parent Class) The class being extended, i.e., Account in this lab. |
| Derived class | (Child) The class doing the extending, i.e., DepositAccount and CurrentAccount here. |
sizeof(T) | A C++ operator that returns the size of any type in bytes, including alignment padding. |
How to use the lab
Section titled “How to use the lab”Below the guide is the interactive lab itself. The interface has three parts:
- The memory strip is a 64-byte slice of the stack, with each little square representing one byte. The leftmost address is shown above (
0x7FFFFFC0); each byte to the right is one address higher. - The class palette has three draggable chips at the bottom. Drag any of them onto the strip to instantiate that class at that location. The chip’s badge tells you how many bytes the object will occupy (e.g.
Account 16B). - The live code panel is a
main.cppview showing every object currently on the stack as a brace-initialised declaration, plus acout << obj.field;line for every member of every object.
You can:
- Drag a class chip onto the strip to create a new object.
- Click an existing object to edit its name and field values.
- Click the × badge on any object to remove it.
- Reset example restores the original two-object starting state.
- Clear stack removes everything.
The lab opens with two objects already on the stack: one
Accountnamedaat the start of the region, and oneDepositAccountnamedbimmediately after it. The walkthrough below builds on that starting state.
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: Read the starting state
Section titled “Step 1: Read the starting state”Look at the lab below. There are two objects: a is an Account sitting at offset 0, and b is a DepositAccount sitting at offset 16. Find these things:
- The starting address of object
a(top-left of the memory strip). - The class name and variable name labels above each block.
- The internal field labels (
accountNumber,balance,interestRate) inside each block. - The diagonal striped region between
accountNumberandbalanceinsidea. That’s padding, not a field you can use. - Inside
b, the slightly darker region on the left labelled “inherited from Account”. That’s the entireAccountsub-object, embedded at the start of theDepositAccount.
Now look at the code panel at the bottom. You’ll see Account a{12345, 100.00}; and DepositAccount b{12345, 100.00, 2.50}; followed by cout lines for every field of both objects. The constructor calls list inherited fields first, then the derived class’s own fields.
Step 2: Edit a field
Section titled “Step 2: Edit a field”If you click anywhere on object a an editor opens. Change the accountNumber to 99999 and the balance to 2500. Click Save.
Notice the code panel updates immediately: Account a{99999, 2500.00}; and the cout << a.balance; line now ends with // output: 2500.00. You haven’t moved any bytes but you’ve just changed what’s stored in them.
Step 3: Create a third object
Section titled “Step 3: Create a third object”Drag the CurrentAccount chip from the palette onto any free area of the memory strip (try around byte 32 or later). The lab will snap it into the next free 24-byte slot.
You should now see three objects in the code panel and three in the strip: a (16 bytes), b (24 bytes), and your new CurrentAccount (24 bytes). Note the byte counter at the top right of the strip. It should now read 64 / 64 bytes used, i.e. the stack region in this simulation is full.
You’re ready for the exercises.
C++ Class Object Lab
Drag a class onto the stack and watch the object's bytes lay out — fields, padding, inherited base and all.
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: How big is an Account, really?
Section titled “Task 1: How big is an Account, really?”Hover over the Account chip in the palette. The badge says 16B. But the class only declares an int (4 bytes) and a double (8 bytes), totalling 12 bytes. Where do the other 4 bytes come from?
In the lab, click on object a and look closely at the bytes between accountNumber (the leftmost field) and balance (the rightmost one). What do you see?
Show explanation
The four bytes between accountNumber and balance are alignment padding, drawn as the diagonal striped region.
A double on a 64-bit platform must begin at an address that’s a multiple of 8. If accountNumber (4 bytes) sits at offset 0, then the next available offset is 4, but that’s not a multiple of 8, so the compiler inserts 4 bytes of unused space, pushing balance out to offset 8 where it can sit aligned.
The total size of the object is therefore:
4 bytes (accountNumber)+ 4 bytes (padding)+ 8 bytes (balance)=16 bytesIn your code, sizeof(Account) would return 16, not 12. Padding is invisible to your program (you can’t read or write it through any field name) but it’s very much real, and it’s why class sizes sometimes seem larger than the sum of their fields.
If you swapped the field order so that balance came first and accountNumber came second, the layout would change: balance at offset 0 (already aligned), accountNumber at offset 8, and 4 bytes of trailing padding to round the whole object up to a multiple of 8.
The object would still be 16 bytes. Try this on yourself.
Task 2: Inheritance is layout
Section titled “Task 2: Inheritance is layout”A DepositAccount “is an” Account plus a bit extra. Look at object b in the lab — it’s 24 bytes total. Without doing the maths in code, predict:
- At what byte offset does
interestRatesit inside theDepositAccountobject? - What’s stored in the bytes before it?
- Now hover over the inherited region of
band check the editor to verify.
Show explanation
The interestRate sits at byte offset +16 within the DepositAccount object. Everything before it (bytes 0 through 15) is an exact copy of an Account layout: accountNumber at offset 0, padding from 4 to 7, balance at offset 8.
This is what inheritance literally is in C++: the derived class begins with all the bytes of the base class, in the same layout, followed by any new fields it adds. There is no separate “Account part” stored elsewhere. It’s right there at the front of the DepositAccount object.
This has a useful consequence: a pointer to a DepositAccount can also be used as a pointer to an Account, because the Account fields are at exactly the offsets the compiler expects. That’s the foundation of polymorphism, which you’ll meet in the heap lab.
It also explains why if b starts at address 0x7FFFFFD0, then b.accountNumber is at 0x7FFFFFD0, b.balance is at 0x7FFFFFD8, and b.interestRate is at 0x7FFFFFE0.
Task 3: Predict the next address
Section titled “Task 3: Predict the next address”Reset the lab (use the Reset example button). Object a (an Account) starts at 0x7FFFFFC0. Without looking at b yet, predict:
- What address does
bstart at? - What address would a third object start at, if you dragged a
CurrentAccountin immediately afterb? Now drag in aCurrentAccountand check.
Show explanation
Object a is an Account (16 bytes), starting at 0x7FFFFFC0. Therefore it ends at 0x7FFFFFCF (inclusive), and the next byte is 0x7FFFFFD0. That’s where b starts.
b is a DepositAccount (24 bytes), so it occupies 0x7FFFFFD0 through 0x7FFFFFE7. The next byte after b is 0x7FFFFFE8 — that’s where your CurrentAccount should appear.
Hex maths reminder: 0xC0 + 16 decimal = 0xC0 + 0x10 = 0xD0. And 0xD0 + 24 = 0xD0 + 0x18 = 0xE8. Each object’s start address is just the previous object’s start plus the previous object’s size.
In real code you’d compute the address of any local with &objectName, but the rule is the same: locals are placed contiguously on the stack frame.
Task 4: Two objects, same values, different identities
Section titled “Task 4: Two objects, same values, different identities”Clear the stack. Drag in two Account objects. By default the lab will name them a and b, both with the same default values (accountNumber = 12345, balance = 100.00).
- Are
aandbthe same object? - If you change
b.balanceto999.99, what happens toa.balance? Predict, then check by editing.
Show explanation
No, a and b are different objects. They happen to start with identical contents, but they live at different addresses (0x7FFFFFC0 and 0x7FFFFFD0), so they’re independent storage. Modifying one has no effect on the other.
This is one of the most important things to internalise about classes: the same class definition can produce many distinct objects. The class is the recipe; each object is a separately-baked cake. Two objects of the same class are equal in type but not in identity. They aren’t the same thing, even if their fields happen to match.
In code: Account a, b; declares two separate Account objects. You could later compare &a == &b and the answer would always be false. They are different addresses, and different objects.
Task 5: Filling the stack
Section titled “Task 5: Filling the stack”The lab’s stack region is 64 bytes. With three objects of the following sizes:
Account= 16 bytesDepositAccount= 24 bytesCurrentAccount= 24 bytes How many objects of each type can you fit before the strip is full? Find at least two different combinations that exactly fill all 64 bytes with no gaps.
Show explanation
Some combinations that exactly fill 64 bytes:
- 4 ×
Account= 4 × 16 = 64 - 1 ×
Account+ 2 ×DepositAccount= 16 + 48 = 64 - 1 ×
Account+ 2 ×CurrentAccount= 16 + 48 = 64 - 1 ×
Account+ 1 ×DepositAccount+ 1 ×CurrentAccount= 16 + 24 + 24 = 64 Combinations that don’t fit cleanly leave a remainder less than the smallest object’s size. For example, 3 ×DepositAccount= 72 bytes — that’s more than the region. 2 ×DepositAccount= 48 bytes, leaving 16 bytes free — exactly the right size for one moreAccount. So this is also valid: 2 ×DepositAccount+ 1 ×Account.
Try a few of these in the lab. Drag the chips to different locations to see the auto-snap behaviour. If you try to drop an object where it doesn’t fit, the lab refuses the drop and the preview tile turns red.
In real C++, this is exactly what happens to your stack frame: each local variable is given a contiguous run of bytes, and the total stack used by a function is the sum of its locals’ sizes (plus any padding the compiler adds between them for alignment).
Task 6: Address arithmetic from first principles
Section titled “Task 6: Address arithmetic from first principles”Without using the lab at all, work out the following on paper. Object c is a CurrentAccount starting at address 0x7FFF1000.
- What’s the address of
c.accountNumber? - What’s the address of
c.balance? - What’s the address of
c.overdraftLimit? - What’s the address of the first byte after
c(i.e. where the next variable on the stack would go)? Once you’ve worked it out, set up the lab with a singleCurrentAccountand verify the offsets match (the lab uses a different base address, but the offsets from the start of the object will be identical).
Show explanation
Working from a CurrentAccount’s layout:
accountNumberis at offset 0 →0x7FFF1000- padding occupies offsets 4–7 (no field name)
balanceis at offset 8 →0x7FFF1008overdraftLimitis at offset 16 →0x7FFF1010- The first byte after the object is at offset 24 →
0x7FFF1018The total size of the object is 24 bytes, so the next variable’s start address isstart + 24=0x7FFF1000 + 0x18=0x7FFF1018.
Notice that the offsets are the same regardless of the starting address. The class layout is fixed at compile time — the only thing that changes from one object to another is the base address you add the offsets to. This is exactly what the compiler does when you write c.balance: it computes &c + 8 and reads 8 bytes from there.
Task 7: A subtle question about sizeof
Section titled “Task 7: A subtle question about sizeof”Suppose you write the following code in a real C++ program and run it on a 64-bit machine:
cout << sizeof(Account) << endl;cout << sizeof(DepositAccount) << endl;cout << sizeof(CurrentAccount) << endl;What three numbers does it print? Now, more interestingly, what would happen to those numbers if you added a bool active; field at the end of Account (after balance)?
Predict, then sketch the new layout on paper before reading the explanation.
Show explanation
Without the new field, the output is 16 24 24 — the sizes you’ve already seen.
With bool active; appended after balance, you might guess the new size is 17 bytes (16 + 1 for the bool). It isn’t.
A bool is 1 byte. After balance ends at offset 16, the bool sits at offset 16. The object’s content now ends at offset 17. But the compiler must round the total object size up to a multiple of the largest alignment requirement of any field, which is 8 (for the double). So the actual size becomes 24 bytes: 7 bytes of trailing padding pad it up to the next multiple of 8.
This trailing padding matters when you put objects back-to-back in an array: each object must start at an address that’s properly aligned for all its fields, so its size has to be a multiple of its alignment. Without trailing padding, an array of objects would have misaligned fields after the first one.
DepositAccount and CurrentAccount would now be 32 bytes each (24 inherited + 8 for the new derived field, with no extra padding needed). One added bool increases every object size by 8 bytes. This is why, in performance-critical C++ code, programmers reorder
fields from largest to smallest — it minimises padding waste.
Bonus task: What changes with virtual?
Section titled “Bonus task: What changes with virtual?”This is a preview of the heap lab that follows. Suppose you modified the classes to make the destructor of Account virtual:
class Account {public: int accountNumber; double balance; virtual ~Account() = default; // NEW: virtual destructor};Without changing any field, what happens to:
sizeof(Account)?- The byte offset of
accountNumber? - The byte offset of
balance? Sketch the new layout. The lab in this exercise doesn’t show this case (that’s the next lab) but you should be able to predict it.
Show explanation
Adding any virtual method (including a virtual destructor) causes the compiler to insert a hidden 8-byte pointer at offset 0 of the object — the vtable pointer, often called the __vptr. It points at a small read-only table of function pointers (the vtable) that the runtime uses to look up which version of a virtual method to call.
The new layout becomes:
+ 0 8 bytes `__vptr` (hidden, points at Account::vtable)+ 8 4 bytes accountNumber+12 4 bytes padding+16 8 bytes balance — total: 24 bytesSo:
sizeof(Account)becomes24(was16).accountNumbermoves from offset 0 to offset 8.balancemoves from offset 8 to offset 16. Every object pays this 8-byte cost regardless of which virtual methods you add. It’s a one-time-per-object overhead for having any virtual method.DepositAccountandCurrentAccountwould correspondingly grow to 32 bytes each.
This is the price of polymorphism, and the next lab makes it visible by allocating these objects on the heap, where you can also see the vtable itself sitting in static memory and the __vptr inside each object pointing at it.
Wrap-up
Section titled “Wrap-up”You’ve now seen, by direct manipulation of bytes, that:
- A class is a layout description; an object is a concrete run of bytes laid out according to that description.
- The size of an object is the sum of its fields’ sizes plus any padding the compiler inserts to satisfy alignment, plus any trailing padding to round the total up to a multiple of its alignment.
- Inheritance is implemented by embedding the base class’s layout at the start of the derived class’s layout, which means a derived object literally begins with a fully-formed base object in its first bytes.
- Two objects of the same type are independent storage at distinct addresses, even if their fields happen to be equal.
- Address arithmetic on objects is just base address + field offset. The compiler computes offsets at compile time and adds them to the runtime base address.
In the next lab you’ll move these same three classes onto the heap with
new, addvirtualmethods, and discover what the vtable pointer looks like in practice, along with the three classic heap bugs (leak, dangling pointer, double-delete).
Knowledge Check
Section titled “Knowledge Check”Match the Memory Layout Concepts
Why is sizeof(Account) 16 bytes when its fields (int and double) only total 12 bytes?
If you have two objects of the same class with identical field values, what can you say about their identity?
Where are the fields of a base class located within a derived class object?
What happens to the stack memory of a local object when the function returns?
© 2026 Derek Molloy, Dublin City University. All rights reserved.