What is weak_ptr in Modern C++ & why do we need it?

In modern C++ programming, smart pointers provides automatic memory management. Among these smart pointers, shared_ptr is widely used because it takes care of releasing memory when it is no longer needed, thanks to reference counting. However, misusing shared_ptr can lead to issues like memory leaks, especially in complex data structures such as binary trees. Let’s explore the proper use of shared_ptr and how weak_ptr can help resolve certain problems.

Case Study: Creating Binary Tree with shared_ptr

Consider a simple binary tree implementation:

#include <iostream>
#include <memory>

class Node
{
    int value;

public:
    std::shared_ptr<Node> leftPtr;
    std::shared_ptr<Node> rightPtr;

    Node(int val) : value(val)
    {
        std::cout << "Constructor" << std::endl;
    }
    ~Node()
    {
        std::cout << "Destructor" << std::endl;
    }
};

int main()
{
    std::shared_ptr<Node> root = std::make_shared<Node>(4);
    root->leftPtr = std::make_shared<Node>(2);
    root->rightPtr = std::make_shared<Node>(5);
    return 0;
}

Output:

Constructor
Constructor
Constructor
Destructor
Destructor
Destructor

Here, the constructors and destructors are called correctly, ensuring that memory is properly managed and released.

Problem: Introducing Parent Pointers

When we add a parent pointer to each node:

#include <iostream>
#include <memory>

class Node
{
    int value;

public:
    std::shared_ptr<Node> leftPtr;
    std::shared_ptr<Node> rightPtr;
    std::shared_ptr<Node> parentPtr;

    Node(int val) : value(val)
    {
        std::cout << "Constructor" << std::endl;
    }
    ~Node()
    {
        std::cout << "Destructor" << std::endl;
    }
};

int main()
{
    std::shared_ptr<Node> ptr = std::make_shared<Node>(4);
    ptr->leftPtr = std::make_shared<Node>(2);
    ptr->leftPtr->parentPtr = ptr;
    ptr->rightPtr = std::make_shared<Node>(5);
    ptr->rightPtr->parentPtr = ptr;

    std::cout << "ptr reference count = " << ptr.use_count() << std::endl;
    std::cout << "ptr->leftPtr reference count = " << ptr->leftPtr.use_count() << std::endl;
    std::cout << "ptr->rightPtr reference count = " << ptr->rightPtr.use_count() << std::endl;
    return 0;
}

Output:

Constructor
Constructor
Constructor
ptr reference count = 3
ptr->leftPtr reference count = 1
ptr->rightPtr reference count = 1

Now the constructor will be called three times, but there will be no call to the destructor, and that indicates a memory leak.

The reason for this problem with shared_ptr is cyclic references, i.e.,

If two objects refer to each other using shared_ptrs, then no one will delete the internal memory when they go out of scope.

It happens because shared_ptr in its destructor, after decrementing the reference count of the associated memory, checks if the count is 0; then it deletes that memory. If it’s greater than 1, it means that another shared_ptr is using this memory.

But in this kind of scenario, these shared_ptrs will always find the count greater than 0 in the destructor.

Let’s reconfirm this for the above example:

When ptr’s destructor is called,

  • It decrements the reference count by 1.
  • Then it checks if the current count is 0, but that is 2 because both the left and right children have a shared_ptr object referencing the parent, i.e., ptr.
  • The Left and Right children will be deleted only when the memory for ptr will be deleted, but that’s not going to happen because the reference count is greater than 0.
  • Hence, the memory for neither ptr nor its children will be deleted. Therefore, no destructor was called.

Solution: Using weak_ptr Smart Pointer

To solve the cyclic reference problem, we can replace the shared_ptr for the parent pointer with a weak_ptr. This allows us to share but not own the object, breaking the cycle:

std::weak_ptr<Node> parentPtr;

A weak_ptr doesn’t contribute to the reference count, hence it avoids creating strong reference cycles.

Here’s how you can work with weak_ptr:

std::shared_ptr<int> ptr = std::make_shared<int>(4);
std::weak_ptr<int> weakPtr(ptr);
std::shared_ptr<int> ptr_2 = weakPtr.lock();

if (ptr_2) 
{
    std::cout << (*ptr_2) << std::endl;
    std::cout << "Reference Count :: " << ptr_2.use_count() << std::endl;
}

if (weakPtr.expired() == false) 
{
    std::cout << "Not expired yet" << std::endl;
}

Output:

4
Reference Count :: 2
Not expired yet

The lock() function tries to retrieve a shared_ptr that owns the object. If the original shared_ptr is gone, lock() will return an empty shared_ptr.

Improved Binary Tree Example

Applying weak_ptr to our binary tree, we can avoid memory leaks even with parent pointers:

#include <iostream>
#include <memory>

class Node
{
    int value;

public:
    std::shared_ptr<Node> leftPtr;
    std::shared_ptr<Node> rightPtr;
    std::weak_ptr<Node> parentPtr; // Changed from shared_ptr to weak_ptr

    Node(int val) : value(val)
    {
        std::cout << "Constructor" << std::endl;
    }
    ~Node()
    {
        std::cout << "Destructor" << std::endl;
    }
};

int main()
{
    std::shared_ptr<Node> root = std::make_shared<Node>(4);
    root->leftPtr = std::make_shared<Node>(2);
    root->leftPtr->parentPtr = root;
    root->rightPtr = std::make_shared<Node>(5);
    root->rightPtr->parentPtr = root;

    // Outputs to help visualize reference counts
    std::cout << "root reference count = " << root.use_count() << std::endl;
    std::cout << "root->leftPtr reference count = " << root->leftPtr.use_count() << std::endl;
    std::cout << "root->rightPtr reference count = " << root->rightPtr.use_count() << std::endl;
    std::cout << "root->rightPtr->parentPtr reference count (via lock) = " << root->rightPtr->parentPtr.lock().use_count() << std::endl;
    std::cout << "root->leftPtr->parentPtr reference count (via lock) = " << root->leftPtr->parentPtr.lock().use_count() << std::endl;

    return 0;
}

Output:

Constructor
Constructor
Constructor
root reference count = 1
root->leftPtr reference count = 1
root->rightPtr reference count = 1
root->rightPtr->parentPtr reference count (via lock) = 2
root->leftPtr->parentPtr reference count (via lock) = 2
Destructor
Destructor
Destructor

Summary

While shared_ptr is an excellent tool for memory management, it must be used with caution to avoid pitfalls like cyclic references. weak_ptr offers a solution to this problem by allowing the existence of references that do not control the lifetime of the resource, thus enabling patterns like parent-child relationships

2 thoughts on “What is weak_ptr in Modern C++ & why do we need it?”

Leave a Reply to Avisha Cancel Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Scroll to Top