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.
| Property | Stack | Heap (Free Store) |
|---|---|---|
| Allocation speed | Very fast (pointer decrement) | Slower (OS or allocator call) |
| Size limit | Small (~1–8 MB typical) | Large (limited by system RAM) |
| Lifetime | Tied to scope — automatic | Manual (new/delete) or smart pointer |
| Deallocation | Automatic when scope exits | Must be explicit |
| Suitable for | Local variables, function frames | Large 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
| Situation | Use |
|---|---|
| Single, clear owner; no sharing needed | std::unique_ptr |
| Multiple owners; lifetime uncertain | std::shared_ptr |
| Observer that must not extend lifetime | std::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
LinkedListdestructor is gone entirely. Whenhead_(aunique_ptr) is destroyed, it recursively destroys the chain. std::movetransfers 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_ptrchain 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
-
Write a program that allocates a
doublearray of size 1000 on the heap usingnew[], fills it with the first 1000 multiples of 3.14, computes the sum, and frees the memory withdelete[]. Then rewrite it using aunique_ptr<double[]>. -
Implement a
unique_ptr-based binary search tree. EachNodeholds anintvalue and twounique_ptr<Node>children (leftandright). Implementinsertand an in-order traversal. -
Write a class
SharedLoggerthat wraps ashared_ptr<std::ofstream>. Multiple instances ofSharedLoggercan share the same file. The file is closed only when the lastSharedLoggeris destroyed. -
Demonstrate a circular reference leak using two
shared_ptrstructs pointing to each other. Then fix it usingweak_ptrand verify (usinguse_count()) that both objects are properly destroyed. -
Write a function
clone(const std::unique_ptr<int>& p)that returns a newunique_ptr<int>containing a copy of the value. Explain why you cannot simply copyp. -
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
newanddelete. - The three classic bugs are: memory leaks (no
delete), dangling pointers (use afterdelete), and double free (twodeletes 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_ptrmodels exclusive ownership; it cannot be copied but can be moved withstd::move.std::shared_ptrmodels shared ownership via reference counting; the object is freed when the last owner is destroyed.std::weak_ptrobserves ashared_ptr-owned object without affecting the reference count; use it to break circular references.- Always prefer
std::make_uniqueandstd::make_sharedovernewto avoid resource leaks and improve performance. - Use
.get()to obtain the underlying raw pointer for non-owning access; neverdeletea pointer obtained with.get(). - Start with
unique_ptr; upgrade toshared_ptronly when shared ownership is genuinely required.