C++ Smart Pointers: unique_ptr, shared_ptr & weak_ptr Explained
Table of Contents
If you followed the previous lesson on dynamic memory, you now know the raw new/delete dance. You allocate on the heap, you track the pointer, you free it manually — and somewhere between an early return and an unexpected exception, you leak memory. Every single time. It’s not a question of if but when.
Smart pointers, introduced in C++11, eliminate this entire class of bugs. They wrap a raw pointer inside an object that automatically calls delete when it’s no longer needed. No manual cleanup. No forgotten delete. No double-free nightmares. This lesson covers the three smart pointers you’ll use in every serious C++ project: std::unique_ptr, std::shared_ptr, and std::weak_ptr.
Why Smart Pointers Exist
Consider this innocent-looking function:
#include <iostream>
#include <stdexcept>
void processData() {
int* data = new int[1000];
// ... some work ...
if (/* error condition */ true) {
throw std::runtime_error("Something broke");
// data is LEAKED — delete[] never runs
}
delete[] data;
}
The exception unwinds the stack, destroys local variables, and jumps to the nearest catch block. But data is a raw pointer — a plain integer holding a memory address. Destroying it does nothing to the heap memory it points to. That memory is gone forever (until the program exits). Multiply this across a long-running server and you’ve got a slow, silent catastrophe.
Smart pointers solve this by tying the lifetime of heap memory to the lifetime of a stack object. When the stack object dies — whether by normal scope exit, early return, or exception — it runs its destructor and frees the memory. Always.
The RAII Principle
This pattern has a name: RAII — Resource Acquisition Is Initialization. The idea is simple: acquire a resource (memory, file handle, network socket) inside a constructor, and release it inside the destructor. Since C++ guarantees that destructors run when objects leave scope, the resource is always cleaned up. If you’ve worked with classes and objects, you’ve already used RAII without knowing it — every std::string and std::vector you’ve created manages its own memory this way.
Smart pointers are RAII wrappers specifically for heap-allocated memory. They live in the <memory> header. Let’s start with the one you should reach for first.
std::unique_ptr — Exclusive Ownership
A std::unique_ptr owns its pointed-to resource exclusively. There is exactly one unique_ptr pointing to any given object at any time. When that unique_ptr is destroyed, the object is deleted. No reference counting, no overhead — it’s essentially free compared to a raw pointer. The cppreference documentation confirms that unique_ptr has zero size overhead when using the default deleter.
Basic Usage
#include <iostream>
#include <memory>
struct Sensor {
std::string name;
double reading;
Sensor(std::string n, double r) : name(std::move(n)), reading(r) {
std::cout << "Sensor " << name << " created\n";
}
~Sensor() {
std::cout << "Sensor " << name << " destroyed\n";
}
};
int main() {
// Create a unique_ptr using make_unique (preferred)
auto sensor = std::make_unique<Sensor>("Temperature", 23.5);
// Access members with -> just like a raw pointer
std::cout << sensor->name << ": " << sensor->reading << "\n";
// Dereference with * also works
Sensor& ref = *sensor;
std::cout << "Via ref: " << ref.reading << "\n";
// Check if it holds an object
if (sensor) {
std::cout << "sensor is valid\n";
}
// Release ownership (returns raw pointer, unique_ptr becomes null)
Sensor* raw = sensor.release();
std::cout << "After release, sensor is "
<< (sensor ? "valid" : "null") << "\n";
delete raw; // We own it now, we must delete it
// Reset destroys the held object and optionally takes a new one
auto s2 = std::make_unique<Sensor>("Pressure", 101.3);
s2.reset(); // Sensor destroyed here
std::cout << "s2 is " << (s2 ? "valid" : "null") << "\n";
return 0;
}
// Output:
// Sensor Temperature created
// Temperature: 23.5
// Via ref: 23.5
// sensor is valid
// After release, sensor is null
// Sensor Pressure created
// Sensor Pressure destroyed
// s2 is null
Notice how s2 automatically destroys the Pressure sensor when reset() is called — and if we hadn’t called reset(), it would have been destroyed at the closing brace of main().
Move Semantics with unique_ptr
Since unique_ptr enforces exclusive ownership, it cannot be copied. Trying to copy one is a compile error. But it can be moved — transferring ownership from one unique_ptr to another. This connects directly to the copy constructors and move semantics you’ve studied.
#include <iostream>
#include <memory>
int main() {
auto original = std::make_unique<std::string>("Hello, Smart World!");
// auto copy = original; // COMPILE ERROR: unique_ptr cannot be copied
// Transfer ownership via std::move
auto moved = std::move(original);
std::cout << "original is " << (original ? "valid" : "null") << "\n";
std::cout << "moved holds: " << *moved << "\n";
// Pass to a function by value (transfers ownership)
auto consume = [](std::unique_ptr<std::string> ptr) {
std::cout << "Consumed: " << *ptr << "\n";
// ptr destroyed at end of lambda — string freed
};
consume(std::move(moved));
std::cout << "moved is " << (moved ? "valid" : "null") << "\n";
return 0;
}
// Output:
// original is null
// moved holds: Hello, Smart World!
// Consumed: Hello, Smart World!
// moved is null
After a move, the source unique_ptr is null. Accessing it would be undefined behavior. Always check or simply don’t use the source after moving.
Factory Functions Returning unique_ptr
One of the most powerful patterns in modern C++ is a factory function that returns unique_ptr. The caller gets ownership, and the compiler applies copy elision (RVO) so there’s no performance cost.
#include <iostream>
#include <memory>
#include <string>
class Logger {
std::string filename;
public:
Logger(std::string fname) : filename(std::move(fname)) {
std::cout << "Logger opened: " << filename << "\n";
}
~Logger() { std::cout << "Logger closed: " << filename << "\n"; }
void log(const std::string& msg) {
std::cout << "[" << filename << "] " << msg << "\n";
}
};
// Factory function — caller receives exclusive ownership
std::unique_ptr<Logger> createLogger(const std::string& name) {
return std::make_unique<Logger>(name);
}
int main() {
auto logger = createLogger("app.log");
logger->log("Application started");
logger->log("Processing data...");
// Logger automatically closed when main() exits
return 0;
}
This is the recommended way to write factory functions in modern C++. The C++ Core Guidelines (F.26) explicitly recommend returning unique_ptr from factories.
std::shared_ptr — Shared Ownership
Sometimes multiple parts of your program genuinely need to share ownership of a resource. A cache that hands out objects to multiple consumers. An observer pattern where multiple observers reference the same subject. This is where std::shared_ptr steps in.
A shared_ptr uses reference counting: an internal counter tracks how many shared_ptr instances point to the same object. Every copy increments the count; every destruction decrements it. When the count hits zero, the object is deleted.
Reference Counting in Action
#include <iostream>
#include <memory>
struct Connection {
int id;
Connection(int i) : id(i) { std::cout << "Connection " << id << " opened\n"; }
~Connection() { std::cout << "Connection " << id << " closed\n"; }
};
int main() {
auto conn = std::make_shared<Connection>(42);
std::cout << "use_count: " << conn.use_count() << "\n"; // 1
{
auto conn2 = conn; // Copy — both share ownership
std::cout << "use_count: " << conn.use_count() << "\n"; // 2
auto conn3 = conn;
std::cout << "use_count: " << conn.use_count() << "\n"; // 3
}
// conn2 and conn3 destroyed — count drops to 1
std::cout << "use_count: " << conn.use_count() << "\n"; // 1
return 0;
}
// Connection 42 closed — printed when conn is destroyed at the end of main
The reference counting is thread-safe (the count itself uses atomic operations), but accessing the pointed-to object from multiple threads still requires your own synchronization. See the cppreference shared_ptr page for the precise thread-safety guarantees.
Cost: shared_ptr is heavier than unique_ptr. It allocates a separate control block for the reference count (unless you use make_shared, which combines them). Every copy and destruction touches the atomic counter. Don’t use it when unique_ptr would suffice.
std::weak_ptr — Breaking Circular References
shared_ptr has an Achilles heel: circular references. If object A holds a shared_ptr to B, and B holds a shared_ptr to A, neither reference count ever reaches zero. Both objects leak permanently.
std::weak_ptr solves this. It observes a shared_ptr-managed object without incrementing the reference count. To actually use the object, you must call lock(), which returns a shared_ptr if the object still exists, or an empty one if it’s been destroyed.
#include <iostream>
#include <memory>
#include <string>
struct Employee;
struct Team {
std::string name;
std::vector<std::shared_ptr<Employee>> members;
Team(std::string n) : name(std::move(n)) {}
~Team() { std::cout << "Team " << name << " destroyed\n"; }
};
struct Employee {
std::string name;
std::weak_ptr<Team> team; // weak_ptr breaks the cycle!
Employee(std::string n) : name(std::move(n)) {}
~Employee() { std::cout << "Employee " << name << " destroyed\n"; }
void showTeam() {
// lock() converts weak_ptr to shared_ptr (may be null)
if (auto t = team.lock()) {
std::cout << name << " is on team " << t->name << "\n";
} else {
std::cout << name << "'s team no longer exists\n";
}
}
};
int main() {
auto devTeam = std::make_shared<Team>("Backend");
auto alice = std::make_shared<Employee>("Alice");
auto bob = std::make_shared<Employee>("Bob");
// Team owns employees (shared_ptr)
devTeam->members.push_back(alice);
devTeam->members.push_back(bob);
// Employees observe team (weak_ptr — no ownership)
alice->team = devTeam;
bob->team = devTeam;
alice->showTeam(); // Alice is on team Backend
// Check if weak_ptr's target is still alive
std::cout << "team expired? " << alice->team.expired() << "\n"; // 0 (false)
return 0;
}
// All objects properly destroyed — no leaks
If Employee::team were a shared_ptr instead of weak_ptr, the Team and Employee objects would hold each other alive forever. The weak_ptr documentation explains the full API, including expired() and lock().
make_unique and make_shared
You should almost always create smart pointers with std::make_unique (C++14) and std::make_shared (C++11) instead of calling new directly. Two reasons:
- Exception safety:
std::shared_ptr<Widget>(new Widget)involves two separate steps — thenewallocation and theshared_ptrconstruction. If something throws between them, you leak.make_shareddoes both atomically. - Performance:
make_sharedallocates the object and the control block in a single memory allocation instead of two, improving cache locality and reducing allocator overhead. Herb Sutter’s GotW #89 covers this in detail.
#include <memory>
struct Config {
int timeout;
std::string host;
Config(int t, std::string h) : timeout(t), host(std::move(h)) {}
};
int main() {
// Preferred — safe, efficient, no "new" keyword
auto cfg1 = std::make_unique<Config>(30, "localhost");
auto cfg2 = std::make_shared<Config>(60, "api.sudoflare.com");
// Avoid — less safe, potentially less efficient
std::unique_ptr<Config> cfg3(new Config(30, "localhost"));
std::shared_ptr<Config> cfg4(new Config(60, "api.sudoflare.com"));
// Arrays with make_unique (C++14)
auto arr = std::make_unique<int[]>(100); // array of 100 ints
arr[0] = 42;
return 0;
}
Custom Deleters
Sometimes you’re not managing memory allocated with new. Maybe you have a C library that uses malloc/free, or a handle-based API like file descriptors or database connections. Smart pointers support custom deleters — functions that run instead of delete when the resource is released.
#include <iostream>
#include <memory>
#include <cstdio>
#include <cstdlib>
int main() {
// Custom deleter for FILE* — calls fclose instead of delete
auto fileDeleter = [](FILE* f) {
if (f) {
std::cout << "Closing file\n";
std::fclose(f);
}
};
{
std::unique_ptr<FILE, decltype(fileDeleter)> file(
std::fopen("test.txt", "w"), fileDeleter
);
if (file) {
std::fputs("Hello from smart pointer!\n", file.get());
}
} // fclose called automatically here
// Custom deleter for malloc'd memory
auto mallocDeleter = [](int* p) {
std::cout << "Freeing malloc'd memory\n";
std::free(p);
};
std::unique_ptr<int, decltype(mallocDeleter)> data(
static_cast<int*>(std::malloc(sizeof(int) * 10)),
mallocDeleter
);
data.get()[0] = 99;
// shared_ptr custom deleter is simpler syntactically
std::shared_ptr<FILE> sharedFile(
std::fopen("log.txt", "w"),
[](FILE* f) { if (f) std::fclose(f); }
);
return 0;
}
Custom deleters make smart pointers universal resource managers — not just memory managers. Anything that has an “acquire” and “release” step can be wrapped.
Ownership Guidelines: Which Pointer When?
The C++ Core Guidelines are clear on this hierarchy:
| Pointer Type | When to Use | Overhead |
|---|---|---|
std::unique_ptr |
Default choice. Single owner. | Zero (same as raw pointer) |
std::shared_ptr |
Genuinely shared ownership. Multiple owners. | Control block + atomic ref counting |
std::weak_ptr |
Observing a shared_ptr without owning. Breaking cycles. | Same control block as shared_ptr |
| Raw pointer / reference | Non-owning observation. Function parameters. | Zero |
If you’re passing a pointer to a function that doesn’t need to own the resource, pass a raw pointer or reference — not a smart pointer. Smart pointers express ownership, not just “I have an address.” As explained in guideline F.7: use T* or T& for non-owning “use” parameters.
Common Mistakes and Pitfalls
Even with smart pointers, there are ways to shoot yourself in the foot. Here are the most common traps:
1. Creating two smart pointers from the same raw pointer:
int* raw = new int(42);
std::unique_ptr<int> a(raw);
std::unique_ptr<int> b(raw); // DISASTER: double delete
Both think they own the memory. Both will delete it. Undefined behavior. Always use make_unique/make_shared instead of passing raw pointers around.
2. Using a unique_ptr after moving it:
auto ptr = std::make_unique<int>(10);
auto other = std::move(ptr);
std::cout << *ptr; // UNDEFINED BEHAVIOR: ptr is null
3. Circular references with shared_ptr (no weak_ptr):
struct Node {
std::shared_ptr<Node> next; // If next points back to this... leak
};
Use weak_ptr for any back-pointer, parent pointer, or observer relationship.
4. Calling .get() and storing the raw pointer long-term:
int* dangerous = myUniquePtr.get();
myUniquePtr.reset(); // Object deleted
*dangerous = 5; // UNDEFINED BEHAVIOR: dangling pointer
The .get() method is for temporary, non-owning access — like passing to a C API. Never store the result beyond the smart pointer’s lifetime.
5. Using shared_ptr when unique_ptr would do:
It’s tempting to use shared_ptr everywhere because it’s more “flexible.” Don’t. The atomic reference counting has real overhead, and it obscures the actual ownership model. Start with unique_ptr. Switch to shared_ptr only when the compiler or your design forces you to.
Understanding these pitfalls goes hand-in-hand with understanding pointer fundamentals — if you’re fuzzy on raw pointers, revisit that lesson first.
Key Takeaways
- Smart pointers automate memory management using RAII. When they go out of scope, they free the resource — even during exceptions.
unique_ptris your default. Zero overhead, exclusive ownership, move-only. Use it unless you have a specific reason not to.shared_ptris for shared ownership. Multiple pointers can share a resource via reference counting. The last one standing callsdelete.weak_ptrbreaks circular references. It observes without owning. Calllock()to get a temporaryshared_ptr.- Use
make_uniqueandmake_sharedinstead of rawnew. They’re safer (exception-safe) andmake_sharedis faster (single allocation). - Custom deleters let smart pointers manage any resource — files, sockets, C library handles — not just
new‘d memory. - Non-owning access uses raw pointers or references. Don’t pass smart pointers to functions that don’t need ownership.
- Never create two smart pointers from the same raw pointer. Never use a
unique_ptrafter moving it. Never store a.get()result long-term.
Smart pointers are the single most impactful feature modern C++ gives you for writing safe, leak-free code. Master them and you’ll eliminate an entire category of bugs that has plagued C++ programmers for decades. In the next lesson, we’ll build on this foundation with move semantics in depth — understanding exactly how std::move works and when the compiler does it for you.