Memory + RAII · #3 of 13

Stack, Heap, Lifetimes, and Scope

Where your variables actually live, when they die, and why both questions are the same question

stack
heap
 

Try it. Press call f() to push a new frame. Declare an int, then new int(…), then exit f() — watch the heap block turn orange (“leaked”) because the pointer that referenced it died with the frame. Then delete before exiting and see it freed properly. The widget is a toy runtime: the rules it enforces are the rules of the C++ memory model.

Why it matters

The single hardest thing about C++ for newcomers from garbage-collected languages isn’t the syntax. It’s the question:

When does this object get destroyed?

In Python, “whenever, eventually, the GC decides.” In C++, the answer is exact, knowable, and burned into the source code at the place the object is declared. Reading C++ fluently means always knowing the lifetime of every name in scope. This lesson teaches you to see it.

Two regions, one mental model

A running C++ program owns two interesting regions of memory:

The two regions exist for the same reason your laptop has both RAM and SSD: one is fast and bounded, the other is slow and roomy. The trade-off is the curriculum of this whole phase.

A stack frame, drawn

Take the simplest possible function. What does the stack look like the moment the compiler is on the last line?

int main() {
  int x = 5;
  int y = 42;
  // ← we are here
  return 0;
}
stack main() int x = 5 int y = 42
Two automatic variables in main's stack frame. When main returns, both vanish in one CPU instruction.

The frame is a contiguous block. x and y sit next to each other in memory at predictable offsets from the frame pointer. There is no allocator involved. There is no delete to write. When main returns, the whole frame is gone in one stack-pointer adjustment.

This is what “stack allocation” buys you. It is, by orders of magnitude, the cheapest memory in your program.

Scope is lifetime (for automatic storage)

The C++ rule is hard but it is exactly one rule:

An automatic variable lives from the point of its declaration to the closing brace of the enclosing block. Then it is destroyed.

Watch:

#include <iostream>

struct Sentinel {
const char* name;
Sentinel(const char* n) : name(n) {
  std::cout << "  born:  " << name << "\n";
}
~Sentinel() {
  std::cout << "  die:   " << name << "\n";
}
};

void demo() {
Sentinel a{"a (outer)"};
{
  Sentinel b{"b (inner)"};
  std::cout << "inside inner block\n";
}                                  // b dies here, b's destructor runs
std::cout << "back to outer\n";
}                                    // a dies here

int main() {
demo();
std::cout << "back to main\n";
return 0;
}
idle

A Sentinel logs its own birth and death. Run this and you see lifetimes line up with braces, exactly.

expected output
  born:  a (outer)
born:  b (inner)
inside inner block
die:   b (inner)
back to outer
die:   a (outer)
back to main
Or run locally
g++ -std=c++23 -O2 snippet.cpp && ./a.out

Note the destructors fire in reverse order of constructionb was born after a, so b dies first. The standard guarantees this. It is what makes the next lesson’s smart pointers (and std::lock_guard, and std::fstream’s auto-close, and every other RAII type) work.

Heap allocation: when scope isn’t enough

Some objects need to outlive the function that created them. Some objects are too big for the stack. Some objects don’t know their size until runtime. For all of these, you allocate on the heap with new (just for this lesson — in real code you use the smart pointers from lesson 05).

int* p = new int(42);   // p lives on the stack, *p lives on the heap
// … use *p …
delete p;               // hand the heap memory back
stack heap main() int* p = int 42
The pointer `p` lives on the stack. The int it points at lives on the heap. They have different lifetimes.

Two objects, two lifetimes, one mental load:

This asymmetry is the entire reason RAII (lesson 05) exists.

The dangling pointer: same bug, two flavors

The cardinal sin in C-style memory management is referring to memory that no longer belongs to you.

Flavor 1: returning the address of a local

int* bad() {
  int local = 42;
  return &local;     // local dies here. caller gets a pointer to nothing.
}
stack main() int* p = → ✗ bad (returned)() int local = 42
`bad`'s frame is gone. `p` points into the void. Reading `*p` is undefined behavior.

The compiler will usually warn you. Modern compilers are good at catching this exact pattern. Always treat the warning as an error.

Flavor 2: use-after-free

int* p = new int(42);
delete p;            // heap memory returned to the allocator
std::cout << *p;     // reading from a slot that may now hold anything
stack heap main() int* p = → ✗ int (freed)
The pointer hasn't changed. The heap slot has — it's been returned to the allocator, which may have given it to someone else.

The pointer value p is unchanged. The bytes it points at are now owned by the allocator, which is free to hand them to the next new. Reading *p after delete p gives you whatever happens to be there. Sometimes you’ll get 42 for a while and convince yourself the bug is elsewhere. The universe is patient.

Heap is for: outlives-the-function, doesn’t-fit, size-unknown

The rule of thumb for stack vs heap in modern C++:

You want…Use
A small, function-local value with a known lifetimeStack. Just declare it.
A big buffer (≥ a few KB)Heap. Stacks are small.
An object that outlives the function that created itHeap, owned by a smart pointer.
A collection whose size isn’t known until runtimeHeap, but use std::vector and let it manage the allocation.
Something polymorphic (pointer to base class)Heap, owned by std::unique_ptr<Base>.

The pattern: prefer the stack. When you can’t, prefer a container (like std::vector) that manages the heap for you. When you can’t do that either, use a smart pointer. Calling new and delete directly should be rare in modern C++ — we’ll cover when in lesson 05.

std::vector: the heap, with manners

A std::vector<int> v; is an object that lives on the stack but owns a heap-allocated array of ints. When v goes out of scope, its destructor releases the array. You get the heap’s flexibility without the bookkeeping.

#include <iostream>
#include <vector>

int main() {
std::vector<int> v;        // empty, on the stack; no heap allocation yet
for (int i = 0; i < 5; i++) v.push_back(i * i);

std::cout << "v has " << v.size() << " elements (capacity " << v.capacity() << ")\n";
for (int x : v) std::cout << x << ' ';
std::cout << "\n";
return 0;
}
idle

The vector owns the heap allocation. When the vector dies, the array dies with it. No `delete` to forget.

expected output
v has 5 elements (capacity 8)
0 1 4 9 16 
Or run locally
g++ -std=c++23 -O2 snippet.cpp && ./a.out
stack heap main() std::vector<int> v = {size, cap, ptr→} int[8] 0,1,4,9,16,_,_,_
The vector header (3 words) lives on the stack. The element array lives on the heap. Same shape as every other dynamic container.

This is the C++ idiom: use containers, not raw new/delete. The container’s destructor knows what to free and when. You can’t forget because the language fires the destructor for you.

Stack overflow: the price of “fast and bounded”

The stack is fast, but it’s also small. Default thread stacks are typically 1–8 MB on Linux, 1 MB on Windows. Allocate a char buffer[2'000'000]; on the stack and your program may crash before main runs. Recursive functions that don’t bottom out the same way.

#include <iostream>

void recurse(int depth) {
char ballast[1024];   // 1 KB per frame, to make this visible
ballast[0] = (char)depth;
if (depth % 5000 == 0) std::cout << "depth " << depth << "\n";
recurse(depth + 1);   // no base case — will exhaust the stack
}

int main() {
std::cout << "about to overflow…\n";
recurse(0);  // your shell will probably show signal 11 / SIGSEGV
return 0;
}
idle

Don't ship this. Do run it once so the failure mode is in your bones.

expected output
about to overflow…
depth 5000
depth 10000
depth 15000
…
Segmentation fault (core dumped)
# (the kernel kills the process with SIGSEGV once the stack guard page is hit)
Or run locally
g++ -std=c++23 -O2 snippet.cpp && ./a.out

The OS catches stack overflow as a segfault. There is no Python-style RecursionError. The fix: keep automatic objects small, prefer iteration, and put big buffers on the heap.

Key takeaways

What’s next

Lesson 04 dives into pointers and references in detail: when each is the right tool, what const T* vs T* const actually means, and why nullptr is the only spelling worth using. Then lesson 05 introduces the two smart pointers that make manual new/delete almost obsolete.