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.
C++
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.

C++
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.

C++
#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.

C++
#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:

C++
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:

C++
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:

C++
// 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/delete and missing a path (an early return, an exception) where delete never runs — a memory leak.
  • Using delete instead of delete[] (or vice versa) on an array allocated with new[] — undefined behavior.
  • Copying a unique_ptr (a compile error — it's non-copyable by design) instead of moving it with std::move when transferring ownership.
  • Creating reference cycles with shared_ptr (A holds a shared_ptr to B, B holds a shared_ptr back to A) so neither's ref count ever reaches zero — break the cycle with weak_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.