1. Introduction
When you first learn about shared_ptr, your brain happily concludes: “Oh! Now I never have to think about object lifetimes again!”. It's a bit like buying a big refrigerator and deciding you no longer need to worry about food expiration dates. You still have to think — the problems just become more expensive and interesting.
The reason is that shared_ptr solves one problem: “when to delete an object if there are multiple owners”. But it does not make your design automatically correct. Moreover, if you start “smearing” shared_ptr everywhere out of fear, you'll get a new class of bugs: ownership cycles, unexpected lifetime extensions, unpredictable dependencies and APIs where it's hard to understand who is responsible for what.
Therefore we need a project-level convention: by default use unique_ptr, and switch to shared_ptr only when shared ownership truly makes sense. And that reason must not be “I feel safer”, but “the program requirements really imply several independent owners”.
Why “switch to shared_ptr to avoid thinking” is a dangerous bargain
Imagine a typical scenario: you returned a Task* to the outside, the user picked a task, and then the storage deleted the task — and that pointer became dangling. A newcomer thinks: “Oh! We should use shared_ptr, then the object will live as long as someone references it.”
Sometimes that does solve the problem, but the price can be higher than the problem itself. Because you changed the architecture: now tasks live “as long as someone somewhere holds them”. This can lead to surprising effects: you removed a task from storage, yet it keeps living because there is a copy of shared_ptr somewhere in the UI. And the user wonders: “Why does it still exist?”
In other words, shared_ptr is not a “fix for dangling pointer”. It's a move to a different object-lifetime model. Sometimes needed, sometimes accidentally chosen.
2. A quick role model: owner, co-owner and observer
Before choosing a pointer it helps to ask yourself one question: what role does this reference to the object play? Three words are enough: owner, co-owner, observer.
An owner is responsible for the lifetime. While it lives — the object is guaranteed to be alive. When the owner dies — the object should be destroyed (or ownership must be transferred to another owner). This is the natural role for unique_ptr.
A co-owner is also responsible for lifetime, but there are several of them. The object lives while at least one co-owner lives. This is shared_ptr.
An observer has access “if you’re lucky”: it can look at the object if it’s still alive, but it has no right to keep it alive. This is weak_ptr (or sometimes a raw pointer/reference as a non-owning access, but today we focus on the shared_ptr/weak_ptr pair).
It's important not just to know this model, but to be able to “read it by eye” from types in code: the type of a field or function parameter should hint who owns.
3. Rule #1: start with unique_ptr
If an object has one obvious owner, std::unique_ptr<T> is almost always the best choice. You gain everything at once: code clarity, predictable lifetimes, no ownership cycles, lower overhead and easier debugging.
In practice unique_ptr is an “owner pointer” that clearly says: “the object belongs to me, and only me”. If you need to transfer ownership — you do it explicitly via std::move. And that's very good: the code shows exactly where responsibility moved.
Let's cement this with a small piece of our application. Imagine we're writing a console "task manager" and want to keep tasks in memory. In the simplest model the storage owns the tasks.
#include <memory>
#include <string>
#include <vector>
struct Task {
int id{};
std::string title;
};
struct TaskStorage {
std::vector<std::unique_ptr<Task>> tasks;
};
This is easy to read: TaskStorage is the owner of all tasks. By default nobody else has the right to own a task. Therefore the task lifetime is clear: while it sits in tasks, it exists.
How to give an object outside access without giving up ownership
When we use unique_ptr, a common question arises: “How do we expose a task to the outside?” A newcomer sometimes wants to return unique_ptr from findTask() — but then the storage will no longer own the object. Usually that's not what you want for a lookup.
Often the right answer is: the storage owns, and we give the outside a non-owning access. For example, a raw pointer Task* (which may be null), or a reference Task& (if existence is guaranteed), or const Task* / const Task& for read-only access.
Example: find a task and return a non-owning pointer. Note: this is not “returning raw ownership”, it's “letting someone look”.
#include <memory>
#include <vector>
Task* find_task(std::vector<std::unique_ptr<Task>>& tasks, int id) {
for (auto& p : tasks) {
if (p->id == id) return p.get(); // non-owning pointer
}
return nullptr;
}
A useful habit here: .get() literally means “give me the raw address, but keep ownership”. That is a normal operation if you understand what you are doing.
4. When shared_ptr is truly justified
std::shared_ptr makes sense when the program semantics imply several independent components, each of which has the right to say: “I need the object to definitely stay alive while I work with it.”
The keyword is independent. Not “I’m too lazy to think”, not “I’m afraid”, but “by system requirements the object must outlive movements between parts of the program without a single center of ownership.”
A real-life example (without complex frameworks): you have an "application settings" object used by different modules. If these modules live independently and can outlive each other, it can be convenient to share ownership of the settings. But even here you can often get away with a const& to a single owner — the owner just needs to be clearly defined.
A more “honest” example for shared_ptr: a cached computation result that can be handed out to multiple parts of the program, and each part wants to ensure the result is not destroyed while it uses it.
#include <memory>
#include <string>
struct Report {
std::string text;
};
std::shared_ptr<Report> make_report() {
auto r = std::make_shared<Report>();
r->text = "Weekly report";
return r; // returning a share of ownership
}
Returning shared_ptr by value is a clear contract: “I create the object and hand it out into shared ownership.”
Why shared_ptr is more expensive than it seems
The overhead of shared_ptr is not only about performance. Yes, there is a control block, atomic counter operations (in most implementations), extra allocations and instructions. But much worse is the increased cost of reasoning about the program.
With unique_ptr you can almost always answer “who owns the object?” by looking at the field. With shared_ptr the answer often becomes: “well… someone”. Then a quest begins: finding all copies, understanding why the object is still alive, and why the destructor wasn't called.
One more important detail: use_count() looks like a life-saver (“I'll check how many owners there are!”), but in real code it is a poor basis for logic. use_count() has weak guarantees, and you can't base correctness of multithreaded or subtle scenarios on it; it's more of a diagnostic tool.
So shared_ptr is a powerful tool, but it's better to take it out not for everyday tasks but from the “toolbox” when you're sure it's the right tool.
5. weak_ptr: "I need a reference, but not ownership"
If you already use shared_ptr, the question almost always follows: “How do I reference the object from another object without creating an ownership cycle?” The answer is weak_ptr.
In terms of selection rules, weak_ptr is not a “third option instead of unique/shared”, but a complement to shared_ptr. It appears when you want to build navigation (for example, a "back" link in a doubly-linked structure, or a parent pointer in a node), but you do not want that link to be owning.
A good pattern: “store a weak_ptr, and when using it call lock() once and work through the obtained shared_ptr.”
#include <memory>
struct Task;
struct Link {
std::weak_ptr<Task> selected;
};
struct Task {
int id{};
};
Here Link does not keep Task alive. It just remembers: “if that task still exists — I can get access to it”.
6. A cheat sheet: rule, table and mini flowchart
If an object has one obvious owner (a container, manager, "storage", parent object) — use unique_ptr. If someone needs to look at it — provide T*, T&, const T*, const T& (depending on the contract).
If by design you need several independent owners, and there is no simple central owner to assign, — then shared_ptr.
If you need to “refer but not own” in the world of shared_ptr — use weak_ptr.
To help remember, keep a short selection table.
| Code situation | What you want to guarantee | Typical choice |
|---|---|---|
| "Object belongs to a container/storage" | A clear single owner | |
| "Need to transfer ownership" | The new owner takes responsibility | |
| "Multiple parts of the program must keep the object alive" | The object lives while someone needs it | |
| "Need to remember a reference but not extend lifetime" | Access only if the object is still alive | |
Mini decision flowchart
To avoid “chasing zen” every time you see smart pointers, keep one short decision algorithm in mind:
flowchart TD
A["Need to store/pass a reference to an object"] --> B{"Who owns the lifetime?"}
B -->|One obvious owner| C["unique_ptr on the owner"]
C --> D["Outside: T*/T&/const T& (observation)"]
B -->|Multiple independent owners| E["shared_ptr on co-owners"]
E --> F{"Are there back/cyclic links?"}
F -->|Yes| G["Break cycle: weak_ptr on one side"]
F -->|No| H["shared_ptr wherever ownership is needed"]
7. When an ID is better than a pointer instead of shared_ptr
One more practical point: sometimes we reach for shared_ptr not because we truly need shared ownership, but because we store a "long-lived reference" to an object that may disappear.
In such cases it's often better to store an identifier (for example, an int id) and perform a lookup when needed. Yes, that's a bit more code, but you won't have hidden lifetime extensions and won't wonder “why is it still alive”.
A mini idea for our Task app: the UI stores selected_id, not a shared_ptr<Task>.
#include <optional>
struct UiState {
std::optional<int> selected_task_id;
};
Then when displaying or editing you call find_task(..., *selected_task_id) and properly handle nullptr. This is sometimes much simpler than changing the ownership model of the whole application.
8. Common mistakes
Mistake #1: using shared_ptr "by default" because it feels safer.
This is a very human trap: you want to put a “safety helmet” on everything. But shared_ptr is not a helmet, it's more like heavy armor: you can wear it, but you'll tire quickly, and sometimes you won't fit through the door. If an object has a single owner, unique_ptr makes lifetimes simpler and more honest.
Mistake #2: trying to fix dangling pointers by switching to shared_ptr without changing the model.
Sometimes the problem is not that “shared owners are needed”, but that you store the wrong thing (for example, keeping a pointer to a container element that you later remove). Often it's better to store an id and look the object up again than to make the object "forever alive" via shared ownership.
Mistake #3: storing shared_ptr in both directions and creating an ownership cycle.
As soon as you have bidirectional links (“parent–child”, “prev–next”, “a references b and b references a”), the risk of a cycle becomes almost automatic. The cure is not “call reset somewhere”, but change the role of a link: one side must become a weak_ptr.
Mistake #4: building program logic on use_count().
use_count() can be useful for debugging and learning, but it's a poor foundation for program correctness: the number of owners can change, and the guarantees are limited. Treat it as a diagnostic, not as the condition “if owners == 1 — it's safe”.
Mistake #5: confusing "observation" and "ownership" in function signatures.
If a function takes std::shared_ptr<T> by value, it becomes a co-owner — even if you “just wanted to print the object”. If ownership isn't needed, pass a const std::shared_ptr<T>& (when the fact of shared ownership really matters) or just a const T& (when you only need access to the data). The clearer the signature, the fewer surprises with lifetimes.
GO TO FULL VERSION