Smart Pointers
Smart pointers are RAII wrappers around raw pointers that encode ownership in the type system. They eliminate the need to manually pair new with delete, and they make ownership decisions visible and enforceable at compile time.
Why raw pointers are not enough
A raw pointer tells you where an object is, but it tells you nothing about ownership. Given a raw pointer, you cannot answer six fundamental questions from the type alone:
Smart pointers answer these questions by design. The choice between unique_ptr, shared_ptr, and weak_ptr is a decision about ownership policy — and it is expressed in the type, not in comments.
unique_ptr — exclusive ownership
std::unique_ptr<T> represents exclusive ownership: at any moment, exactly one unique_ptr owns the object. When that pointer is destroyed (or reset), the object is destroyed with it. It is move-only — you can transfer ownership, but you cannot copy it, because that would create two owners for the same object.
With the default deleter, a unique_ptr is exactly the same size as a raw pointer — it adds zero overhead at runtime. This is the smart pointer to reach for first, because it is both the simplest and the fastest. Scott Meyers writes in Effective Modern C++ that unique_ptr "is the C++11 way to express exclusive ownership."
#include <memory>
// Always prefer make_unique over new — explained in the make section below
auto p = std::make_unique<Widget>(); // Widget* inside, zero overhead
// unique_ptr is move-only
auto q = std::move(p); // q owns it now, p is null
// p = q; // ERROR: copy is deleted
// Destruction is automatic
{
auto w = std::make_unique<Widget>();
w->do_something();
} // Widget destroyed here — delete called automatically
// Factory functions: return unique_ptr
// Callers can later upgrade to shared_ptr if needed — forward-compatible
std::unique_ptr<Animal> make_animal(Kind k) {
if (k == Kind::Dog) return std::make_unique<Dog>();
if (k == Kind::Cat) return std::make_unique<Cat>();
return nullptr;
}
std::shared_ptr<Animal> shared = make_animal(Kind::Dog);
// Converting unique_ptr → shared_ptr is trivial
// The reverse is impossible// Custom deleters: for non-memory resources
// Stateless lambda deleter: same size as raw pointer (no penalty)
auto file_del = [](FILE* f) { if (f) fclose(f); };
std::unique_ptr<FILE, decltype(file_del)> f(fopen("data.bin", "r"), file_del);
// Function pointer deleter: grows unique_ptr by one pointer width
// (function pointer must be stored as state)
// → Prefer stateless lambdas for size-sensitive code
// Array form: use when you must manage a C-style heap array
// (prefer std::vector in almost all cases)
auto buf = std::make_unique<char[]>(1024);
buf[0] = 'H'; // operator[] is available for array form
// operator* and operator-> are NOT available for array formunique_ptr at a glance
- • Size: 1 pointer (default deleter) or 1–2 pointers (custom deleter)
- • Copy: disabled — ownership is exclusive
- • Move: O(1), transfers ownership, sets source to null
- • Overhead vs raw pointer: zero (with default deleter)
shared_ptr — shared ownership
std::shared_ptr<T> implements reference-counted shared ownership. Multiple shared_ptrs can point to the same object. The object is destroyed when the last owner is destroyed or reset. The timing of destruction is deterministic — unlike garbage collection — but the ownership structure can be complex.
Internally, a shared_ptr is two pointers: one to the managed object and one to a control block. The control block lives on the heap and holds the reference count, the weak count, and the custom deleter. This means a shared_ptr is twice the size of a raw pointer. The reference count is updated atomically (thread-safe, but not free — one or two atomic operations per copy or destruction).
auto sp1 = std::make_shared<Widget>(); // refcount = 1
auto sp2 = sp1; // refcount = 2 (copy → atomic increment)
auto sp3 = std::move(sp1); // refcount still 2 (move → no increment)
// sp1 is now null
sp2.reset(); // refcount → 1 (atomic decrement)
// sp3 goes out of scope → refcount → 0 → Widget destroyed
// Copy vs move performance
void store(std::shared_ptr<Widget> sp); // copy: +1 atomic increment
void store(std::shared_ptr<Widget>&& sp); // move: 0 atomic ops
// Custom deleter: NOT part of the type (unlike unique_ptr)
auto del = [](Widget* w) { cleanup(w); delete w; };
std::shared_ptr<Widget> sp(new Widget, del);
// Type is std::shared_ptr<Widget> regardless of deleter type
// → can mix shared_ptrs with different deleters in one containerNever create two shared_ptrs from the same raw pointer
Widget* pw = new Widget; std::shared_ptr<Widget> sp1(pw); // control block 1, refcount=1 std::shared_ptr<Widget> sp2(pw); // control block 2, refcount=1 — WRONG! // When sp1 dies: Widget deleted // When sp2 dies: Widget deleted again → undefined behavior (double free)
Always construct from another shared_ptr, or use make_shared. If you must use new, pass it directly without a variable: shared_ptr<Widget>(new Widget).
// enable_shared_from_this: safely create shared_ptr to *this
// Without this, passing 'this' to a shared_ptr constructor is UB
class Widget : public std::enable_shared_from_this<Widget> {
public:
static std::shared_ptr<Widget> create() {
return std::shared_ptr<Widget>(new Widget); // private ctor
}
void register_self(Registry& reg) {
reg.add(shared_from_this()); // correct: reuses existing control block
// reg.add(std::shared_ptr<Widget>(this)); // WRONG: new control block
}
private:
Widget() = default;
};
// Rule: if the class needs shared_from_this(), make its constructors private
// and provide a factory function — prevents pre-ownership useshared_ptr at a glance
- • Size: 2 pointers (ptr to object + ptr to control block)
- • Copy: O(1) + 1 atomic increment
- • Move: O(1), no atomic ops
- • Destruction: O(1) + 1 atomic decrement (+ destructor if last owner)
- • Control block: separate heap allocation (unless make_shared)
weak_ptr — non-owning observation
std::weak_ptr<T> is an augmentation of shared_ptr. It points to an object managed by a shared_ptr, but does not participate in ownership — it does not increment the reference count. This means the object can be destroyed while a weak_ptr still exists. A weak_ptr can detect that situation: it can tell whether what it points to has been destroyed.
You cannot dereference a weak_ptr directly — it has no operator* or operator->. To access the object, you must first lock it, which atomically checks whether the object still exists and, if so, creates a temporary shared_ptr to it. This prevents the race condition where the object is destroyed between the check and the access.
auto sp = std::make_shared<Widget>(); // refcount=1, weak_count=0
std::weak_ptr<Widget> wp(sp); // refcount still 1, weak_count=1
sp = nullptr; // refcount→0: Widget destroyed
// weak_count still 1, control block alive
// Check if expired (non-atomic — don't use for access)
if (wp.expired()) { /* gone */ }
// Lock: atomic check + access in one operation
if (auto live = wp.lock()) { // live is shared_ptr<Widget> or null
live->use_it(); // safe: object is guaranteed alive here
}
// Alternative: throw if expired
try {
std::shared_ptr<Widget> live(wp); // throws std::bad_weak_ptr if expired
live->use_it();
} catch (const std::bad_weak_ptr&) { /* handle */ }// Use case 1: Caching — cache doesn't own the objects
std::unordered_map<ID, std::weak_ptr<Widget>> cache;
std::shared_ptr<Widget> get(ID id) {
if (auto sp = cache[id].lock())
return sp; // still alive: return from cache
auto sp = load(id); // expired or not cached: reload
cache[id] = sp; // store weak reference
return sp;
}
// Use case 2: Break reference cycles
// A and B should not hold shared_ptr to each other — cycle → neither freed
struct Node {
std::shared_ptr<Node> child; // owns child
std::weak_ptr<Node> parent; // observes parent, doesn't own
};weak_ptr at a glance
- • Size: same as shared_ptr (2 pointers)
- • Does NOT affect the object's reference count (only the weak count)
- • Control block stays alive until last weak_ptr dies (even after object is destroyed)
- • Use cases: caching, observer patterns, breaking shared_ptr cycles
Always prefer make_unique and make_shared
The make functions (std::make_unique and std::make_shared) perfect-forward their arguments to the object's constructor and return a smart pointer. They are preferred over calling new directly for three reasons.
First, they eliminate type repetition. Writing make_unique<Widget>() names Widget once; unique_ptr<Widget>(new Widget) names it twice — a DRY violation that can silently diverge.
Second, they are exception-safe. Passing a raw new expression as a function argument alongside another expression that might throw can leak memory — the compiler is allowed to interleave the evaluation. make_shared eliminates this risk by combining allocation and pointer construction into one atomic step.
Third, make_shared is more efficient: it combines the object allocation and the control block allocation into a single heap allocation (vs. two separate allocations when using new).
// Prefer: auto up = std::make_unique<Widget>(args...); // C++14 auto sp = std::make_shared<Widget>(args...); // C++11 // Avoid: auto up = std::unique_ptr<Widget>(new Widget(args...)); // type repeated auto sp = std::shared_ptr<Widget>(new Widget(args...)); // two allocations // Exception-safety issue with new: void process(std::shared_ptr<Widget> sp, int priority); int computePriority(); // might throw process(std::shared_ptr<Widget>(new Widget), computePriority()); // Compiler may evaluate in this order: // 1. new Widget (allocates memory) // 2. computePriority() throws // 3. shared_ptr constructor never runs → Widget leaks! process(std::make_shared<Widget>(), computePriority()); // make_shared is a single expression → no interleaving possible → safe // make_shared: single heap allocation (object + control block fused) // Widget size: 48 bytes, control block: ~24 bytes // new Widget: two allocs → 48 + 24 = 72 bytes, two round trips to allocator // make_shared: one alloc → 72 bytes, one round trip to allocator
When you must use new directly
- • Custom deleter: make functions don't accept deleters — use new + immediate wrap
- • Braced initialization:
make_unique<vector<int>>(10, 20)calls the 2-arg constructor, not{10, 20} - • Very large shared objects with long-lived weak_ptrs: fused allocation delays freeing the object memory until all weak_ptrs are gone too
Choosing the right pointer
The choice is about ownership semantics, not performance. Always start with the weakest ownership that correctly models your problem.
| Situation | Use | Why |
|---|---|---|
| Single owner, not shared | unique_ptr | Zero overhead, expresses intent clearly |
| Factory function return | unique_ptr | Callers can upgrade to shared_ptr if needed; reverse is impossible |
| Genuinely shared ownership | shared_ptr | Reference counting ensures object lives as long as any owner |
| Observer, cache, cycle-breaker | weak_ptr | Doesn't extend lifetime; can detect when object is gone |
| Non-owning alias (short lifetime) | raw pointer or reference | No ownership implied; safe when caller guarantees lifetime |
| Passing to a function that doesn't take ownership | const T& or T* | Avoid incurring refcount overhead; just observe |
Key rules
Default to unique_ptr; reach for shared_ptr only when you need shared ownership
Shared ownership adds complexity and cost. Start exclusive, upgrade if necessary.
Prefer make_unique and make_shared over new
Type deduplication, exception safety, and (for make_shared) a single allocation.
Never construct two shared_ptrs from the same raw pointer
Creates two control blocks, two reference counts, and a double-free on destruction.
Inherit enable_shared_from_this when you need shared_ptr to this
The only safe way to get a shared_ptr from inside a member function.
Pass by const reference or raw pointer to functions that don't take ownership
Don't pay shared_ptr copy overhead (atomic increment) when you're just observing.
Use weak_ptr to break cycles and in observer/cache patterns
A cycle of shared_ptrs is a memory leak. weak_ptr breaks the cycle without dangling.