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:
| Type | Ownership 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.
#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
#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.
#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.
#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.
#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.
#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.
// 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.
#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.
#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().
#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 storageThe 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.
#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)orstd::aligned_storageto 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.
#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:
#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().
| Strategy | Mechanism | When to use | Benchmark first? |
|---|---|---|---|
Default heap (new/delete) | OS / libc allocator (jemalloc, tcmalloc) | General-purpose; unknown object sizes and lifetimes | Always the baseline |
std::pmr::monotonic_buffer_resource | Bump pointer; no individual frees | Batch allocate, free everything at once (parser, request scope) | Yes β if heap is a bottleneck |
std::pmr::unsynchronized_pool_resource | Size-segregated free lists | Individual frees needed; fixed or bounded object size; single thread | Yes β over monotonic |
std::pmr::synchronized_pool_resource | Same, with mutex | Multi-threaded pool with individual frees | Only when threading is the constraint |
| Placement new + hand-rolled pool | No allocator overhead at all | Object pools with known max capacity; zero-latency real-time code | Yes β verify alignment cost is zero |
| Slab / arena allocator (jemalloc, mimalloc) | Huge-page-backed slabs per size class | Long-running servers; reduce fragmentation over hours of runtime | Yes β measure RSS and allocation tail latency |
| Hazard-pointer / epoch-based reclamation | Deferred deletion, safe in lock-free code | Lock-free data structures where threads may hold references to nodes being freed | Only when building lock-free structures |
Decision order:
- Start with the default heap and measure.
- If allocator calls appear in profiles, try
std::pmr::monotonic_buffer_resourceon the hot path β it is the lowest-effort gain. - If individual frees are needed, switch to a pool resource.
- If you need absolute control (zero-overhead, deterministic latency), use placement new with a hand-managed pool.
- 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.
// 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.
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().
#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
| Need | Tool |
|---|---|
| Single owner, no sharing | std::unique_ptr<T> |
| Shared ownership | std::shared_ptr<T> |
| Non-owning observer/cache | std::weak_ptr<T> |
| Non-heap resource | unique_ptr + custom deleter |
| Hide implementation | Pimpl with unique_ptr |
| Cheap allocation of shared objects | std::make_shared |
Safe this in shared context | enable_shared_from_this |
| Borrow without ownership | Raw pointer or reference parameter |
| Fast bump allocation (C++17) | std::pmr::monotonic_buffer_resource |
| Per-object pool, single-threaded | std::pmr::unsynchronized_pool_resource |
| Per-object pool, thread-safe | std::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 iflets you write allocator policies that compile away entirely in constexpr if (advanced). - Learn how lambda captures interact with ownership β capturing
shared_ptrby value extends lifetime into async callbacks in Lambda (advanced).