1. Why weak_ptr is needed
When you first see std::shared_ptr, it feels like the universal answer to “how to live without delete”. And indeed: a shared owner count, automatic object deletion, beauty. But this beauty has a cost: when ownership and a mere navigation reference get mixed, you can accidentally build a data structure that never gets freed.
Imagine a common real-life situation: we have an entity “Project” and an entity “Task”. A project “contains” tasks — logical. And a task “knows” which project it belongs to — also logical, because you want to print “(project: Study)” or jump quickly to the project's settings.
If both sides are implemented “logically” via shared_ptr, you get a closed loop: the project holds tasks, tasks hold the project, reference counts never drop to zero, and destructors are not called. So you didn't write delete anywhere… but there is a leak.
That's exactly why we need a tool for a “non-owning reference”. Today that's std::weak_ptr.
weak_ptr as an observer: a non-owning reference
std::weak_ptr<T> is a smart pointer that “looks at” an object managed by a std::shared_ptr<T> but is not an owner. Its key idea sounds almost philosophical: “I know where the object is, but I don't promise it will live.”
Technically a weak_ptr is linked to the same control block as the shared_ptr (the block that holds the counters). For practice, two rules are most important.
First rule: a weak_ptr does not increase the owner count, so it does not extend the object's lifetime.
Second rule: to safely “touch” the object, you must convert the weak_ptr into a temporary shared_ptr via lock(). If the object is still alive — you get a non-empty shared_ptr. If it's already destroyed — you get an empty one.
To keep this from being just abstract, imagine:
flowchart LR
A["shared_ptr<Project>"] -->|owns| P["Project object"]
P -->|owns| T["shared_ptr<Task> tasks"]
T -->|observes| W["weak_ptr<Project> project"]
Here there is only one type of “owns” arrow (the shared_ptr), and the back link is an “observation” (the weak_ptr). There is no ownership cycle — which is exactly what we want.
2. Creation and safe access
How to create a weak_ptr and what an empty weak_ptr is
At first a weak_ptr often looks strange: “where is *w? where is w->field?” — and that's a good sign. It means you won't accidentally dereference something that may no longer exist.
Usually a weak_ptr is obtained from a shared_ptr. It's like “taking the object's business card” but not “getting the apartment keys”.
#include <memory>
#include <iostream>
int main() {
auto p = std::make_shared<int>(10);
std::weak_ptr<int> w = p;
std::cout << "ok\n"; // ok
}
A weak_ptr can also be empty (in the state “looking at nothing”). This is a normal state — roughly like a regular pointer being nullptr, but more polite.
#include <memory>
#include <iostream>
int main() {
std::weak_ptr<int> w; // empty
auto locked = w.lock();
std::cout << std::boolalpha << (locked == nullptr) << "\n"; // true
}
It's important to understand: the “emptiness” of a weak_ptr is not an error but a normal part of the contract.
lock() — safe access: check and secure
The most important habit when working with weak_ptr is never trying to use the object directly, and always doing “lock + check”.
The point of lock() is that it creates a temporary shared_ptr (a temporary owner). While this shared_ptr lives in your variable, the object is guaranteed to exist. It feels great: it's like you “held the object's hand” during your operation.
The mini-pattern looks like this:
#include <memory>
void use_if_alive(const std::weak_ptr<int>& w) {
if (auto p = w.lock()) {
// the object is definitely alive while p exists
(void)*p;
} else {
// the object is already gone
}
}
Note the style: if (auto p = w.lock()) — it's not only pretty, it also protects against the classic mistake “checked separately — used separately”. If you checked in one place and used later, the object might have died in between (especially in more complex programs). This way everything is inside one block.
Another important habit: lock once and then use only the locked variable. Don't call lock() five times — that's like asking for someone's ID five times because you think they could have changed in a second.
expired() and use_count(): you can look, but don't rely
A weak_ptr has a method expired(): it answers “is the object already destroyed?”. In practice it is useful as a light hint, but real code should still access via lock().
Why? Because expired() is just a check “right now”. If immediately after it you try to access the object without lock(), you return to a race: “between check and use the state could have changed”.
In tutorials (and debugging) expired() can be used as an “indicator”. But if you need the object — use only lock().
You can also ask a weak_ptr for use_count() (how many strong owners exist via shared_ptr). But the same rule applies as for shared_ptr::use_count(): it's a diagnostic tool, not the basis for correctness.
Correct model: if you want “if object is alive — work with it” → use lock().
3. Breaking an ownership cycle using TaskBook as an example
From this point let's do what programmers love most: create a problem for ourselves and heroically solve it.
Imagine a small console app TaskBook: it stores projects and tasks. We won't build a full task manager; what matters now is the data model and ownership.
Anti-example: ownership cycle “Project ↔ Task”
Let's start with the wrong version to feel the pain. A project owns tasks, and each task owns the project.
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct Project;
struct Task {
std::string title;
std::shared_ptr<Project> project; // bad: owning "back"
~Task() { std::cout << "~Task: " << title << "\n"; }
};
struct Project {
std::string name;
std::vector<std::shared_ptr<Task>> tasks; // we own tasks
~Project() { std::cout << "~Project: " << name << "\n"; }
};
Now assemble a cycle:
#include <memory>
int main() {
auto pr = std::make_shared<Project>();
pr->name = "C++ course";
auto t = std::make_shared<Task>();
t->title = "Read about weak_ptr";
pr->tasks.push_back(t);
t->project = pr; // ownership cycle
}
When exiting main you might expect to see ~Task... and ~Project.... But due to the ownership cycle destructors often aren't called: project holds task, task holds project. Counts don't reach zero.
Fix: task references the project but does not own it
The domain logic is simple: the project owns the task, and the task only “knows where its project is”. This is not ownership — it's navigation. So the back link needs to be a weak_ptr.
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct Project;
struct Task {
std::string title;
std::weak_ptr<Project> project; // good: observe
~Task() { std::cout << "~Task: " << title << "\n"; }
};
struct Project {
std::string name;
std::vector<std::shared_ptr<Task>> tasks; // we own tasks
~Project() { std::cout << "~Project: " << name << "\n"; }
};
Creating the link hardly changes — just assign a weak_ptr:
#include <memory>
int main() {
auto pr = std::make_shared<Project>();
pr->name = "C++ course";
auto t = std::make_shared<Task>();
t->title = "Read about weak_ptr";
pr->tasks.push_back(t);
t->project = pr; // now this is a weak_ptr, no cycle
}
Now when exiting main destructors are called naturally: first pr (the last shared_ptr to the project) is destroyed, this destroys the project, and destroying the project destroys the vector of tasks (more precisely, decreases the shared_ptr counts in the vector), tasks lose their last owners and are destroyed too. No magic — just the correct ownership model.
How to safely print “task belongs to project X”
Since Task::project is now a weak_ptr, we cannot do task.project->name. And that's right: we must not behave as if the project is immortal.
Write a careful print function:
#include <iostream>
#include <memory>
#include <string>
void print_task(const Task& t) {
if (auto pr = t.project.lock()) {
std::cout << t.title << " (project: " << pr->name << ")\n";
} else {
std::cout << t.title << " (project: deleted)\n";
}
}
If the project is alive — print its name. If not — print that the project was deleted. This is normal UI logic even for real applications: “the reference is stale”.
4. Common mistakes when working with weak_ptr
Mistake #1: treating weak_ptr as a “almost shared_ptr” that you can dereference.
Sometimes you are tempted to do w->field or *w, because “it's a pointer after all”. But weak_ptr is deliberately designed so you cannot directly dereference a potentially non-existent object. The correct way is only lock() and work through a temporary shared_ptr.
Mistake #2: doing if (!w.expired()) and then using the object without lock().
This looks logical, but it's the “checked the door — looked away — the door was closed” pattern. Between check and use the object may disappear. The only safe way to access is auto p = w.lock(); and then if (p).
Mistake #3: calling lock() many times in one expression or in different places and then using different results.
Such code is harder to read and easily leads to wrong assumptions. Better lock once into a local variable and work only with it. It also improves readability: it's immediately clear where the “object is guaranteed alive” region starts.
Mistake #4: fixing an ownership cycle with an ad hoc reset() somewhere instead of changing the model.
Sometimes people try to manually break the cycle: call reset() on one of the shared_ptrs and hope everything will settle. This is unstable: you're fighting the symptom, not the cause. The correct treatment is to change the link type: where there is ownership — use shared_ptr; where there is navigation/observation — use weak_ptr.
Mistake #5: turning a weak_ptr into a “mandatory reference” without handling the “object is gone” scenario.
If you store a weak_ptr, you accept by contract that the object may be destroyed. Any code that works with a weak_ptr must have a branch for “object is gone” — even if it's “do nothing” or “print (deleted)”. If you need an object that must live, then someone must own it via a shared_ptr (or another explicit ownership mechanism), not just observe it.
GO TO FULL VERSION