unique_ptr, shared_ptr & weak_ptr
Scenario
You create a `Node` class for a doubly-linked list using `std::shared_ptr` for both `next` and `prev` pointers. When the list goes out of scope, none of the Node destructors run, and the entire list leaks.
If smart pointers manage memory automatically, how did they cause a massive memory leak?
Mental model
`unique_ptr` is exclusive ownership (you own a car; when you die, the car is scrapped). `shared_ptr` is shared ownership via a sign-out sheet (when the last person signs out, the car is scrapped). If A signs out B, and B signs out A, the sheet never hits zero, and the car is never scrapped (a cyclic leak). `weak_ptr` is observing without signing the sheet.
Smart pointers (C++11) automate memory management via RAII, drastically reducing memory leaks. `std::unique_ptr` has exactly one owner and zero overhead. `std::shared_ptr` uses an atomic reference count to track multiple owners, which has a performance cost. `std::weak_ptr` breaks circular references in shared ownership graphs.
Explanation
In modern C++, you should almost never use raw `new` and `delete`.
**1. `std::unique_ptr` (Default choice)**
It guarantees exclusive ownership. It cannot be copied (the compiler explicitly `=delete`s its copy constructor to enforce this), only moved (transferring ownership). When it goes out of scope, it automatically calls `delete`. It has literally zero performance overhead compared to a raw pointer.
**2. `std::shared_ptr`**
It maintains a "control block" with a reference count. When you copy it, the count increments. When one goes out of scope, it decrements. When the count hits zero, it deletes the object. Because the count must be thread-safe, modifying it uses atomic operations, making it noticeably slower than `unique_ptr`. Note that while the reference count itself is thread-safe, the actual underlying object being pointed to is NOT thread-safe; reading or writing the payload across threads still requires a mutex. Only use `shared_ptr` when ownership *must* be shared.
**3. `std::weak_ptr`**
The trap of `shared_ptr` is the circular reference. If object A holds a `shared_ptr` to B, and B holds a `shared_ptr` to A, their ref-counts are both 1. When the main program drops them, their counts never hit 0, causing a memory leak. `std::weak_ptr` solves this: it points to a `shared_ptr` object but *does not* increase the reference count. It allows you to safely check if the object still exists (`lock()`) without forcing it to stay alive.
Code examples
The bug: Circular reference leak
#include <iostream>
#include <memory>
struct Node {
std::shared_ptr<Node> peer;
~Node() { std::cout << "Destroyed\n"; }
};
int main() {
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
// Create a cycle: A owns B, B owns A.
a->peer = b;
b->peer = a;
// Main scope ends. A drops B, B drops A? No.
// A's count is 2 (main + b). B's count is 2 (main + a).
// They drop to 1. Neither reaches 0. Memory leaks!
return 0;
}
Because both objects hold a strong reference to each other, the reference count will never reach zero. The destructors are completely bypassed.
The fix: std::weak_ptr
#include <iostream>
#include <memory>
struct Node {
// weak_ptr observes, but does not increase the reference count
std::weak_ptr<Node> peer;
~Node() { std::cout << "Destroyed\n"; }
};
int main() {
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->peer = b;
b->peer = a;
// A's count is 1. B's count is 1.
// Scope ends, counts hit 0. Destructors run perfectly.
return 0;
}
By changing the back-reference to a `weak_ptr`, we break the cycle. When the program needs to use the `peer`, it temporarily converts it to a `shared_ptr` using `peer.lock()`.
Key points
- unique_ptr by default: Always reach for `std::unique_ptr` first. Upgrade to `shared_ptr` only if absolutely necessary.
- Use make_* functions: Always construct using `std::make_unique` or `std::make_shared` instead of raw `new`.
Common mistakes
- Using new inside the smart pointer constructor: Writing `std::shared_ptr<int> p(new int(5))` requires two separate memory allocations (one for the integer, one for the control block). Always use `std::make_shared<int>(5)` and `std::make_unique<int>(5)`, which combine allocations for better performance and exception safety.
Recall questions
- Why should std::unique_ptr be preferred over std::shared_ptr whenever possible?
- What happens if you try to pass a std::unique_ptr to a function by value?
- How does std::weak_ptr solve the circular reference problem?
Questions & answers
A candidate writes `std::unique_ptr<int> p1 = std::make_unique<int>(10); std::unique_ptr<int> p2 = p1;`. What is the compiler error, and what is the fix?
The compiler errors because `unique_ptr` cannot be copied. If they want both pointers to read the value, they should pass a raw pointer or reference. If they want to transfer ownership, they must use `std::move(p1)`.
Approach: Understand that `unique_ptr` copy operations are `=delete`d.
In a multithreaded environment, is `std::shared_ptr` thread-safe?
The reference count mechanism itself is thread-safe (it uses atomic operations), so you can safely copy/destroy the pointers across threads. However, reading/writing the actual underlying object is NOT thread-safe and requires a mutex.
Approach: Differentiate between the safety of the control block vs the payload.