C++ shared_ptr vs unique_ptr: Ownership, Overhead, Thread Safety and When to Use Each
Key takeaways
shared_ptr vs unique_ptr: prefer unique_ptr by default; use shared_ptr for shared ownership. Reference counting cost, weak_ptr for cycles, and performance-minded patterns.
Why Smart Pointers?
Raw pointers in C++ do not communicate ownership. Who frees the memory? Who is allowed to use the pointer after a function call? These questions cause memory leaks, use-after-free, and double-free bugs.
Smart pointers from <memory> make ownership explicit and automatic:
// Raw pointer — ownership unclear
int* raw = new int(42);
delete raw; // remember to delete, no double-delete, no forget
// unique_ptr — single owner, deleted automatically
std::unique_ptr<int> u = std::make_unique<int>(42);
// deleted when u goes out of scope — no manual delete
// shared_ptr — reference counted, deleted when last owner is gone
std::shared_ptr<int> s = std::make_shared<int>(42);
auto s2 = s; // refcount = 2
// deleted when both s and s2 go out of scope
The difference between them is ownership semantics, not just syntax.
Ownership Models
unique_ptr: Exclusive Ownership
unique_ptr expresses that exactly one object owns the resource. You cannot copy it — only move it. When the unique_ptr is destroyed, the object is deleted.
#include <memory>
#include <iostream>
struct Widget {
int id;
Widget(int id) : id(id) { std::cout << "Widget " << id << " created\n"; }
~Widget() { std::cout << "Widget " << id << " destroyed\n"; }
};
int main() {
auto w = std::make_unique<Widget>(1); // created
// auto w2 = w; // compile error — cannot copy
auto w2 = std::move(w); // ownership transferred
// w is now null — w2 owns the Widget
if (!w) std::cout << "w is empty\n"; // w is empty
std::cout << "w2 owns Widget " << w2->id << '\n';
}
// Widget 1 destroyed — when w2 goes out of scope
This is the most common smart pointer — use it whenever a resource has a single clear owner.
The deleted copy constructor is the whole point. With a raw pointer, Widget* w2 = w; silently creates a second path to the same object, and nothing in the code says which one must delete it. With unique_ptr, the compiler refuses the copy (error: use of deleted function 'std::unique_ptr<...>::unique_ptr(const std::unique_ptr<...>&)'), and the only way to hand the object over is an explicit std::move, which leaves the source empty. A moved-from unique_ptr is guaranteed to be null, so the if (!w) check is well defined; dereferencing it is not, and is the usual source of crashes after a move.
shared_ptr: Shared Ownership
shared_ptr allows multiple owners. A reference count in the control block tracks how many shared_ptr objects point to the resource. The resource is deleted when the last shared_ptr to it is destroyed.
#include <memory>
#include <iostream>
int main() {
auto shared = std::make_shared<Widget>(2); // refcount = 1
std::cout << "count: " << shared.use_count() << '\n'; // 1
{
auto copy1 = shared; // refcount = 2
auto copy2 = shared; // refcount = 3
std::cout << "count: " << shared.use_count() << '\n'; // 3
} // copy1 and copy2 destroyed — refcount = 1
std::cout << "count: " << shared.use_count() << '\n'; // 1
}
// Widget 2 destroyed — last owner (shared) goes out of scope
use_count() is useful for demonstrations and debugging but should not drive program logic: in multi-threaded code the value can change between reading it and acting on it. The more important consequence of shared ownership is that when the object is destroyed is no longer visible in the code. It happens wherever the last copy happens to be released, which may be a different thread, inside a callback, or much later than expected because a cache or a lambda capture kept a copy. For objects that hold files, sockets or locks, that uncertainty is often a bigger problem than the reference-counting cost.
weak_ptr: Non-Owning Observer
weak_ptr observes a shared_ptr-managed object without participating in ownership. It does not keep the object alive.
#include <memory>
#include <iostream>
int main() {
std::shared_ptr<Widget> owner = std::make_shared<Widget>(3);
std::weak_ptr<Widget> observer = owner; // does not increment refcount
std::cout << "owner count: " << owner.use_count() << '\n'; // 1
// To use a weak_ptr, lock() it — returns shared_ptr or empty
if (auto locked = observer.lock()) {
std::cout << "Object still alive: " << locked->id << '\n';
}
owner.reset(); // Widget destroyed
if (observer.expired()) {
std::cout << "Object has been destroyed\n";
}
}
Always go through lock() to use the object. Checking expired() and then using the object is a check-then-act race in multi-threaded code: the last owner can release it between the two calls. lock() checks and takes a temporary shared ownership in one atomic step, so the returned shared_ptr either holds a live object for as long as you keep it, or is empty. expired() is fine for “is it still there?” questions where a stale answer is harmless, such as pruning dead entries from an observer list.
Overhead Comparison
unique_ptr has zero overhead compared to a raw pointer — it is the same size and often compiles to identical machine code:
unique_ptr<T>: [T*] — same size as a raw pointer
— no heap allocation for ownership tracking
— custom deleter encoded in the type
shared_ptr<T>: [T*, control*] — two pointers
— control block: refcount (atomic) + weakcount + deleter
— control block is a separate heap allocation unless make_shared
make_shared is more efficient than shared_ptr(new T(...)) because it allocates the object and control block in a single allocation:
// Two allocations: one for Widget, one for control block
std::shared_ptr<Widget> p1(new Widget(1));
// One allocation: Widget and control block together
std::shared_ptr<Widget> p2 = std::make_shared<Widget>(2);
The refcount updates in shared_ptr use atomic operations, which have measurable cost on multicore systems. In a tight loop copying and destroying shared_ptr, this can be slower than a similar unique_ptr workload. Profile before assuming it matters in your case.
Two qualifications to the table. The “same size as a raw pointer” claim holds for unique_ptr with the default deleter or any stateless deleter such as a captureless lambda; a function-pointer deleter (unique_ptr<FILE, int(*)(FILE*)>) stores that pointer too and doubles the size. And make_shared has a trade-off: because the object and the control block share one allocation, the object’s memory cannot be returned until the last weak_ptr is gone as well, even though the object’s destructor ran long before. For large objects observed by long-lived weak_ptrs (a cache, for example), constructing with shared_ptr<T>(new T(...)) keeps the memory separable.
The most common avoidable cost is passing shared_ptr by value to functions that only use the object. Each call increments and decrements the atomic count for nothing. Pass const T& or T* when the function does not keep the object, and const std::shared_ptr<T>& only when it might copy it conditionally.
Usage Patterns
Passing unique_ptr to Functions
// Sink: function takes ownership — pass by value, caller must move
void consume(std::unique_ptr<Widget> w) {
std::cout << "consuming " << w->id << '\n';
// w deleted when function returns
}
// Borrow: function uses but doesn't own — pass raw pointer or const reference
void inspect(const Widget& w) {
std::cout << "inspecting " << w.id << '\n';
}
void inspectPtr(const Widget* w) {
if (w) std::cout << "inspecting " << w->id << '\n';
}
int main() {
auto w = std::make_unique<Widget>(10);
inspect(*w); // borrow by reference — w still owns
inspectPtr(w.get()); // borrow by raw pointer — w still owns
consume(std::move(w)); // transfer ownership — w is now null
// w is null here
}
Factory Functions — Return unique_ptr
Factories that create objects should return unique_ptr. The caller can either keep it as unique_ptr, or upgrade to shared_ptr if needed:
std::unique_ptr<Widget> makeWidget(int id) {
return std::make_unique<Widget>(id); // clear: factory gives you ownership
}
int main() {
// Keep as unique_ptr
auto w = makeWidget(1);
// Upgrade to shared_ptr if shared ownership is needed later
std::shared_ptr<Widget> shared = makeWidget(2);
}
Custom Deleters
Both smart pointer types support custom deletion logic:
#include <cstdio>
#include <memory>
// unique_ptr with custom deleter — deleter is part of the type
auto fileCloser = [](FILE* f) { if (f) fclose(f); };
std::unique_ptr<FILE, decltype(fileCloser)> file(fopen("data.txt", "r"), fileCloser);
// shared_ptr with custom deleter — deleter is type-erased, not in the type
std::shared_ptr<FILE> sharedFile(fopen("data.txt", "r"), [](FILE* f) {
if (f) fclose(f);
});
The difference: unique_ptr’s deleter is part of the template type (visible to callers), while shared_ptr’s deleter is stored in the control block and erased from the type. shared_ptr is more flexible here — you can store different deleters in the same shared_ptr<FILE> container.
The if (f) checks are not equally necessary. unique_ptr never calls its deleter on a null pointer, so the check in fileCloser is redundant there. shared_ptr, on the other hand, does invoke the deleter when it was constructed with a null pointer and a deleter, so if fopen fails, the lambda receives nullptr and must not pass it to fclose. Either way, check whether the file actually opened before using it.
Fixing Circular Reference with weak_ptr
The most common shared_ptr bug is a reference cycle. Two objects pointing to each other with shared_ptr keep each other alive forever — memory leak:
#include <memory>
#include <iostream>
struct Node {
int value;
std::shared_ptr<Node> next; // shared_ptr to next
std::shared_ptr<Node> prev; // shared_ptr to previous — PROBLEM
Node(int v) : value(v) {}
~Node() { std::cout << "Node " << value << " destroyed\n"; }
};
int main() {
auto n1 = std::make_shared<Node>(1);
auto n2 = std::make_shared<Node>(2);
n1->next = n2;
n2->prev = n1; // cycle: n1 → n2 → n1
// End of main: n1 refcount = 1 (n2->prev), n2 refcount = 1 (n1->next)
// Neither reaches zero — LEAK. Neither destructor is called.
}
Fix: use weak_ptr on the back edge (the one that doesn’t need to keep the object alive):
struct Node {
int value;
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // weak_ptr — no ownership
Node(int v) : value(v) {}
~Node() { std::cout << "Node " << value << " destroyed\n"; }
};
int main() {
auto n1 = std::make_shared<Node>(1);
auto n2 = std::make_shared<Node>(2);
n1->next = n2;
n2->prev = n1; // weak_ptr — does not increment n1's refcount
// End of main: locals are destroyed in reverse order.
// n2 (stack) destroyed first: Node 2 refcount 2 → 1 (n1->next still owns it)
// n1 destroyed: Node 1 refcount 1 → 0 → Node 1 destroyed
// Node 1 releases next: Node 2 refcount 1 → 0 → Node 2 destroyed
}
// Output:
// Node 1 destroyed
// Node 2 destroyed
Cycles are the one kind of leak that smart pointers create rather than prevent, and they are easy to miss because no tool reports a “cycle” as such. LeakSanitizer and Valgrind report the nodes as leaked allocations, and the destructors that should print never run. The patterns that cause them are predictable: parent and child pointing at each other, observers that hold shared_ptrs to their subject, and lambdas that capture a shared_ptr to the object that stores the lambda. Deciding which direction owns (parent owns children, the subject does not own its observers) tells you which side becomes weak_ptr or a plain raw pointer.
Thread Safety
shared_ptr guarantees that reference count modifications (copying and destroying shared_ptr instances) are thread-safe. But the pointed-to object is not:
#include <iostream>
#include <memory>
#include <thread>
#include <mutex>
auto shared = std::make_shared<int>(0);
std::mutex mtx;
void increment() {
// shared_ptr copy/destroy is thread-safe
auto local = shared; // safe — atomic refcount increment
// BUT: modifying the pointed-to int is NOT thread-safe without synchronization
std::lock_guard<std::mutex> lock(mtx);
(*local)++;
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << *shared << '\n'; // 2 — correct with mutex
}
The guarantee is narrower than “shared_ptr is thread-safe”. Different threads may copy, destroy and reset their own shared_ptr objects that point to the same thing, because the shared control block is updated atomically. Here each thread copies the global shared into local while nobody writes to shared itself, which is allowed. What is not allowed is two threads accessing the same shared_ptr object when at least one of them modifies it, for example one thread doing shared = std::make_shared<int>(5); while another copies shared. That is an ordinary data race on the pointer object and undefined behavior. For a global pointer that is swapped at runtime (a configuration snapshot, say), use a mutex around the pointer or C++20’s std::atomic<std::shared_ptr<T>>.
Selection Guide
| Situation | Use |
|---|---|
| Single clear owner, resource returned from factory | unique_ptr |
| Multiple independent owners that outlive each other | shared_ptr |
| Observer: needs access but not ownership | weak_ptr or raw reference |
| Resource with custom cleanup (file, socket, lock) | unique_ptr with deleter |
| Doubly-linked structure, parent-child with back-pointer | shared_ptr forward, weak_ptr back |
| Polymorphic base class pointer | unique_ptr<Base> or shared_ptr<Base> |
| Container of heterogeneous owned objects | vector<unique_ptr<Base>> |
Default rule: reach for unique_ptr first. Upgrade to shared_ptr only when the use case genuinely requires shared ownership — when it’s difficult to define a single owner with a clear lifetime.
enable_shared_from_this
When an object managed by shared_ptr needs to create a new shared_ptr to itself (common in async callbacks), use enable_shared_from_this:
#include <memory>
#include <iostream>
class Session : public std::enable_shared_from_this<Session> {
public:
void start() {
// shared_from_this() returns a shared_ptr to this — safe
auto self = shared_from_this();
// pass self to async callback to keep Session alive
doAsync([self]() {
std::cout << "async work on Session\n";
});
}
template<typename F>
void doAsync(F f) { f(); }
};
int main() {
auto session = std::make_shared<Session>();
session->start();
}
Never call shared_from_this() in a constructor — the shared_ptr managing the object doesn’t exist yet.
Since C++17, calling shared_from_this() on an object that is not owned by any shared_ptr (a stack object, or one created with plain new, or from the constructor) throws std::bad_weak_ptr; before C++17 it was undefined behavior. The inheritance must also be public, otherwise make_shared cannot find the hidden weak_ptr and shared_from_this() fails the same way. The pattern exists because the naive alternative, std::shared_ptr<Session>(this), creates a second control block for the same object, and the object is then deleted twice. In asynchronous code such as Asio handlers, capturing self is what keeps a Session alive until its last pending callback has run; forgetting it is a classic source of use-after-free crashes when a connection closes while an operation is still in flight.
Ownership rules to default to
- Default to
unique_ptr— zero overhead, clear ownership, move-only semantics prevent accidental sharing - Use
shared_ptronly when ownership is genuinely shared with independently-scoped owners make_unique/make_sharedare preferred: exception-safe and (formake_shared) a single allocationweak_ptrbreaks reference cycles and allows non-owning observation — always lock before use- Thread safety:
shared_ptrcopy/destroy is thread-safe; the pointed-to object is not - Custom deleters:
unique_ptrencodes in the type;shared_ptrtype-erases it in the control block - Factory functions should return
unique_ptr— callers can upgrade toshared_ptrif needed
Frequently Asked Questions (FAQ)
Q. Can I start with unique_ptr and switch to shared_ptr later if ownership becomes shared?
A. Yes. A std::shared_ptr<T> can be constructed from a std::unique_ptr<T>&&, so std::shared_ptr<Widget> sp = std::move(up); or passing the result of a factory that returns unique_ptr both work, and the custom deleter is carried over. The reverse direction is impossible, because a shared_ptr cannot give up ownership that others may share. This is why factory functions return unique_ptr: the caller keeps the option to share. The converted pointer does get a separately allocated control block, which make_shared would have avoided.
Related Articles
- Finding C++ Memory Leaks: Valgrind and AddressSanitizer
- C++ Smart Pointers
- C++ Memory Leaks
- Shallow vs Deep Copy and Move Semantics