Memory Management
Reviewed & published by Brayan K
By the end of this lesson you'll know exactly where your data lives — stack or heap — how to allocate and free it by hand with new/delete, why leaks and dangling pointers happen, and how RAII and smart pointers let modern C++ manage memory for you so you almost never write delete again.
Part of the free C++ course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Tell stack (automatic) from heap (dynamic) storage and pick the right one
- Allocate and free with new/delete and new[]/delete[] correctly
- Spot memory leaks, dangling pointers, double frees, and use-after-free
- Use RAII so a destructor frees memory automatically
- Default to unique_ptr + make_unique and move ownership instead of copying
- Share with shared_ptr and break reference cycles with weak_ptr
💡 Real-World Analogy
Think of two ways to store your stuff. The stack is the desk in front of you: grabbing something is instant, and when you stand up everything is cleared away for you — but the desk is small. The heap is a self-storage warehouse: there's room for almost anything of any size, but you rent the unit and you must remember to return the key. Forget to return it and you keep paying for a unit you can't even reach — that's a memory leak. A smart pointer is a rental agreement that returns the key for you the moment you no longer need the unit.
1. Stack vs Heap, Automatic vs Dynamic
Every program has two regions of memory. The stack holds your ordinary local variables; this is automatic storage because each variable is created when you declare it and destroyed automatically when it goes out of scope. The heap is for dynamic storage — memory you request at runtime with new and keep until you hand it back with delete. The stack is fast but small (a few MB); the heap is huge but you manage its lifetime by hand.
| Feature | Stack (automatic) | Heap (dynamic) |
|---|---|---|
| Speed | Very fast | Slower |
| Size | Limited (~1–8 MB) | Large (GBs) |
| Lifetime | Ends at scope exit | Until you delete |
| Sizing | Fixed at compile time | Chosen at runtime |
| Allocate | int x = 5; | int* p = new int(5); |
Read this worked example, run it, and watch which values are cleaned up for you and which you free yourself.
#include <iostream>
using namespace std;
void stackExample() {
int local = 10; // STACK: automatic storage, freed at return
int arr[3] = {1, 2, 3}; // STACK array: size fixed at compile time
cout << "Stack value: " << local << "\n";
// 'local' and 'arr' vanish automatically when this function ends
}
void heapExample() {
int* p = new int(99); // HEAP: dynamic storage, YOU own its lifetime
cout << "Heap value: " << *p << "\n"; // *p reads the pointed-to int -> 99
delete p; // YOU must free it, or it leaks
}
int main() {
stackExample(); // prints: Stack value: 10
heapExample(); // prints: Heap value: 99
// The heap shines when the size is only known at runtime:
int n = 5; // imagine this came from user input
int* heapArr = new int[n]; // allocate n ints on the heap
for (int i = 0; i < n; i++) heapArr[i] = i * i;
cout << "heapArr[4] = " << heapArr[4] << "\n"; // heapArr[4] = 16
delete[] heapArr; // array form: delete[] (not plain delete)
return 0;
}
// ✅ Expected output:
// Stack value: 10
// Heap value: 99
// heapArr[4] = 162. Allocating by Hand: new and delete
new reserves heap memory and returns a pointer to it; delete hands that memory back. For a single value you pair new with delete; for an array you pair new[] with delete[]. The forms are not interchangeable — using delete on something from new[] is undefined behaviour. The golden rule: every new has exactly one matching delete, every new[] exactly one delete[].
#include <iostream>
using namespace std;
int main() {
// 1) Single value: new returns a pointer to one fresh int on the heap.
int* ptr = new int(42); // allocate + initialise to 42
cout << "Value: " << *ptr << "\n"; // *ptr -> 42 (dereference)
*ptr = 100; // change the value through the pointer
cout << "Updated: " << *ptr << "\n"; // *ptr -> 100
delete ptr; // free the single int
ptr = nullptr; // nullify so it can't dangle
// 2) Array: new[] allocates many in a row; pair it with delete[].
int size = 4;
int* arr = new int[size]; // 4 uninitialised ints on the heap
for (int i = 0; i < size; i++) {
arr[i] = (i + 1) * 10; // 10, 20, 30, 40
}
cout << "Array: ";
for (int i = 0; i < size; i++) cout << arr[i] << " "; // Array: 10 20 30 40
cout << "\n";
delete[] arr; // array form frees ALL 4 ints
arr = nullptr;
return 0;
}
// ✅ Expected output:
// Value: 42
// Updated: 100
// Array: 10 20 30 40Your turn. Fill in the three blanks to allocate one double, free it, and nullify the pointer.
#include <iostream>
using namespace std;
int main() {
// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// 1) Allocate ONE double on the heap, initialised to 3.14
double* pi = ___; // 👉 new double(3.14)
cout << "pi = " << *pi << "\n"; // *pi reads the value through the pointer
// 2) Free that single double (single value -> plain delete)
___; // 👉 delete pi;
// 3) Nullify the pointer so it can't dangle
pi = ___; // 👉 nullptr
cout << (pi == nullptr ? "Freed and safe!" : "Still dangling") << "\n";
// ✅ Expected output:
// pi = 3.14
// Freed and safe!
return 0;
}Now do it for an array. Remember the array form of both new and delete.
#include <iostream>
using namespace std;
int main() {
// 🎯 YOUR TURN — a runtime-sized array on the heap.
int n = 3;
// 1) Allocate an array of n ints on the heap
int* scores = ___; // 👉 new int[n]
scores[0] = 70;
scores[1] = 85;
scores[2] = 95;
int total = 0;
for (int i = 0; i < n; i++) total += scores[i];
cout << "Average: " << total / n << "\n";
// 2) Free the ARRAY (array form, not plain delete)
___; // 👉 delete[] scores;
scores = nullptr;
// ✅ Expected output:
// Average: 83
return 0;
}3. Leaks, Dangling Pointers & Double Frees
Manual memory has three classic traps. A memory leak is new with no matching delete — the memory stays reserved but unreachable, like losing your locker key. A dangling pointer still holds an address you've already freed; reading through it is undefined behaviour (it might print garbage, might crash, might silently corrupt data). A double free is calling delete twice on the same block, which crashes. The simple discipline: set a raw pointer to nullptr right after delete — deleting nullptr is a safe no-op.
#include <iostream>
using namespace std;
// LEAK: allocates but never frees -> 400 bytes lost on every call
void leaky() {
int* data = new int[100];
// ... no delete[] here -> memory is reserved but unreachable forever
}
int main() {
// --- Dangling pointer: using memory after it's freed ---
int* p = new int(42);
cout << "Before delete: " << *p << "\n"; // Before delete: 42
delete p; // memory is returned to the system
// cout << *p; // ❌ UNDEFINED BEHAVIOUR: p now dangles
p = nullptr; // ✅ fix: nullify so the mistake is obvious
if (p == nullptr) cout << "Pointer is null -> safe to test\n";
// --- Double delete: freeing the same block twice ---
int* q = new int(7);
delete q; // first delete: fine
// delete q; // ❌ CRASH: double free
q = nullptr;
delete q; // ✅ deleting nullptr is a harmless no-op
cout << "No leaks, no dangles in main()\n";
return 0;
}
// ✅ Expected output:
// Before delete: 42
// Pointer is null -> safe to test
// No leaks, no dangles in main()4. RAII: Let a Destructor Do the Cleanup
Remembering to delete on every path — including when an exception is thrown — is hard, and humans forget. RAII (Resource Acquisition Is Initialisation) is the C++ answer: wrap the resource in an object whose constructor acquires it and whose destructor releases it. Because C++ guarantees the destructor runs the moment the object leaves scope, cleanup becomes automatic and leak-proof. This single idea is the foundation everything else in modern C++ builds on.
#include <iostream>
using namespace std;
// RAII = Resource Acquisition Is Initialisation.
// The constructor grabs the resource; the destructor releases it.
// When the object dies, cleanup happens AUTOMATICALLY — even on exceptions.
class IntBuffer {
int* data;
int size;
public:
IntBuffer(int n) : data(new int[n]()), size(n) { // () zero-initialises
cout << "Allocated " << n << " ints\n";
}
~IntBuffer() { // destructor runs at end of scope
delete[] data; // you never call delete yourself
cout << "Freed automatically\n";
}
int& operator[](int i) { return data[i]; }
};
int main() {
{
IntBuffer buf(3); // prints: Allocated 3 ints
buf[0] = 11;
buf[1] = 22;
cout << "buf[0] = " << buf[0] << "\n"; // buf[0] = 11
} // buf goes out of scope HERE -> destructor frees the memory
cout << "Back in main, memory already freed\n";
return 0;
}
// ✅ Expected output:
// Allocated 3 ints
// buf[0] = 11
// Freed automatically
// Back in main, memory already freed5. Smart Pointers — the Modern Default
You rarely write your own RAII wrapper because the standard library already ships them: smart pointers, in <memory>. They look and act like raw pointers (use * and ->) but they own what they point at and free it automatically. They also encode ownership semantics — who is responsible for the memory — right in the type.
unique_ptr is the sole owner: build it with make_unique, and it frees the object at scope exit. You can't copy a unique_ptr (that would mean two owners) but you can move ownership with std::move. shared_ptr allows shared ownership through a reference count: each copy bumps the count, each destruction lowers it, and the object is freed only when the count hits zero. Default to unique_ptr; use shared_ptr only when ownership is truly shared.
#include <iostream>
#include <memory> // smart pointers live here
using namespace std;
struct Widget {
int id;
Widget(int i) : id(i) { cout << "Widget " << id << " built\n"; }
~Widget() { cout << "Widget " << id << " destroyed\n"; }
};
int main() {
// unique_ptr: SOLE owner. Frees automatically. No new/delete in sight.
unique_ptr<Widget> a = make_unique<Widget>(1); // Widget 1 built
cout << "a owns Widget " << a->id << "\n"; // arrow works like a raw ptr
// unique_ptr<Widget> b = a; // ❌ won't compile: can't COPY ownership
unique_ptr<Widget> b = move(a); // ✅ MOVE ownership to b
cout << "a is now " << (a ? "set" : "empty") << "\n"; // a is now empty
// shared_ptr: SHARED ownership via a reference count.
shared_ptr<Widget> s1 = make_shared<Widget>(2); // Widget 2 built, count = 1
{
shared_ptr<Widget> s2 = s1; // count = 2 (both own it)
cout << "use_count = " << s1.use_count() << "\n"; // use_count = 2
} // s2 dies -> count = 1, Widget 2 stays alive
cout << "use_count = " << s1.use_count() << "\n"; // use_count = 1
return 0; // b frees Widget 1, s1 frees Widget 2 — automatically
}
// ✅ Expected output:
// Widget 1 built
// a owns Widget 1
// a is now empty
// Widget 2 built
// use_count = 2
// use_count = 1
// Widget 2 destroyed
// Widget 1 destroyed6. weak_ptr: Breaking Reference Cycles
shared_ptr has one trap. If object A holds a shared_ptr to B and B holds one back to A, each keeps the other's reference count above zero forever — neither is ever freed. That's a reference cycle, and it leaks. A weak_ptr fixes it: it observes an object without owning it, so it doesn't raise the reference count. To use what it points at, you call .lock(), which gives you a temporary shared_ptr (empty if the object is already gone). Rule of thumb: make the "back-reference" the weak one.
#include <iostream>
#include <memory>
using namespace std;
struct Node {
int value;
shared_ptr<Node> next; // strong: keeps 'next' alive
weak_ptr<Node> prev; // weak: observes without owning -> no cycle
Node(int v) : value(v) { cout << "Node " << v << " built\n"; }
~Node() { cout << "Node " << value << " destroyed\n"; }
};
int main() {
auto a = make_shared<Node>(1);
auto b = make_shared<Node>(2);
a->next = b; // a strongly owns b
b->prev = a; // b only WEAKLY refers back to a -> count of a stays 1
// To use a weak_ptr you must lock() it into a temporary shared_ptr:
if (auto locked = b->prev.lock()) {
cout << "b->prev points to Node " << locked->value << "\n"; // Node 1
}
return 0; // both destroyed cleanly; if prev were shared_ptr, both LEAK
}
// ✅ Expected output:
// Node 1 built
// Node 2 built
// b->prev points to Node 1
// Node 1 destroyed
// Node 2 destroyedCommon Errors (and the fix)
- Memory leak — forgot to free: int* p = new int[100]; with no delete[] p;. The memory is reserved forever. Fix: match every new with a delete, or use a smart pointer so you can't forget.
- Double free / "free(): double free detected": calling delete p; twice on the same pointer crashes. Fix: set p = nullptr; after the first delete — deleting nullptr does nothing.
- Use-after-free (dangling): reading *p after delete p; is undefined behaviour — garbage, a crash, or silent corruption. Fix: nullify after delete, or let a smart pointer manage lifetime.
- Wrong delete form: using delete p; on memory from new int[n] (or delete[] on a single new) is undefined behaviour. Fix: new[] pairs only with delete[]; new only with delete.
- shared_ptr cycle leak: two shared_ptrs pointing at each other never reach refcount 0, so neither frees. Fix: make one direction a weak_ptr to break the cycle.
- "error: use of deleted function" copying unique_ptr: auto b = a; where a is a unique_ptr won't compile. Fix: move it — auto b = std::move(a);.
Pro Tips
- 💡 Prefer the stack: if the size is known and small, a plain local beats new every time — no leaks possible.
- 💡 Default to unique_ptr: it has zero overhead over a raw pointer and clearly states single ownership.
- 💡 Reach for make_unique/make_shared: they're exception-safe and you never write a naked new.
- 💡 Detect leaks with tools: run valgrind ./program or build with -fsanitize=address to catch leaks and use-after-free.
📋 Quick Reference
| Task | Code |
|---|---|
| Allocate one value | int* p = new int(5); |
| Free one value | delete p; p = nullptr; |
| Allocate an array | int* a = new int[n]; |
| Free an array | delete[] a; |
| Sole owner (modern) | auto u = make_unique<T>(args); |
| Move ownership | auto v = std::move(u); |
| Shared owner | auto s = make_shared<T>(args); |
| Non-owning observer | weak_ptr<T> w = s; w.lock(); |
Mini-Challenge: Own an Account with a Smart Pointer
No blanks this time — just a brief and an outline. Create an Account on the heap using make_unique, update it through the -> arrow, and notice you never write delete. Run it and check your output against the expected line.
#include <iostream>
#include <memory>
using namespace std;
struct Account {
string owner;
double balance;
Account(string o, double b) : owner(o), balance(b) {}
};
int main() {
// 🎯 MINI-CHALLENGE: own an Account with a smart pointer
// 1. Use make_unique<Account>(...) to create an Account
// for "Sam" with a balance of 100.0 (store it in a unique_ptr)
// 2. Use the -> arrow to add 50 to the balance
// 3. Print: "Sam now has 150"
// 4. Notice: you write NO delete — the unique_ptr frees it for you
//
// ✅ Expected output:
// Sam now has 150
// your code here
return 0;
}🎉 Lesson Complete
- ✅ Stack = automatic storage (freed at scope exit); heap = dynamic storage (freed by you)
- ✅ new/delete for one value, new[]/delete[] for arrays — never mix the forms
- ✅ Leaks, dangling pointers, and double frees come from manual delete; nullify after freeing
- ✅ RAII ties cleanup to a destructor so it always runs, even on exceptions
- ✅ unique_ptr + make_unique is the default; move ownership instead of copying
- ✅ shared_ptr counts references; weak_ptr observes without owning to break cycles
Practice quiz
Which storage is automatic — created and destroyed for you as it goes in and out of scope?
- The heap
- The data segment
- The stack
- The code segment
Answer: The stack. Stack (automatic) storage frees itself at scope exit. Heap memory from new lives until you delete it yourself.
What is the correct way to free memory from 'new int[n]'?
- delete[] p;
- delete p;
- free(p);
- p = nullptr;
Answer: delete[] p;. Pair new[] with delete[]. Using plain delete on an array allocation is undefined behaviour.
What is a memory leak?
- Reading freed memory
- Deleting twice
- Stack overflow
- A 'new' with no matching 'delete', so the memory stays reserved but unreachable
Answer: A 'new' with no matching 'delete', so the memory stays reserved but unreachable. A leak is allocated memory that is never freed and never reclaimed — like losing your locker key. Match every new with a delete, or use a smart pointer.
What is a dangling pointer?
- A null pointer
- A pointer that still holds the address of memory that has already been freed
- A pointer to the stack
- A const pointer
Answer: A pointer that still holds the address of memory that has already been freed. A dangling pointer points at memory that was already freed (or a local that went out of scope). Reading or writing through it is undefined behaviour.
What is the simple discipline to avoid a dangling pointer after delete?
- Set the raw pointer to nullptr right after delete
- Call delete twice
- Cast it to int
- Call free() on it
Answer: Set the raw pointer to nullptr right after delete. Setting p = nullptr; after delete makes a stray later use fail loudly, and deleting nullptr is a harmless no-op.
What does RAII stand for and rely on?
- Random Access Is Instant; pointers
- Run And Initialise Immediately; the constructor only
- Resource Acquisition Is Initialisation; the destructor releasing the resource at scope exit
- Reference And Index Iterator; iterators
Answer: Resource Acquisition Is Initialisation; the destructor releasing the resource at scope exit. RAII wraps a resource so the constructor acquires it and the destructor releases it. Because the destructor always runs at scope exit, cleanup is automatic — even on exceptions.
What is the key difference between unique_ptr and shared_ptr?
- unique_ptr can be copied freely
- unique_ptr is the sole owner (move-only); shared_ptr co-owns via a reference count
- shared_ptr cannot free memory
- They are identical
Answer: unique_ptr is the sole owner (move-only); shared_ptr co-owns via a reference count. unique_ptr has one owner and can be moved but not copied. shared_ptr keeps a reference count and frees the object only when the last owner is destroyed.
Why won't 'unique_ptr<T> b = a;' compile when a is a unique_ptr?
- a is null
- T is abstract
- unique_ptr has no constructor
- Copying would mean two owners — you must move: b = std::move(a);
Answer: Copying would mean two owners — you must move: b = std::move(a);. A unique_ptr is the sole owner, so it can't be copied. Transfer ownership with std::move, after which a is empty.
Why can two shared_ptrs pointing at each other leak?
- They corrupt the heap
- They form a reference cycle — each keeps the other's count above zero, so neither is ever freed
- shared_ptr is broken
- They double-free
Answer: They form a reference cycle — each keeps the other's count above zero, so neither is ever freed. A reference cycle keeps both counts from reaching zero. Break it by making one direction a weak_ptr, which observes without bumping the count.
How do you access the object a weak_ptr observes?
- Dereference it directly with *
- Call .get() and delete it
- Call .lock() to get a temporary shared_ptr (empty if the object is gone)
- You cannot
Answer: Call .lock() to get a temporary shared_ptr (empty if the object is gone). A weak_ptr doesn't own the object, so you call .lock() to obtain a temporary shared_ptr; it is empty if the object has already been destroyed.
Continue this course
- Previous: STL (Standard Template Library)
- Next: Move Semantics & Smart Pointers — Use unique_ptr, shared_ptr, and move semantics for safe memory management
- Quick reference: C++ cheat sheet › Memory & Ownership
- From the blog: Memory Management in C++ · C++ vs Java: Which Should You Learn First?
Frequently asked questions
When should I use the heap (new) instead of the stack?
Reach for the heap only when you genuinely need it: the size isn't known until runtime, the object must outlive the function that created it, or it's too big for the stack (a few MB). For everything else, prefer plain stack variables — they're faster and clean themselves up.
What is the difference between unique_ptr and shared_ptr?
A unique_ptr is the sole owner of its object and frees it when it goes out of scope — ownership can be moved but never copied. A shared_ptr lets several pointers co-own one object; it keeps a reference count and frees the object only when the last shared_ptr is destroyed. Use unique_ptr by default and shared_ptr only when ownership is genuinely shared.
Why does shared_ptr leak when two objects point at each other?
Two shared_ptrs pointing at each other form a cycle: each keeps the other's reference count at 1, so neither ever reaches zero and neither is freed. Break the cycle by making one direction a weak_ptr, which observes the object without bumping the reference count.
Do I still need to write new and delete in modern C++?
Almost never. Modern C++ (C++11 and later) replaces raw new/delete with make_unique and make_shared, which own the memory and free it automatically. You learn new/delete to understand what smart pointers do under the hood and to read older code, but you write smart pointers.
What is a dangling pointer and how do I avoid one?
A dangling pointer still holds the address of memory that has already been freed (or of a local variable that went out of scope). Reading or writing through it is undefined behaviour. Avoid it by setting raw pointers to nullptr right after delete, never returning the address of a local, and using smart pointers so lifetime is managed for you.