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

💡 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.

FeatureStack (automatic)Heap (dynamic)
SpeedVery fastSlower
SizeLimited (~1–8 MB)Large (GBs)
LifetimeEnds at scope exitUntil you delete
SizingFixed at compile timeChosen at runtime
Allocateint 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] = 16

2. 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 40

Your 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 freed

5. 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 destroyed

6. 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 destroyed

Common Errors (and the fix)

Pro Tips

📋 Quick Reference

TaskCode
Allocate one valueint* p = new int(5);
Free one valuedelete p; p = nullptr;
Allocate an arrayint* a = new int[n];
Free an arraydelete[] a;
Sole owner (modern)auto u = make_unique<T>(args);
Move ownershipauto v = std::move(u);
Shared ownerauto s = make_shared<T>(args);
Non-owning observerweak_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

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

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.

Related lessons