Memory Management in C++
Stack vs heap, RAII, unique_ptr/shared_ptr, and move semantics with a real before/after example.
Stack vs heap
Every C++ program has two places to put data:
- The stack — local variables live here. Allocation and deallocation are automatic and extremely fast (just moving a stack pointer), and memory is reclaimed the instant a variable goes out of scope.
- The heap — memory you (or a smart pointer on your behalf) explicitly request, which stays alive until it's explicitly freed. Slower to allocate than the stack, but necessary when an object must outlive the function that created it, or when its size isn't known until runtime.
void stackExample() {
int x = 42; // allocated on the stack
int arr[100]; // also on the stack — fixed size known at compile time
} // both are automatically destroyed here, no code needed
void heapExample() {
int* p = new int(42); // allocated on the heap — lives until explicitly deleted
delete p; // must be done manually, or it leaks
}
RAII — Resource Acquisition Is Initialization
RAII is the central idiom of modern C++ memory (and resource) management: tie a resource's lifetime to an object's lifetime. Acquire the resource in the constructor; release it in the destructor. Because C++ guarantees destructors run when an object goes out of scope — including during stack unwinding from an exception — the resource is always released, with no try/finally needed.
class FileHandle {
public:
explicit FileHandle(const std::string& path) {
file_ = std::fopen(path.c_str(), "r");
}
~FileHandle() {
if (file_) std::fclose(file_); // guaranteed to run
}
// ... read methods using file_ ...
private:
std::FILE* file_ = nullptr;
};
void process() {
FileHandle handle("data.txt");
// if an exception is thrown here, handle's destructor still runs
// and the file still gets closed — no leak, no explicit cleanup code
}
std::unique_ptr, std::vector, std::string, and std::lock_guard are all standard-library RAII wrappers — this is why idiomatic modern C++ almost never calls delete, fclose, or unlock directly.
std::unique_ptr — exclusive ownership
std::unique_ptr<T> owns a heap object exclusively — exactly one unique_ptr points to it at a time — and deletes it automatically when the unique_ptr goes out of scope. It cannot be copied, only moved.
#include <memory>
class Report {
public:
void print() const { std::cout << "Report contents\n"; }
};
std::unique_ptr<Report> makeReport() {
return std::make_unique<Report>(); // prefer make_unique over raw `new`
}
int main() {
std::unique_ptr<Report> r = makeReport();
r->print();
// r's destructor runs at end of scope, automatically deleting the Report
// Report is deleted here with zero manual cleanup code
}
std::shared_ptr — shared ownership
std::shared_ptr<T> allows multiple owners of the same heap object via reference counting — the object is deleted only when the last shared_ptr pointing to it is destroyed.
#include <memory>
std::shared_ptr<Report> a = std::make_shared<Report>();
std::shared_ptr<Report> b = a; // both now share ownership; ref count is 2
std::cout << a.use_count() << "\n"; // 2
b.reset(); // ref count drops to 1
// a still owns the Report; it's deleted only when 'a' also goes away
Use shared_ptr when ownership is genuinely shared between multiple parts of the program; a weak_ptr can observe a shared_ptr-owned object without extending its lifetime or contributing to the reference count — useful for breaking reference cycles (e.g., a child node holding a weak_ptr back to its parent).
unique_ptr |
shared_ptr |
|
|---|---|---|
| Ownership | Exclusive — exactly one owner | Shared — reference-counted, multiple owners |
| Copyable | No — only movable | Yes — copying increments the ref count |
| Overhead | None (as cheap as a raw pointer) | A control block + atomic ref-count operations |
| Use when | Ownership is clear and singular (the common case) | Ownership is genuinely shared and lifetime is unclear upfront |
Move semantics
Copying a large object (e.g., one owning a big buffer) is wasteful when the source is about to be discarded anyway — moving transfers ownership of the source's internal resources instead of duplicating them, leaving the source in a valid but unspecified ("empty") state.
Before — a class with only a copy constructor pays a deep-copy cost every time it's returned or passed around:
class Buffer {
public:
explicit Buffer(size_t size) : size_(size), data_(new int[size]) {
std::cout << "Allocating " << size_ << " ints\n";
}
// Copy constructor — always does a full deep copy
Buffer(const Buffer& other) : size_(other.size_), data_(new int[other.size_]) {
std::cout << "Deep-copying " << size_ << " ints\n";
std::copy(other.data_, other.data_ + size_, data_);
}
~Buffer() { delete[] data_; }
private:
size_t size_;
int* data_;
};
Buffer makeBuffer() {
Buffer b(1'000'000);
return b; // without move semantics, this could deep-copy a million ints
}
After — adding a move constructor lets the caller steal the temporary's internal pointer instead of copying its contents:
class Buffer {
public:
explicit Buffer(size_t size) : size_(size), data_(new int[size]) {
std::cout << "Allocating " << size_ << " ints\n";
}
// Copy constructor — still available when an explicit copy is really needed
Buffer(const Buffer& other) : size_(other.size_), data_(new int[other.size_]) {
std::cout << "Deep-copying " << size_ << " ints\n";
std::copy(other.data_, other.data_ + size_, data_);
}
// Move constructor — steals the pointer instead of copying, then nulls the source
Buffer(Buffer&& other) noexcept : size_(other.size_), data_(other.data_) {
std::cout << "Moving (no copy!)\n";
other.data_ = nullptr;
other.size_ = 0;
}
~Buffer() { delete[] data_; } // safe even if data_ is nullptr
private:
size_t size_;
int* data_;
};
Buffer makeBuffer() {
Buffer b(1'000'000);
return b; // the compiler moves (or elides entirely) instead of copying
}
int main() {
Buffer a(10);
Buffer b = std::move(a); // explicitly says "I'm done with 'a', steal its contents"
// 'a' is now in a valid but empty state — don't use its contents afterwards
}
std::move doesn't move anything by itself — it's just a cast that says "treat this as an rvalue," making it eligible to bind to the move constructor/assignment operator overload instead of the copy one. The actual "stealing" happens inside whatever move constructor gets selected.
Why raw new/delete should be rare
In modern C++, reaching for new/delete directly is usually a sign a smart pointer or standard container should be used instead:
// Avoid:
int* arr = new int[100];
// ... every exit path from this function must remember to delete[] arr ...
delete[] arr;
// Prefer:
std::vector<int> arr(100); // deallocates itself automatically, exception-safe
The manual version has to get cleanup right on every exit path — including exceptions — or it leaks. RAII types handle this correctly by construction, which is why idiomatic modern C++ code rarely, if ever, calls delete directly.
Common mistakes
- Manually pairing
new/deleteand missing a path (an earlyreturn, an exception) wheredeletenever runs — a memory leak. - Using
deleteinstead ofdelete[](or vice versa) on an array allocated withnew[]— undefined behavior. - Copying a
unique_ptr(a compile error — it's non-copyable by design) instead of moving it withstd::movewhen transferring ownership. - Creating reference cycles with
shared_ptr(A holds ashared_ptrto B, B holds ashared_ptrback to A) so neither's ref count ever reaches zero — break the cycle withweak_ptr.
Interview questions
Q: What is RAII and why is it central to C++?
RAII ties a resource's lifetime to an object's lifetime — acquire in the constructor, release in the destructor. Because C++ guarantees destructors run during normal scope exit and during stack unwinding from exceptions, this makes resource cleanup automatic and exception-safe without needing try/finally.
Q: When would you choose shared_ptr over unique_ptr?
Only when ownership is genuinely shared among multiple parts of the program and it isn't clear upfront which one will outlive the others. unique_ptr should be the default — it has zero overhead versus a raw pointer and makes ownership unambiguous; reach for shared_ptr only when that shared-lifetime requirement is real.