Skip to content
C Codeloom
C++

C++ Smart Pointers: unique, shared, weak

A practical guide to unique_ptr, shared_ptr, and weak_ptr in modern C++: ownership semantics, when to use each, and the pitfalls that lead to leaks and cycles.

·5 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • What ownership means in modern C++
  • When to use unique_ptr vs shared_ptr
  • How weak_ptr breaks reference cycles
  • Custom deleters and aliasing constructors
  • Common pitfalls and how to avoid them

Prerequisites

  • Basic familiarity with the language

Modern C++ removed most reasons to write new and delete by hand. The standard library offers three smart pointers that cover almost every ownership pattern: unique_ptr, shared_ptr, and weak_ptr. They are not interchangeable. Pick the wrong one and you get either subtle leaks or unnecessary overhead.

Why smart pointers exist

A raw pointer says nothing about who owns the object it points at. Reading a function signature like void process(Widget* w) you cannot tell whether process should delete w, store it, or simply read from it. Memory bugs in C++ almost always come from this ambiguity.

Smart pointers encode ownership in the type system. A function that takes a unique_ptr<Widget> by value is saying, in compiler-enforced terms, that it is taking ownership.

unique_ptr

A unique_ptr<T> owns one object. It cannot be copied, only moved. When it goes out of scope, it deletes the object. Zero overhead compared to a raw pointer plus manual delete.

#include <memory>

auto w = std::make_unique<Widget>(42);
w->doSomething();
// w deletes the Widget automatically when it goes out of scope

Pass it by value to transfer ownership; pass the underlying reference (Widget&) when you only need to use it.

void consume(std::unique_ptr<Widget> w);   // takes ownership
void inspect(const Widget& w);             // borrows

Use unique_ptr whenever you have a single, clear owner. That is the vast majority of cases.

shared_ptr

A shared_ptr<T> owns an object jointly with other shared_ptr instances. Each copy increments a reference count, each destruction decrements it. The object is deleted when the count hits zero.

auto a = std::make_shared<Widget>(42);
auto b = a;  // ref count = 2
// both a and b destroyed -> Widget deleted

shared_ptr is not free. It carries a control block with reference counts (strong and weak), and the increment/decrement is atomic for thread safety. For high-frequency code, that overhead matters.

Use shared_ptr only when ownership is genuinely shared and the lifetime cannot be expressed as a tree. Otherwise, unique_ptr is better.

weak_ptr

A weak_ptr<T> observes a shared_ptr without contributing to its reference count. You cannot dereference it directly; you must call lock() to get a shared_ptr, which is null if the object has already been destroyed.

std::shared_ptr<Node> root = std::make_shared<Node>();
std::weak_ptr<Node> obs = root;

if (auto live = obs.lock()) {
    live->useIt();
} else {
    // already gone
}

The main reason to reach for weak_ptr is breaking reference cycles, especially in graphs and observer patterns.

The mental model

Think of the three as different relationships to the lifetime of an object.

unique_ptr : single owner   ----[T]
shared_ptr : co-owners       ====[T]====
                                 ^
weak_ptr   : observer (no own) - - +
Ownership relationships of the smart pointers

The ==== lines stand for strong references that keep the object alive. The dashed line is non-owning.

Reference cycles

shared_ptr cannot detect cycles. Two objects holding shared_ptr to each other will never be deleted.

struct Node {
    std::shared_ptr<Node> peer;  // BUG: forms a cycle
};

auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->peer = b;
b->peer = a;  // leak when a and b go out of scope

The fix: one direction is strong, the other is weak.

struct Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node>  prev;
};

A common convention is parent owns child via shared_ptr (or unique_ptr), and child observes parent via weak_ptr or a raw pointer.

Custom deleters

Sometimes you wrap a resource that is not freed with delete: a C API handle, a file descriptor, a CUDA buffer. Smart pointers accept a custom deleter.

auto file = std::unique_ptr<FILE, decltype(&std::fclose)>(
    std::fopen("data.txt", "r"), &std::fclose);

For shared_ptr, the deleter type does not become part of the smart pointer type, so heterogeneous deleters are easier. For unique_ptr, the deleter is in the type and lambdas or function pointers work.

Common pitfalls

Calling make_shared when you only need one owner. You pay for an atomic refcount you never use.

Storing this as a shared_ptr directly. The constructor does not know there is already a shared_ptr to the object, so you create a second control block. Use std::enable_shared_from_this.

Mixing raw new with make_shared. Always prefer make_unique and make_shared. They are exception-safe and avoid an extra allocation for the control block in the shared case.

Passing shared_ptr by value when by reference would do. If a function does not need to extend lifetime, take a const reference or a raw pointer/reference to the underlying type.

Practical tips

Default to unique_ptr. Move it where ownership changes. Reach for shared_ptr only when multiple owners are unavoidable. Use weak_ptr whenever you need to observe but not own. Treat raw pointers and references as non-owning borrows; they are still useful, just not for ownership.

Wrap-up

Smart pointers are not about avoiding delete. They are about making ownership visible. Once your function signatures honestly say who owns what, memory bugs become a matter of reading the types. Pick unique_ptr first, escalate only when necessary, and use weak_ptr to break cycles when shared ownership leads to them.