Chapter 19 of 23

Memory Management and Smart Pointers

Understand stack vs heap memory, prevent leaks and dangling pointers, and master C++11 smart pointers — unique_ptr, shared_ptr, and weak_ptr — with RAII-based ownership models.

Meritshot12 min read
C++Memory ManagementSmart Pointersunique_ptrshared_ptrRAIIC++11
All C++ Chapters

Memory Management and Smart Pointers

One of the most celebrated (and feared) aspects of C++ is manual memory management. Unlike Java or Python, C++ gives you direct control over when memory is allocated and when it is freed. This control is powerful but demands discipline: forget to free memory and you have a leak; free it too early and you have a dangling pointer; free it twice and you have undefined behaviour.

Modern C++ (C++11 and beyond) solves this with smart pointers — objects that own heap memory and release it automatically when they go out of scope. At product companies like Flipkart, PhonePe, or any team writing high-performance C++ for trading systems or embedded devices, smart pointers are the expected standard. Raw pointer ownership is a red flag in code review.


Stack vs Heap Memory

Before looking at new and delete, you must understand the two regions of memory a C++ program uses most.

PropertyStackHeap (Free Store)
Allocation speedVery fast (pointer decrement)Slower (OS or allocator call)
Size limitSmall (~1–8 MB typical)Large (limited by system RAM)
LifetimeTied to scope — automaticManual (new/delete) or smart pointer
DeallocationAutomatic when scope exitsMust be explicit
Suitable forLocal variables, function framesLarge data, dynamic size, shared ownership
void stackExample() {
    int x = 42;          // allocated on the stack
    double arr[100];     // 800 bytes on the stack — fine
}                        // x and arr are destroyed here automatically

void heapExample() {
    int* p = new int(42);     // allocated on the heap
    // ... use p ...
    delete p;                  // must free explicitly
    p = nullptr;               // good practice: null the pointer after delete
}

new and delete

new allocates memory on the heap and calls the constructor. delete calls the destructor and frees the memory.

// Single object
int* p = new int(10);
delete p;

// Array
int* arr = new int[5]{1, 2, 3, 4, 5};
delete[] arr;   // MUST use delete[] for arrays, not delete

The Three Classic Bugs

1. Memory Leak — allocating without deallocating

void leak() {
    int* p = new int(100);
    // forgot delete p; — 4 bytes leaked every call
}

2. Dangling Pointer — using memory after it has been freed

int* p = new int(5);
delete p;
std::cout << *p;  // undefined behaviour — p is dangling

3. Double Free — deleting the same pointer twice

int* p = new int(5);
delete p;
delete p;  // undefined behaviour — heap corruption

Detecting Leaks with Valgrind

Valgrind is a command-line tool popular on Linux (available on Ubuntu, Fedora, and most servers including AWS/GCP instances) that instruments your program and detects memory leaks, dangling pointer accesses, and invalid reads/writes.

g++ -g -o myapp myapp.cpp
valgrind --leak-check=full ./myapp

Sample Valgrind output for a leak:

==12345== HEAP SUMMARY:
==12345==   total heap usage: 2 allocs, 1 frees, 72,712 bytes allocated
==12345== 4 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345==    at 0x4C2A123: operator new(unsigned long)
==12345==    by 0x40054E: leak() (myapp.cpp:3)

On macOS, the equivalent is leaks or the AddressSanitizer (-fsanitize=address) flag:

g++ -fsanitize=address -g -o myapp myapp.cpp && ./myapp

RAII — The Core Principle

RAII (Resource Acquisition Is Initialisation) ties a resource's lifetime to an object's lifetime. The object acquires the resource in its constructor and releases it in its destructor. Because destructors run automatically when a scope exits — even during exception unwinding — the resource is always released.

class ManagedBuffer {
public:
    explicit ManagedBuffer(std::size_t size)
        : data_(new int[size]), size_(size) {}

    ~ManagedBuffer() { delete[] data_; }  // always runs

    int& operator[](std::size_t i) { return data_[i]; }

private:
    int* data_;
    std::size_t size_;
};

void process() {
    ManagedBuffer buf(1000);
    buf[0] = 42;
    // ... if an exception is thrown here, ~ManagedBuffer still runs
}  // buf destroyed here, memory freed

Smart pointers are the standard library's implementation of RAII for heap objects.


std::unique_ptr (C++11)

std::unique_ptr is the most common smart pointer. It represents exclusive ownership of a heap object — exactly one unique_ptr owns the object at any time. When the unique_ptr is destroyed, it deletes the owned object.

#include <memory>
#include <iostream>

int main() {
    std::unique_ptr<int> p = std::make_unique<int>(42);  // C++14
    std::cout << *p << "\n";  // dereference like a raw pointer

    // No need to call delete — happens automatically
}

Transferring Ownership with std::move

unique_ptr cannot be copied (that would violate exclusivity), but it can be moved:

std::unique_ptr<int> a = std::make_unique<int>(10);
std::unique_ptr<int> b = std::move(a);  // ownership transferred to b
// a is now null; b owns the int

unique_ptr for Arrays

auto arr = std::make_unique<int[]>(5);
arr[0] = 100;
// deleted with delete[] automatically

Releasing and Resetting

std::unique_ptr<int> p = std::make_unique<int>(5);
int* raw = p.release();  // p gives up ownership; raw must now be deleted manually
p.reset();               // deletes owned object and sets to null
p.reset(new int(99));    // replaces owned object

std::shared_ptr (C++11)

std::shared_ptr represents shared ownership: multiple shared_ptrs can point to the same object. The object is destroyed when the last shared_ptr owning it is destroyed or reset. This is tracked via a reference count.

#include <memory>
#include <iostream>

int main() {
    std::shared_ptr<int> a = std::make_shared<int>(100);
    {
        std::shared_ptr<int> b = a;  // both a and b own the int
        std::cout << "Count: " << a.use_count() << "\n";  // 2
    }  // b destroyed, count drops to 1
    std::cout << "Count: " << a.use_count() << "\n";  // 1
}   // a destroyed, count drops to 0, int is deleted

Why make_shared is Preferred

std::make_shared<T>(args) allocates the object and the control block (which holds the reference count) in a single allocation, which is faster and more cache-friendly than std::shared_ptr<T>(new T(args)).

// Preferred — one allocation
auto sp = std::make_shared<std::string>("Namaste");

// Avoid — two allocations
std::shared_ptr<std::string> sp2(new std::string("Namaste"));

std::weak_ptr (C++11)

A weak_ptr observes an object owned by shared_ptr without contributing to the reference count. Its primary use is to break circular references, which would otherwise keep objects alive forever and cause a leak.

#include <memory>
#include <iostream>

struct Node {
    int value;
    std::shared_ptr<Node> next;    // strong reference
    std::weak_ptr<Node> prev;      // weak reference — breaks the cycle
};

int main() {
    auto n1 = std::make_shared<Node>();
    auto n2 = std::make_shared<Node>();
    n1->next = n2;
    n2->prev = n1;  // weak — does not increase n1's ref count
}  // both nodes are destroyed correctly

To use a weak_ptr, you must first lock it to obtain a temporary shared_ptr:

std::weak_ptr<int> weak = someSharedPtr;

if (auto locked = weak.lock()) {  // returns shared_ptr, or empty if expired
    std::cout << *locked << "\n";
} else {
    std::cout << "Object has been destroyed\n";
}

Choosing the Right Smart Pointer

SituationUse
Single, clear owner; no sharing neededstd::unique_ptr
Multiple owners; lifetime uncertainstd::shared_ptr
Observer that must not extend lifetimestd::weak_ptr
Non-owning reference (just needs to access)Raw pointer or reference (but not owning)

Rule of thumb: start with unique_ptr. Upgrade to shared_ptr only when you genuinely need shared ownership.


Worked Example: Refactoring a Linked List to Use unique_ptr

Before — Raw Pointer Version (Leaks on Exception)

struct Node {
    int data;
    Node* next;
    Node(int d) : data(d), next(nullptr) {}
};

class LinkedList {
public:
    LinkedList() : head_(nullptr) {}

    ~LinkedList() {
        Node* cur = head_;
        while (cur) {
            Node* next = cur->next;
            delete cur;   // easy to forget or get wrong
            cur = next;
        }
    }

    void push_front(int val) {
        Node* node = new Node(val);
        node->next = head_;
        head_ = node;
    }

    void print() const {
        for (Node* cur = head_; cur; cur = cur->next) {
            std::cout << cur->data << " -> ";
        }
        std::cout << "null\n";
    }

private:
    Node* head_;
};

After — unique_ptr Version (Automatically Safe)

#include <iostream>
#include <memory>
#include <utility>

struct Node {
    int data;
    std::unique_ptr<Node> next;   // owns the next node

    explicit Node(int d) : data(d), next(nullptr) {}
};

class LinkedList {
public:
    LinkedList() : head_(nullptr) {}

    // No destructor needed!
    // When head_ is destroyed, it deletes the owned Node,
    // whose destructor destroys its next, and so on — recursively.

    void push_front(int val) {
        auto node = std::make_unique<Node>(val);
        node->next = std::move(head_);   // transfer old head into new node
        head_ = std::move(node);
    }

    void print() const {
        for (const Node* cur = head_.get(); cur; cur = cur->next.get()) {
            std::cout << cur->data << " -> ";
        }
        std::cout << "null\n";
    }

    // Move the front element's value out and remove it
    int pop_front() {
        if (!head_) throw std::underflow_error("List is empty");
        int val = head_->data;
        head_ = std::move(head_->next);
        return val;
    }

private:
    std::unique_ptr<Node> head_;
};

int main() {
    LinkedList list;
    list.push_front(30);
    list.push_front(20);
    list.push_front(10);

    std::cout << "List: ";
    list.print();   // 10 -> 20 -> 30 -> null

    int val = list.pop_front();
    std::cout << "Popped: " << val << "\n";

    std::cout << "List after pop: ";
    list.print();   // 20 -> 30 -> null

    // No delete anywhere — memory is managed entirely by unique_ptr
}

Key observations:

  • The LinkedList destructor is gone entirely. When head_ (a unique_ptr) is destroyed, it recursively destroys the chain.
  • std::move transfers ownership when inserting or removing nodes.
  • .get() retrieves the raw pointer for read-only traversal without transferring ownership.
  • If an exception is thrown at any point, the unique_ptr chain still cleans up correctly.

Common Pitfalls

1. Mixing shared_ptr with raw new and delete

If you create a shared_ptr from a raw pointer and also delete that raw pointer elsewhere, the object is freed twice — undefined behaviour.

int* raw = new int(5);
std::shared_ptr<int> sp(raw);
delete raw;  // WRONG — sp will also delete it

2. Creating two separate shared_ptrs from the same raw pointer

Each shared_ptr tracks its own independent reference count. Both will try to delete the object when they expire.

int* raw = new int(5);
std::shared_ptr<int> a(raw);
std::shared_ptr<int> b(raw);  // WRONG — two independent ref counts, double free!

Always use make_shared or copy/move from an existing shared_ptr.

3. Circular shared_ptr references

struct A { std::shared_ptr<B> b; };
struct B { std::shared_ptr<A> a; };  // cycle — neither is ever freed
// Fix: make one direction a weak_ptr

4. Dereferencing an expired weak_ptr without locking

Calling *weak directly is not possible (it does not compile). But calling .lock() and dereferencing without checking for null is a runtime bug.

auto locked = weak.lock();
// Must check before use:
if (locked) { std::cout << *locked; }

5. Using unique_ptr::release() carelessly

release() gives up ownership without deleting. The returned raw pointer must be deleted manually or transferred to another smart pointer. Losing track of it causes a leak.

6. Deep recursive destruction

With a long unique_ptr linked list, the recursive destructor calls can overflow the stack. For very long lists, write an iterative destructor.


Practice Exercises

  1. Write a program that allocates a double array of size 1000 on the heap using new[], fills it with the first 1000 multiples of 3.14, computes the sum, and frees the memory with delete[]. Then rewrite it using a unique_ptr<double[]>.

  2. Implement a unique_ptr-based binary search tree. Each Node holds an int value and two unique_ptr<Node> children (left and right). Implement insert and an in-order traversal.

  3. Write a class SharedLogger that wraps a shared_ptr<std::ofstream>. Multiple instances of SharedLogger can share the same file. The file is closed only when the last SharedLogger is destroyed.

  4. Demonstrate a circular reference leak using two shared_ptr structs pointing to each other. Then fix it using weak_ptr and verify (using use_count()) that both objects are properly destroyed.

  5. Write a function clone(const std::unique_ptr<int>& p) that returns a new unique_ptr<int> containing a copy of the value. Explain why you cannot simply copy p.

  6. Use Valgrind or AddressSanitizer to detect a memory leak in a small program, then fix the leak and confirm the tool reports clean.


Summary

  • The stack is fast and automatic; the heap is larger and manually managed with new and delete.
  • The three classic bugs are: memory leaks (no delete), dangling pointers (use after delete), and double free (two deletes on the same pointer).
  • Valgrind and AddressSanitizer (-fsanitize=address) are essential tools for detecting memory errors.
  • RAII ties resource lifetimes to object lifetimes so that destructors always clean up, even during exceptions.
  • std::unique_ptr models exclusive ownership; it cannot be copied but can be moved with std::move.
  • std::shared_ptr models shared ownership via reference counting; the object is freed when the last owner is destroyed.
  • std::weak_ptr observes a shared_ptr-owned object without affecting the reference count; use it to break circular references.
  • Always prefer std::make_unique and std::make_shared over new to avoid resource leaks and improve performance.
  • Use .get() to obtain the underlying raw pointer for non-owning access; never delete a pointer obtained with .get().
  • Start with unique_ptr; upgrade to shared_ptr only when shared ownership is genuinely required.