← Back to blog

C++ Smart Pointers Explained: unique_ptr, shared_ptr, and weak_ptr

C++ smart pointers are standard library types that manage dynamically allocated objects using RAII (Resource Acquisition Is Initialization). Instead of manually pairing new with delete, you express ownership in the type system, and cleanup happens automatically when the owner goes out of scope.

Prerequisites and scope

  • Assumes C++14+ (for std::make_unique).
  • Examples use <memory> (and occasionally <vector>, <cstdio>).
  • This post focuses on ownership/lifetime management. Raw pointers (T*) are still useful for non-owning references, array traversal, and C APIs.

Cheat sheet (anchor)

  • unique_ptr: owns (exclusive), no copies (moves only), use for a single clear owner.
  • shared_ptr: owns (shared), copies share ownership, use only for shared lifetime.
  • weak_ptr: non-owning observer of shared_ptr, break cycles / observe without extending lifetime.

Why smart pointers exist

Raw pointers (T*) are not inherently “bad”—they’re great for non-owning references and interoperability. The problem is owning raw pointers: it’s easy to leak memory, double-delete, or forget cleanup when exceptions occur.

Smart pointers address this by:

  • Making ownership explicit
  • Automatically releasing resources in destructors
  • Working correctly with exceptions (stack unwinding)

Rule of thumb: use raw pointers for non-owning access, smart pointers for owning relationships.

std::unique_ptr: exclusive ownership

std::unique_ptr<T> represents sole ownership of a T. There is exactly one unique_ptr responsible for deleting the object.

Key properties:

  • Not copyable, but movable
  • Very low overhead (often just a pointer, but a custom deleter can increase size)
  • Supports custom deleters
  • Ideal default choice for ownership

Creating a unique_ptr

Prefer std::make_unique (C++14+):

#include <memory>

auto p = std::make_unique<int>(42);

For a user-defined type:

struct Widget {
  void run();
};

auto w = std::make_unique<Widget>();
w->run();

Returning and moving a unique_ptr (move or elision)

Because it can’t be copied, transferring ownership uses move semantics. When you return a unique_ptr, it will be moved or elided (copy elision is allowed; a move is fine too).

std::unique_ptr<Widget> makeWidget() {
  return std::make_unique<Widget>(); // moved or elided
}

auto a = std::make_unique<Widget>();
auto b = std::move(a); // ownership transfers to b
// a is now nullptr

Reminder: after std::move(a), treat a as moved-from; for unique_ptr that means it’s typically nullptr. Avoid use-after-move.

Using unique_ptr in containers

unique_ptr works well in containers because it’s movable:

#include <vector>
#include <memory>

std::vector<std::unique_ptr<Widget>> widgets;
widgets.push_back(std::make_unique<Widget>());

Array ownership: prefer containers, but know the tool

If you need a dynamic array, prefer std::vector<T> (size-aware, rich API) or std::array<T, N> (fixed size).

If you truly need an owning pointer to a dynamically allocated array, use std::unique_ptr<T[]>:

#include <memory>

auto arr = std::make_unique<int[]>(10); // value-initialized elements
arr[0] = 123;

Notes:

  • std::unique_ptr<T> and std::unique_ptr<T[]> are different specializations.
  • make_unique<T[]>(n) allocates an array; you access elements with operator[].
  • Don’t use std::unique_ptr<T> to own new T[n].

Custom deleters (and why they matter)

Custom deleters are useful for resources not freed by delete (e.g., FILE*, OS handles, C allocations). Always handle acquisition failure (e.g., fopen can return nullptr).

#include <cstdio>
#include <memory>

struct FileCloser {
  void operator()(FILE* f) const noexcept {
    if (f) std::fclose(f);
  }
};

using FilePtr = std::unique_ptr<FILE, FileCloser>;

FilePtr openFile(const char* path) {
  // If fopen fails, returns nullptr; unique_ptr will hold nullptr safely.
  return FilePtr(std::fopen(path, "r"));
}

Deleter details to be aware of:

  • The deleter type is part of the unique_ptr type. A stateless functor deleter often has no size overhead (can be empty-base optimized), while a function pointer deleter typically adds storage.
  • Changing deleter types can affect ABI and object size—important at library boundaries.

Transition: when unique ownership isn’t enough

Start with unique_ptr whenever there’s a clear owner. If you discover you truly need multiple parts of the program to co-own the same object (shared lifetime), that’s when you “upgrade” to shared_ptr.

std::shared_ptr: shared ownership (reference counting)

std::shared_ptr<T> represents shared ownership: multiple shared_ptrs can point to the same object, and the object is deleted when the last owning shared_ptr is destroyed.

Internally, shared_ptr uses a control block that stores:

  • Strong reference count (owners)
  • Weak reference count (observers)
  • Deleter/allocator information

Takeaway: the object is destroyed when strong_count == 0. The control block is destroyed only when both strong_count == 0 and weak_count == 0.

Creating a shared_ptr

Prefer std::make_shared:

#include <memory>

auto sp = std::make_shared<std::string>("hello");

Benefits of make_shared:

  • Typically one allocation for control block + object (better locality)
  • Exception safety

Tradeoff to know:

  • Because the control block and object are often allocated together, the object’s memory may be kept alive until the control block is freed (e.g., while weak_ptrs still exist), even though the object has been destroyed. This can matter for very large objects or memory-sensitive code.
  • Custom allocator / allocation strategies may also push you away from make_shared in some designs.

Copying increments the count

auto a = std::make_shared<int>(7);
auto b = a; // shares ownership; strong count increases

When a and b both go out of scope, the managed int is deleted.

Thread-safety note

  • Operations that adjust the reference counts are generally implemented to be thread-safe (often via atomics; exact details are implementation-defined).
  • The pointed-to object itself is not automatically synchronized. If multiple threads access/modify *sp, you still need your own synchronization.

When shared_ptr is a good fit

Use shared_ptr when you truly have shared lifetime:

  • Objects owned by multiple subsystems without a clear single owner
  • Graph-like structures (but watch for cycles)
  • Caching scenarios where values can outlive the caller

If there is a clear owner, prefer unique_ptr and pass raw pointers/references for non-owning access.

Common pitfall: constructing multiple shared_ptr from the same raw pointer

This is a classic bug:

T* raw = new T;
std::shared_ptr<T> a(raw);
std::shared_ptr<T> b(raw); // BUG: two control blocks => double delete

Instead, create one shared_ptr and copy it:

auto a = std::make_shared<T>();
auto b = a; // OK

If you must start from an existing raw pointer, do it once, with a clear deleter, and distribute copies:

T* raw = /* obtained from a C API or legacy code */;

// Create exactly one owner control block for this raw pointer:
auto a = std::shared_ptr<T>(raw, [](T* p){ delete p; });

// Now share ownership by copying:
auto b = a;

Warning: only do this if no other owner will delete raw. Mixing ownership models (some code calls delete, other code uses shared_ptr) is a recipe for double-free.

enable_shared_from_this

If an object needs to create a shared_ptr to itself (e.g., handing out shared ownership from a member function), use std::enable_shared_from_this.

#include <memory>

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

auto n = std::make_shared<Node>();
auto n2 = n->getptr(); // shares ownership correctly

Key precondition:

  • The object must already be owned by a shared_ptr (e.g., created via make_shared or otherwise placed under shared_ptr ownership) before calling shared_from_this(). Otherwise it throws std::bad_weak_ptr (or can be undefined behavior in some misuses).

Do not create std::shared_ptr(this) inside a member function—this creates a second control block.

Transition: shared ownership creates a new problem—cycles

Once you start sharing ownership, it’s easy to create reference cycles (especially in graphs, parent/child relations, and observer patterns). That’s where weak_ptr comes in.

std::weak_ptr: non-owning observer of shared_ptr

std::weak_ptr<T> is a companion to shared_ptr. It observes an object managed by shared_ptr without increasing the strong reference count.

Why this matters:

  • It helps break reference cycles
  • It lets you safely check whether an object still exists

Takeaway: weak_ptr does not keep the object alive. Use lock() to get a temporary owning shared_ptr while you access the object.

Creating and using weak_ptr

std::shared_ptr<Widget> sp = std::make_shared<Widget>();
std::weak_ptr<Widget> wp = sp; // does not extend lifetime

sp.reset(); // object may be destroyed here

if (auto locked = wp.lock()) {
  // object is still alive; locked is a shared_ptr
  locked->run();
} else {
  // object expired
}

Important APIs:

  • wp.lock() gives a shared_ptr if alive, else an empty shared_ptr
  • wp.expired() tells you if the object is gone, but in multi-threaded code it can race; prefer the check-and-use pattern via lock().

Breaking cycles with weak_ptr (real-world pattern)

Two objects that hold shared_ptr to each other form a cycle, so they are never destroyed because strong counts never reach zero.

Common real-world examples:

  • Parent/child relationships: parent owns children; child references parent (should be weak)
  • Observer pattern: subject owns observers; observers reference subject (often weak)

Cycle bug:

struct B;
struct A { std::shared_ptr<B> b; };
struct B { std::shared_ptr<A> a; };

auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->b = b;
b->a = a;
// Leak via cycle: strong counts never reach zero

Fix by making one side weak_ptr:

struct B;
struct A { std::shared_ptr<B> b; };
struct B { std::weak_ptr<A> a; };

Takeaway: make at least one direction of a bidirectional relationship non-owning to allow cleanup.

Choosing the right smart pointer

A practical decision guide:

  • Use unique_ptr by default for ownership.
  • Use shared_ptr only when you need shared lifetime.
  • Use weak_ptr to observe a shared_ptr without owning (especially to break cycles).

Decision table

SituationRecommended typeNotes
One clear ownerstd::unique_ptr<T>Cheapest owning option; move to transfer ownership
Many parts must co-own lifetimestd::shared_ptr<T>More overhead; watch for cycles
Need a back-pointer / observer without extending lifetimestd::weak_ptr<T>Use lock() to access safely
Non-owning access onlyT*, T&, std::span<T>Don’t store ownership in raw pointers

Passing smart pointers to functions (value vs reference)

A good rule is: pass ownership types by value only when the function participates in ownership.

  • Function does not take ownership: prefer T&, const T&, or T* (non-owning)
  • Function takes ownership exclusively: take std::unique_ptr<T> by value
  • Function may share/store ownership: take std::shared_ptr<T> by value
  • Function only needs to observe a shared object: take std::weak_ptr<T> (or sometimes const std::shared_ptr<T>& if you just need to read and don’t want to increment counts)

Examples:

void use(const Widget& w);                 // no ownership
void take(std::unique_ptr<Widget> w);      // takes ownership
void store(std::shared_ptr<Widget> w);     // shares/stores ownership
void observe(std::weak_ptr<Widget> w);     // observes without owning

Common anti-patterns

  • “Just use shared_ptr everywhere”: increases overhead and can hide unclear ownership design.
  • Aliasing/ownership confusion: multiple independent owners of the same raw pointer (double-delete territory).
  • Using new with smart pointers by default: prefer make_unique / make_shared for exception safety and clarity (use new only when you have a specific reason).

Performance and correctness notes

  • unique_ptr is usually as cheap as a raw pointer, but a non-empty deleter can increase its size.
  • shared_ptr has overhead: reference counting (commonly atomic for thread-safety), control block management, and potential contention.
  • Prefer make_unique / make_shared for safety and efficiency, but remember the make_shared lifetime/allocation tradeoff.
  • Avoid shared_ptr cycles; use weak_ptr to break them.
  • Don’t return or store owning raw pointers; make ownership explicit.

Summary

Smart pointers are about expressing ownership:

  • std::unique_ptr: one owner, movable, best default
  • std::shared_ptr: multiple owners, reference counted, use when lifetimes are genuinely shared
  • std::weak_ptr: non-owning observer to safely reference shared objects and break cycles

A final std::move reminder: moving a unique_ptr transfers ownership and leaves the source empty (typically nullptr). Treat moved-from pointers as invalid for use except for checks/reset.

References