Smart Pointers — Ownership Without the Pain
The Problem with Raw Pointers
Raw pointers (new / delete) are the single biggest source of memory bugs in C++: leaks, double-frees, dangling references. Smart pointers automate ownership and cleanup via RAII.
Think of raw pointers like handing someone a hotel room key with no checkout system. Who's responsible for returning it? What if two people each think they should return it? What if nobody does? Every one of those scenarios maps to a real bug:
- Memory leak, nobody calls
delete. The memory is allocated but never freed. - Double free, two places call
deleteon the same pointer. Undefined behavior, usually a crash. - Dangling pointer, you use memory after it's been freed. Silent corruption or crash.
// Bad: who deletes this?
Widget* w = new Widget();
process(w);
// Did process() take ownership? Is w still valid?
// Forgot delete → leak. Double delete → crash.
std::unique_ptr: Exclusive Ownership
unique_ptr owns a resource exclusively. It cannot be copied, only moved. When it goes out of scope, it automatically deletes the managed object. This is the C++ equivalent of saying: "exactly one variable is responsible for this memory, and when that variable dies, the memory is freed."
The key mental model: unique_ptr is like a physical key to a locker, there's only one key. You can hand it to someone else (std::move), but you can't duplicate it. When the last holder drops it, the locker is cleaned up.
#include <memory>
auto w = std::make_unique<Widget>(42);
process(*w); // pass by reference
take_ownership(std::move(w)); // transfers ownership
// w is now nullptr
▶ unique_ptr Ownership Transfer
Watch exclusive ownership move from one unique_ptr to another.
unique_ptr for Arrays
You can also use unique_ptr to manage dynamically allocated arrays. The specialization unique_ptr<T[]> automatically calls delete[] instead of delete, preventing undefined behavior:
auto arr = std::make_unique<int[]>(100);
arr[0] = 42;
// automatically calls delete[] when arr goes out of scope
Custom Deleters
Not everything is freed with delete. File handles need fclose(), C library objects might need a specialized cleanup function, and GPU buffers need their own deallocation. Custom deleters let unique_ptr manage any resource, not just heap memory:
auto file_closer = [](FILE* f) { if (f) fclose(f); };
std::unique_ptr<FILE, decltype(file_closer)> fp(
fopen("data.txt", "r"), file_closer
);
unique_ptr. It has zero overhead compared to a raw pointer. Only reach for shared_ptr when you genuinely need shared ownership.
std::shared_ptr: Shared Ownership
shared_ptr uses reference counting to allow multiple pointers to share ownership of the same object. Each time you copy a shared_ptr, an internal counter increments. Each time a shared_ptr is destroyed or reset, the counter decrements. When the count reaches zero, the object is deleted.
Think of it like a shared Netflix account, as long as at least one person is subscribed, the account stays active. The moment the last person unsubscribes, it's gone.
auto a = std::make_shared<Widget>(42);
auto b = a; // ref count = 2
b.reset(); // ref count = 1
// Widget destroyed when 'a' goes out of scope
The Cost of shared_ptr
Shared ownership isn't free. Here's what you pay compared to unique_ptr:
- Control block — heap-allocated metadata (ref count, weak count, deleter).
make_sharedmerges the control block and object into one allocation. - Atomic ref counting — thread-safe increment/decrement, which has a small but measurable overhead.
- Size — two pointers (object + control block) vs one for
unique_ptr.
std::weak_ptr: Breaking Cycles
weak_ptr is a non-owning observer of a shared_ptr's resource. It can see whether the resource still exists, but it doesn't keep it alive, it doesn't increment the reference count.
The classic use case is breaking circular references. If object A holds a shared_ptr to B, and B holds a shared_ptr to A, neither object's reference count will ever reach zero, they keep each other alive forever. By making one of those pointers a weak_ptr, you break the cycle and allow proper cleanup.
Other use cases include caches (observe an object without preventing its deletion) and parent-back-pointers in tree/graph structures.
struct Node {
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // weak to break cycle
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->prev = a; // if this were shared_ptr, neither node would be freed
▶ Circular Reference & weak_ptr Fix
See how shared_ptr cycles cause leaks, and how weak_ptr breaks them.
Accessing a weak_ptr
Since a weak_ptr doesn't own the resource, the object could be deleted at any time. You can't dereference it directly. Instead, call .lock() to attempt to get a shared_ptr. If the object still exists, you get a valid pointer; if it's been destroyed, you get nullptr:
std::weak_ptr<Widget> weak = some_shared;
if (auto locked = weak.lock()) {
// locked is a valid shared_ptr
locked->doWork();
} else {
// object has been destroyed
}
Common Pitfalls
Smart pointers eliminate most memory bugs, but they introduce their own traps if misused. Here are the ones that bite most often:
Creating shared_ptr from raw pointer twice
Each shared_ptr constructed directly from a raw pointer creates its own independent control block. If two shared_ptrs point to the same raw pointer but have different control blocks, each thinks it's the sole manager, resulting in a double free when they both try to delete the same memory:
Widget* raw = new Widget();
std::shared_ptr<Widget> a(raw);
std::shared_ptr<Widget> b(raw); // ⚠️ Two control blocks → double free!
// Fix: always use make_shared, or copy the shared_ptr
auto a = std::make_shared<Widget>();
auto b = a; // shares the same control block
Sharing this
Sometimes a class method needs to hand out a shared_ptr to itself (e.g., for callbacks or registering with an event system). You can't just do shared_ptr<T>(this), that creates a new control block and leads to double free. The solution is to inherit from std::enable_shared_from_this, which stores a weak_ptr internally and provides shared_from_this():
class Server : public std::enable_shared_from_this<Server> {
public:
std::shared_ptr<Server> getPtr() {
return shared_from_this();
}
};
Circular References
This is the most subtle pitfall. Two shared_ptrs pointing at each other keep both objects alive forever, the reference count never reaches zero. No error, no crash, just silently leaked memory that grows over time. The fix: use weak_ptr on at least one side of the cycle. A good rule of thumb: parent owns child with shared_ptr, child references parent with weak_ptr.
Which Smart Pointer?
- Exclusive ownership, no sharing →
unique_ptr(default choice) - Multiple owners, shared lifetime →
shared_ptr - Observing without owning (cache, back-pointers) →
weak_ptr - Non-owning access within a single scope → raw pointer or reference
Prefer make_unique / make_shared
Always prefer the make_* factory functions over constructing smart pointers with new. Three reasons:
- Exception-safe (no leak if constructor throws between
newand smart pointer construction). make_sharedfuses object + control block into one allocation.- Cleaner syntax:
auto p = std::make_unique<T>(args...).
Summary
- Default to
unique_ptr— zero overhead, exclusive ownership. - Use
shared_ptronly when shared ownership is genuinely needed. - Use
weak_ptrto observe shared resources and break reference cycles. - Always use
make_unique/make_shared. - Never create two
shared_ptrs from the same raw pointer.