Ready to practice?
Sign up to access interactive coding exercises and track your progress.
Solving Circular References with std::weak_ptr
Learn modern memory management with circular dependency issues with std::shared_ptr, and std::weak_ptr.
Circular dependency issues with std::shared_ptr and std::weak_ptr
In the previous lesson, we saw how std::shared_ptr allows multiple smart pointers to co-own the same resource. However, in certain cases, this can become problematic. Consider the following case, where the shared pointers in two separate objects each point at the other object:
#include <iostream>
#include <memory> // for std::shared_ptr
#include <string>
class Employee
{
std::string m_name;
std::shared_ptr<Employee> m_manager; // initially created empty
public:
Employee(const std::string& name): m_name{name}
{
std::cout << m_name << " hired\n";
}
~Employee()
{
std::cout << m_name << " fired\n";
}
friend bool assignManager(std::shared_ptr<Employee>& emp, std::shared_ptr<Employee>& mgr)
{
if (!emp || !mgr)
return false;
emp->m_manager = mgr;
mgr->m_manager = emp;
std::cout << emp->m_name << " now reports to " << mgr->m_name << '\n';
return true;
}
};
int main()
{
auto alice{std::make_shared<Employee>("Alice")}; // create an Employee named "Alice"
auto bob{std::make_shared<Employee>("Bob")}; // create an Employee named "Bob"
assignManager(alice, bob); // Make "Alice" point to "Bob" and vice-versa
return 0;
}
In the above example, we dynamically allocate two Employees, "Alice" and "Bob" using make_shared() (to ensure alice and bob are destroyed at the end of main()). Then we assign them managers. This sets the std::shared_ptr inside "Alice" to point at "Bob", and the std::shared_ptr inside "Bob" to point at "Alice". Shared pointers are meant to be shared, so it's fine that both the alice shared pointer and Bob's m_manager shared pointer both point at "Alice" (and vice-versa).
However, this program doesn't execute as expected:
Alice hired
Bob hired
Alice now reports to Bob
And that's it. No deallocations took place. What happened?
After assignManager() is called, there are two shared pointers pointing to "Bob" (bob, and Alice's m_manager) and two shared pointers pointing to "Alice" (alice, and Bob's m_manager).
At the end of main(), the bob shared pointer goes out of scope first. When that happens, bob checks if there are any other shared pointers that co-own the Employee "Bob". There are (Alice's m_manager). Because of this, it doesn't deallocate "Bob" (if it did, then Alice's m_manager would end up as a dangling pointer). At this point, we now have one shared pointer to "Bob" (Alice's m_manager) and two shared pointers to "Alice" (alice, and Bob's m_manager).
Next the alice shared pointer goes out of scope, and the same thing happens. The shared pointer alice checks if there are any other shared pointers co-owning the Employee "Alice". There are (Bob's m_manager), so "Alice" isn't deallocated. At this point, there is one shared pointer to "Alice" (Bob's m_manager) and one shared pointer to "Bob" (Alice's m_manager).
Then the program ends -- and neither Employee "Alice" nor "Bob" have been deallocated! Essentially, "Alice" ends up keeping "Bob" from being destroyed, and "Bob" ends up keeping "Alice" from being destroyed.
It turns out that this can happen any time shared pointers form a circular reference.
Circular references
A Circular reference (also called a cyclical reference or a cycle) is a series of references where each object references the next, and the last object references back to the first, causing a referential loop. The references don't need to be actual C++ references -- they can be pointers, unique IDs, or any other means of identifying specific objects.
In the context of shared pointers, the references will be pointers.
This is exactly what we see in the case above: "Alice" points at "Bob", and "Bob" points at "Alice". With three pointers, you'd get the same thing when A points at B, B points at C, and C points at A. The practical effect of having shared pointers form a cycle is that each object ends up keeping the next object alive -- with the last object keeping the first object alive. Thus, no objects in the series can be deallocated because they all think some other object still needs it!
A reductive case
It turns out, this cyclical reference issue can even happen with a single std::shared_ptr -- a std::shared_ptr referencing the object that contains it is still a cycle (just a reductive one). Although it's fairly unlikely that this would ever happen in practice, we'll show you for additional comprehension:
#include <iostream>
#include <memory> // for std::shared_ptr
class Task
{
public:
std::shared_ptr<Task> m_dependency{}; // initially created empty
Task() { std::cout << "Task created\n"; }
~Task() { std::cout << "Task completed\n"; }
};
int main()
{
auto task{std::make_shared<Task>()};
task->m_dependency = task; // m_dependency is now sharing the Task that contains it
return 0;
}
In the above example, when task goes out of scope, the Task isn't deallocated because the Task's m_dependency is sharing the Task. At that point, the only way for the Task to be released would be to set m_dependency to something else (so nothing is sharing the Task any longer). But we can't access m_dependency because task is out of scope, so we no longer have a way to do this. The Task has become a memory leak.
Thus, the program prints:
Task created
and that's it.
So what is std::weak_ptr for anyway?
std::weak_ptr was designed to solve the "cyclical ownership" problem described above. A std::weak_ptr is an observer -- it can observe and access the same object as a std::shared_ptr (or other std::weak_ptrs) but it isn't considered an owner. Remember, when a std::shared pointer goes out of scope, it only considers whether other std::shared_ptr are co-owning the object. std::weak_ptr doesn't count!
Let's solve our Employee issue using a std::weak_ptr:
#include <iostream>
#include <memory> // for std::shared_ptr and std::weak_ptr
#include <string>
class Employee
{
std::string m_name;
std::weak_ptr<Employee> m_manager; // note: This is now a std::weak_ptr
public:
Employee(const std::string& name): m_name{name}
{
std::cout << m_name << " hired\n";
}
~Employee()
{
std::cout << m_name << " fired\n";
}
friend bool assignManager(std::shared_ptr<Employee>& emp, std::shared_ptr<Employee>& mgr)
{
if (!emp || !mgr)
return false;
emp->m_manager = mgr;
mgr->m_manager = emp;
std::cout << emp->m_name << " now reports to " << mgr->m_name << '\n';
return true;
}
};
int main()
{
auto alice{std::make_shared<Employee>("Alice")};
auto bob{std::make_shared<Employee>("Bob")};
assignManager(alice, bob);
return 0;
}
This code behaves properly:
Alice hired
Bob hired
Alice now reports to Bob
Bob fired
Alice fired
Functionally, it works almost identically to the problematic example. However, now when bob goes out of scope, it sees that there are no other std::shared_ptr pointing at "Bob" (the std::weak_ptr from "Alice" doesn't count). Therefore, it will deallocate "Bob". The same occurs for alice.
Using std::weak_ptr
One downside of std::weak_ptr is that std::weak_ptr aren't directly usable (they have no operator->). To use a std::weak_ptr, you must first convert it into a std::shared_ptr. Then you can use the std::shared_ptr. To convert a std::weak_ptr into a std::shared_ptr, you can use the lock() member function. Here's the above example, updated to show this off:
#include <iostream>
#include <memory> // for std::shared_ptr and std::weak_ptr
#include <string>
class Employee
{
std::string m_name;
std::weak_ptr<Employee> m_manager; // note: This is now a std::weak_ptr
public:
Employee(const std::string& name) : m_name{name}
{
std::cout << m_name << " hired\n";
}
~Employee()
{
std::cout << m_name << " fired\n";
}
friend bool assignManager(std::shared_ptr<Employee>& emp, std::shared_ptr<Employee>& mgr)
{
if (!emp || !mgr)
return false;
emp->m_manager = mgr;
mgr->m_manager = emp;
std::cout << emp->m_name << " now reports to " << mgr->m_name << '\n';
return true;
}
std::shared_ptr<Employee> getManager() const { return m_manager.lock(); } // use lock() to convert weak_ptr to shared_ptr
const std::string& getName() const { return m_name; }
};
int main()
{
auto alice{std::make_shared<Employee>("Alice")};
auto bob{std::make_shared<Employee>("Bob")};
assignManager(alice, bob);
auto manager{bob->getManager()}; // get shared_ptr to Bob's manager
std::cout << bob->getName() << "'s manager is: " << manager->getName() << '\n';
return 0;
}
This prints:
Alice hired
Bob hired
Alice now reports to Bob
Bob's manager is: Alice
Bob fired
Alice fired
We don't have to worry about circular dependencies with std::shared_ptr variable "manager" since it's just a local variable inside the function. It will eventually go out of scope at the end of the function and the reference count will be decremented by 1.
Avoiding dangling pointers with std::weak_ptr
Consider the case where a normal "dumb" pointer is holding the address of some object, and then that object is destroyed. Such a pointer is dangling, and dereferencing the pointer will lead to undefined behavior. And unfortunately, there's no way for us to determine whether a pointer holding a non-null address is dangling or not. This is a large part of the reason dumb pointers are dangerous.
Because std::weak_ptr won't keep an owned resource alive, it's similarly possible for a std::weak_ptr to be left pointing to a resource that has been deallocated by a std::shared_ptr. However, std::weak_ptr has a neat trick -- because it has access to the reference count for an object, it can determine if it's pointing to a valid object or not! If the reference count is non-zero, the resource is still valid. If the reference count is zero, then the resource has been destroyed.
The easiest way to test whether a std::weak_ptr is valid is to use the expired() member function, which returns true if the std::weak_ptr is pointing to an invalid object, and false otherwise.
Here's a simple example showing this difference in behavior:
// h/t to reader Waldo for an early version of this example
#include <iostream>
#include <memory>
class Task
{
public:
Task() { std::cerr << "Task created\n"; }
~Task() { std::cerr << "Task completed\n"; }
};
// Returns a std::weak_ptr to an invalid object
std::weak_ptr<Task> getWeakPtr()
{
auto task{std::make_shared<Task>()};
return std::weak_ptr<Task>{task};
} // task goes out of scope, Task destroyed
// Returns a dumb pointer to an invalid object
Task* getDumbPtr()
{
auto task{std::make_unique<Task>()};
return task.get();
} // task goes out of scope, Task destroyed
int main()
{
auto dumb{getDumbPtr()};
std::cout << "Our dumb ptr is: " << ((dumb == nullptr) ? "nullptr\n" : "non-null\n");
auto weak{getWeakPtr()};
std::cout << "Our weak ptr is: " << ((weak.expired()) ? "expired\n" : "valid\n");
return 0;
}
This prints:
Task created
Task completed
Our dumb ptr is: non-null
Task created
Task completed
Our weak ptr is: expired
Both getDumbPtr() and getWeakPtr() use a smart pointer to allocate a Task -- this smart pointer ensures that the allocated Task will be destroyed at the end of the function. When getDumbPtr() returns a Task*, it returns a dangling pointer (because std::unique_ptr destroyed the Task at the end of the function). When getWeakPtr() returns a std::weak_ptr, that std::weak_ptr is similarly pointing to an invalid object (because std::shared_ptr destroyed the Task at the end of the function).
Inside main(), we first test whether the returned dumb pointer is nullptr. Because the dumb pointer is still holding the address of the deallocated task, this test fails. There's no way for main() to tell whether this pointer is dangling or not. In this case, because it's a dangling pointer, if we were to dereference this pointer, undefined behavior would result.
Next, we test whether weak.expired() is true. Because the reference count for the object being pointed to by weak is 0 (because the object being pointed to was already destroyed), this resolves to true. The code in main() can thus tell that weak is pointing to an invalid object, and we can conditionalize our code as appropriate!
Note that if a std::weak_ptr is expired, then we shouldn't call lock() on it, because the object being pointed to has already been destroyed, so there's no object to share. If you do call lock() on an expired std::weak_ptr, it will return a std::shared_ptr to nullptr.
Summary
Circular references problem: When std::shared_ptr objects form a cycle (A points to B, B points to A), neither can be deallocated because each keeps the other's reference count above zero. This causes memory leaks.
Circular reference definition: A series of references where each object references the next, and the last references back to the first, creating a referential loop. With shared pointers, this prevents any object in the cycle from being destroyed.
Reductive case: Even a single std::shared_ptr can create a cycle if it references the object containing it. The object can't be destroyed because its own member keeps the reference count positive.
std::weak_ptr purpose: An observer that can access std::shared_ptr-managed objects without participating in ownership. Lives in the
Using std::weak_ptr: Not directly usable (no operator->). Must first convert to std::shared_ptr using the lock() member function, which returns a std::shared_ptr if the object still exists, or nullptr if already destroyed.
Detecting dangling pointers: Unlike raw pointers, std::weak_ptr can determine if its target is valid using expired(), which returns true if the reference count is zero (object destroyed).
Breaking cycles: Replace std::shared_ptr with std::weak_ptr in one direction of a bidirectional relationship. The weak pointer can observe without ownership, allowing proper cleanup when the owning shared pointers are destroyed.
Lock() safety: If a std::weak_ptr is expired, calling lock() returns a std::shared_ptr to nullptr. Always check expired() before using lock() to avoid accessing invalid objects.
Use std::weak_ptr to break circular dependencies with std::shared_ptr. Check expired() before calling lock() to ensure the object still exists.
Solving Circular References with std::weak_ptr - Quiz
Test your understanding of the lesson.
Practice Exercises
Fix Circular Reference Memory Leak
Fix a memory leak caused by circular references between Node objects in a doubly-linked list structure. Use std::weak_ptr to break the cycle while maintaining the ability to navigate between nodes.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!