Skip to content
C++
Language
since C++11
Advanced

Master C++ Memory Management with RAII and Smart Pointers

Master ownership semantics, RAII, smart pointers, and custom allocators to write leak-free, exception-safe C++ code.

By the end of this page, you will understand ownership as a compile-time property, express every ownership pattern through the type system using std::unique_ptr, std::shared_ptr, and std::weak_ptr, write custom deleters for non-heap resources, and reason confidently about when manual allocation is still the right tool.

What and Why

C++ gives you direct control over object lifetime. That power comes with a responsibility: every resource you acquire must be released exactly once, even when exceptions fly and control flow branches unexpectedly.

The core insight is ownership: at every point in your program, some entity is responsible for each live object. When ownership is implicit β€” tracked only in the programmer's head β€” it leaks or double-frees. When ownership is encoded in the type system, the compiler enforces it for you.

RAII (Resource Acquisition Is Initialization) is the idiom that makes this concrete: tie a resource's lifetime to a stack object's lifetime. The constructor acquires, the destructor releases. Because destructors run deterministically β€” even during stack unwinding from exceptions β€” the resource cannot escape.

C++11 formalized this with three vocabulary types:

TypeOwnership model
std::unique_ptr<T>Sole owner β€” one pointer, one resource
std::shared_ptr<T>Shared ownership β€” reference-counted
std::weak_ptr<T>Non-owning observer of a shared_ptr

Choosing among them is an ownership design decision, not a performance micro-optimisation.

Step by Step

Unique ownership

std::unique_ptr is a zero-overhead abstraction over a raw pointer. Moving it transfers ownership; copying is deleted.

cpp
#include <memory>
#include <iostream>

struct Buffer {
    explicit Buffer(std::size_t n) : data(new char[n]), size(n) {
        std::cout << "Buffer(" << n << ") acquired\n";
    }
    ~Buffer() {
        delete[] data;
        std::cout << "Buffer released\n";
    }
    char* data;
    std::size_t size;
};

int main() {
    auto buf = std::make_unique<Buffer>(1024); // C++14
    // buf is released automatically when it leaves scope
    // even if an exception is thrown above this line
}

std::make_unique (C++14) is preferred over new directly: it avoids the pitfall where an exception between two new expressions in a function argument list leaks one of them.

Transferring unique ownership

cpp
#include <memory>
#include <vector>

std::unique_ptr<int> produce() {
    return std::make_unique<int>(42); // move-elision or explicit move
}

void consume(std::unique_ptr<int> p) {
    // p owns the int here; released on return
}

int main() {
    auto p = produce();
    consume(std::move(p)); // explicit transfer; p is now null
    // p == nullptr here β€” safe to check, unsafe to dereference
}

Shared ownership

When multiple objects genuinely co-own a resource β€” such as nodes in a graph β€” use std::shared_ptr. A control block holds the reference count alongside (or near) the managed object.

cpp
#include <memory>
#include <iostream>

struct Node {
    int value;
    std::shared_ptr<Node> next;
    explicit Node(int v) : value(v) {}
    ~Node() { std::cout << "~Node(" << value << ")\n"; }
};

int main() {
    auto a = std::make_shared<Node>(1); // use_count == 1
    {
        auto b = a;                     // use_count == 2
        b->next = std::make_shared<Node>(2);
    }                                   // b destroyed; use_count back to 1
    // a and a->next both released here
}

std::make_shared allocates the object and control block in a single allocation β€” more cache-friendly and one fewer call to the allocator than std::shared_ptr<T>(new T(...)).

Breaking cycles with weak_ptr

A cycle of shared_ptrs never reaches zero and leaks. Use std::weak_ptr for back-edges or caches: it observes without contributing to the count.

cpp
#include <memory>
#include <iostream>

struct Parent;
struct Child {
    std::weak_ptr<Parent> parent; // non-owning back-reference
    ~Child() { std::cout << "~Child\n"; }
};

struct Parent {
    std::shared_ptr<Child> child;
    ~Parent() { std::cout << "~Parent\n"; }
};

int main() {
    auto p = std::make_shared<Parent>();
    auto c = std::make_shared<Child>();
    p->child = c;
    c->parent = p; // weak β€” no cycle
    // Both destructors run here
}

To use a weak_ptr, call .lock() to obtain a temporary shared_ptr. If the object was already destroyed, .lock() returns nullptr.

Custom deleters

Not all resources are heap memory. File descriptors, GPU handles, and OS locks need custom cleanup.

cpp
#include <memory>
#include <cstdio>

int main() {
    auto closer = [](std::FILE* f) { if (f) std::fclose(f); };
    std::unique_ptr<std::FILE, decltype(closer)> fp(
        std::fopen("data.bin", "rb"), closer
    );
    if (!fp) return 1;
    // file closed automatically, even on early return
}

The deleter becomes part of unique_ptr's type, so there is no runtime overhead for stateless lambdas (empty-base optimisation). For shared_ptr, the deleter is type-erased into the control block, so it does not appear in the type.

Common Patterns

Factory returning unique ownership

Callers receive sole ownership and can choose to retain, move, or wrap it in a shared_ptr themselves.

cpp
#include <memory>

class Widget { /* ... */ };

std::unique_ptr<Widget> make_widget(int config) {
    return std::make_unique<Widget>(/* ... */);
}

Pimpl with unique_ptr

Hide implementation details behind a forward-declared class. The destructor must be defined in the .cpp where Impl is complete.

cpp
// widget.h
#include <memory>
class Widget {
public:
    Widget();
    ~Widget();             // declared here, defined in .cpp
    void render();
private:
    struct Impl;
    std::unique_ptr<Impl> impl_;
};

// widget.cpp
struct Widget::Impl { int x; };
Widget::Widget() : impl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default;  // unique_ptr<Impl> destructor runs here
void Widget::render() { /* use impl_->x */ }

Observer list with weak_ptr

A subject holds weak references to observers. Dead observers are silently skipped without the subject knowing β€” no manual deregistration needed.

cpp
#include <memory>
#include <vector>
#include <algorithm>

struct Observer { virtual void on_event() = 0; virtual ~Observer() = default; };

struct Subject {
    void subscribe(std::shared_ptr<Observer> o) {
        observers_.push_back(o);
    }
    void notify() {
        observers_.erase(
            std::remove_if(observers_.begin(), observers_.end(),
                [](const std::weak_ptr<Observer>& w) {
                    if (auto o = w.lock()) { o->on_event(); return false; }
                    return true; // prune expired
                }),
            observers_.end()
        );
    }
    std::vector<std::weak_ptr<Observer>> observers_;
};

Pool allocation with std::pmr (C++17)

When you create thousands of small objects in a hot path, the cost of individual heap allocations β€” lock contention, allocator overhead, cache misses β€” dominates. std::pmr (Polymorphic Memory Resource, <memory_resource>) lets you swap in a custom allocator strategy without changing the container type.

The key types:

  • std::pmr::monotonic_buffer_resource β€” bump allocator; allocations never returned, everything freed at once when the resource is destroyed. No deallocate overhead. Ideal for builders that construct an object graph and then discard it wholesale.
  • std::pmr::unsynchronized_pool_resource β€” size-segregated pool; individual allocations can be freed. Single-threaded.
  • std::pmr::synchronized_pool_resource β€” same but thread-safe.
cpp
#include <memory_resource>
#include <vector>
#include <list>

void process_batch(const std::vector<int>& ids) {
    // Stack-allocated backing buffer β€” zero heap traffic for small batches
    std::byte buf[64 * 1024];
    std::pmr::monotonic_buffer_resource pool{buf, sizeof(buf)};

    // std::pmr::vector is std::vector<T, std::pmr::polymorphic_allocator<T>>
    std::pmr::vector<int> scratch{&pool};
    scratch.reserve(ids.size());

    for (int id : ids) {
        if (id > 0) scratch.push_back(id);
    }
    // scratch and all its elements freed when pool goes out of scope
    // β€” no individual delete calls, no heap fragmentation
}

std::pmr containers (std::pmr::vector, std::pmr::list, std::pmr::string, etc.) are aliases for the standard containers with std::pmr::polymorphic_allocator baked in. The allocator is passed at construction, not as a template argument, so heterogeneous containers with different backing resources can be stored in the same collection.

Use std::pmr::get_default_resource() and std::pmr::set_default_resource() to swap the global default for an entire subsystem during startup or testing.

Placement new: Object Construction in Pre-Allocated Memory

When you need zero allocator overhead β€” no heap call at all β€” placement new lets you construct an object directly at an address you already control. The syntax new(ptr) T(args) invokes T's constructor at ptr without allocating any memory. Because no allocation happened, you must also call the destructor explicitly: obj->~T().

cpp
#include <new>      // placement new
#include <cstddef>  // std::byte

struct Particle {
    float x, y, z;
    explicit Particle(float x, float y, float z) : x(x), y(y), z(z) {}
};

// Stack storage with correct alignment β€” no heap involved
alignas(Particle) std::byte storage[sizeof(Particle)];

Particle* p = new (storage) Particle(1.0f, 2.0f, 3.0f); // construct in place
// ... use p ...
p->~Particle(); // explicit destructor β€” mandatory before reusing storage

The primary use case is object pools: allocate a fixed slab of memory once, then recycle slots for repeated construction and destruction without touching the heap again.

cpp
#include <new>
#include <cstddef>
#include <cassert>

template <typename T, std::size_t N>
class ObjectPool {
public:
    template <typename... Args>
    T* acquire(Args&&... args) {
        for (std::size_t i = 0; i < N; ++i) {
            if (!used_[i]) {
                used_[i] = true;
                return new (slots_[i]) T(std::forward<Args>(args)...);
            }
        }
        return nullptr; // pool exhausted
    }

    void release(T* obj) {
        obj->~T();       // explicit destructor call
        std::size_t slot = reinterpret_cast<Slot*>(obj) - slots_;
        assert(slot < N);
        used_[slot] = false;
    }

private:
    using Slot = alignas(T) std::byte[sizeof(T)];
    Slot  slots_[N];
    bool  used_[N]{};
};

Key rules:

  • Always include <new> for the placement new overload declaration.
  • Use alignas(T) or std::aligned_storage to guarantee alignment; misaligned access is undefined behaviour.
  • The destructor must be called before the storage is reused or released β€” the compiler will not insert it automatically.
  • Placement new is also used for ring buffers, shared-memory IPC, and embedding objects inside larger structures.

Writing an Allocator (std::allocator_traits)

The standard allocator interface requires only two things from your type: a nested value_type and allocate/deallocate member functions. std::allocator_traits fills in every other associated type and operation as a default, so you write the minimum and containers get the rest for free.

cpp
#include <memory>    // std::allocator_traits
#include <cstdlib>   // std::malloc / std::free
#include <new>       // ::operator new placement form

template <typename T>
struct ArenaAllocator {
    using value_type = T;

    // Stateful allocator: holds a pointer to an external arena
    void*  arena_;
    std::size_t capacity_;
    std::size_t used_ = 0;

    ArenaAllocator(void* arena, std::size_t capacity)
        : arena_(arena), capacity_(capacity) {}

    // Required: allocate n objects of type T
    T* allocate(std::size_t n) {
        std::size_t bytes = n * sizeof(T);
        if (used_ + bytes > capacity_)
            throw std::bad_alloc{};
        T* ptr = reinterpret_cast<T*>(static_cast<char*>(arena_) + used_);
        used_ += bytes;
        return ptr;
    }

    // Required: release (no-op in a bump allocator β€” memory freed with the arena)
    void deallocate(T* /*ptr*/, std::size_t /*n*/) noexcept {}

    // allocator_traits auto-provides: rebind_alloc, construct, destroy,
    // max_size, propagate_on_container_* typedefs, is_always_equal, etc.
};

// Two specializations for the same arena are compatible:
template <typename T, typename U>
bool operator==(const ArenaAllocator<T>&, const ArenaAllocator<U>&) noexcept {
    return true;
}

Passing the allocator to a standard container:

cpp
#include <vector>

int main() {
    alignas(int) char buf[4096];
    ArenaAllocator<int> alloc{buf, sizeof(buf)};

    std::vector<int, ArenaAllocator<int>> v{alloc};
    v.push_back(1);
    v.push_back(2);
    // All vector storage comes from buf β€” no heap calls
}

When the container needs to allocate a different type (e.g., a node type for std::list), it calls std::allocator_traits<A>::rebind_alloc<U>. If your allocator is a single-template-argument template of the form MyAlloc<T>, allocator_traits generates the rebind automatically. Otherwise, add a nested template <typename U> struct rebind { using other = MyAlloc<U>; };.

Prefer std::pmr for new code. PMR achieves the same goal (swappable allocator strategy) through a virtual dispatch interface rather than a template parameter, which means heterogeneous containers and polymorphic usage work without multiplying template instantiations. Raw custom allocators remain necessary when integrating with libraries that take an Allocator template parameter rather than a pmr::memory_resource*.

Choosing a Memory Strategy

The right strategy depends on object lifetime patterns, size predictability, and threading requirements. Profile before committing β€” the heap is fast on modern hardware and most allocation pressure disappears with reserve().

StrategyMechanismWhen to useBenchmark first?
Default heap (new/delete)OS / libc allocator (jemalloc, tcmalloc)General-purpose; unknown object sizes and lifetimesAlways the baseline
std::pmr::monotonic_buffer_resourceBump pointer; no individual freesBatch allocate, free everything at once (parser, request scope)Yes β€” if heap is a bottleneck
std::pmr::unsynchronized_pool_resourceSize-segregated free listsIndividual frees needed; fixed or bounded object size; single threadYes β€” over monotonic
std::pmr::synchronized_pool_resourceSame, with mutexMulti-threaded pool with individual freesOnly when threading is the constraint
Placement new + hand-rolled poolNo allocator overhead at allObject pools with known max capacity; zero-latency real-time codeYes β€” verify alignment cost is zero
Slab / arena allocator (jemalloc, mimalloc)Huge-page-backed slabs per size classLong-running servers; reduce fragmentation over hours of runtimeYes β€” measure RSS and allocation tail latency
Hazard-pointer / epoch-based reclamationDeferred deletion, safe in lock-free codeLock-free data structures where threads may hold references to nodes being freedOnly when building lock-free structures

Decision order:

  1. Start with the default heap and measure.
  2. If allocator calls appear in profiles, try std::pmr::monotonic_buffer_resource on the hot path β€” it is the lowest-effort gain.
  3. If individual frees are needed, switch to a pool resource.
  4. If you need absolute control (zero-overhead, deterministic latency), use placement new with a hand-managed pool.
  5. For server-scale fragmentation, swap in jemalloc or mimalloc at link time before writing any custom code.

What Can Go Wrong

Mixing owning and raw pointers carelessly. Passing a raw T* obtained from unique_ptr::get() to a function that stores it beyond the owner's lifetime is undefined behaviour. Use raw pointers only as non-owning, non-stored "borrow" parameters.

cpp
// Wrong: storing a raw pointer that may dangle
struct Cache {
    int* value; // dangerous if the unique_ptr owner is destroyed
};

// Right: accept a reference for non-owning, short-lived access
void process(const int& value);

Constructing shared_ptr from the same raw pointer twice. Each shared_ptr creates its own control block; both will attempt to delete the object.

cpp
int* raw = new int(5);
std::shared_ptr<int> a(raw);
std::shared_ptr<int> b(raw); // double-free β€” undefined behaviour
// Correct: copy or alias from an existing shared_ptr
auto b2 = a;

Using this inside a class to create a shared_ptr without enable_shared_from_this. The fix is to inherit from std::enable_shared_from_this<T> and call shared_from_this().

cpp
#include <memory>

struct Node : std::enable_shared_from_this<Node> {
    std::shared_ptr<Node> self() { return shared_from_this(); }
};

Forgetting the shared_ptr overhead. Each make_shared call hits the allocator and pays atomic reference-count increments on copy/destroy. In hot paths with millions of small objects, a pool allocator or unique_ptr may be significantly faster.

Quick Reference

NeedTool
Single owner, no sharingstd::unique_ptr<T>
Shared ownershipstd::shared_ptr<T>
Non-owning observer/cachestd::weak_ptr<T>
Non-heap resourceunique_ptr + custom deleter
Hide implementationPimpl with unique_ptr
Cheap allocation of shared objectsstd::make_shared
Safe this in shared contextenable_shared_from_this
Borrow without ownershipRaw pointer or reference parameter
Fast bump allocation (C++17)std::pmr::monotonic_buffer_resource
Per-object pool, single-threadedstd::pmr::unsynchronized_pool_resource
Per-object pool, thread-safestd::pmr::synchronized_pool_resource

Rule of thumb: prefer unique_ptr by default. Reach for shared_ptr only when ownership genuinely cannot be determined at the call site. Use weak_ptr to break cycles or represent optional, non-owning access.

What's Next

  • Understand the concurrency guarantees behind shared_ptr's atomic reference count in Memory Model.
  • See how allocator-aware containers let you control where memory comes from in Memory Management Reference.
  • Explore how constexpr if lets you write allocator policies that compile away entirely in constexpr if (advanced).
  • Learn how lambda captures interact with ownership β€” capturing shared_ptr by value extends lifetime into async callbacks in Lambda (advanced).