std::weak_ptr

13 minutes read

One of the varieties of smart pointers is std::weak_ptr. It is a kind of superstructure over shared_ptr. std::weak_ptr is a smart pointer that holds a non-owning ("weak") reference to an object that is managed by std::shared_ptr. Unlike std::shared_ptr, a std::weak_ptr does not participate in the ownership of the object it references, meaning it does not affect the reference count of the shared object.

std::weak_ptr is like having a temporary pass to a club. You can access the club as long as it is open, but you don't have the power to keep it open. If the club closes, your pass becomes invalid, and you can't access the club anymore.

std::weak_ptr

std::weak_ptr does not participate in the ownership of the object, it simply references it and this feature allows weak_ptr to break cyclic references, which can cause memory leaks in structures such as doubly-linked lists or graphs where two objects reference each other using std::shared_ptr.

Here are some useful methods provided by std::weak_ptr:

  • expired(): - this method checks whether the object managed by the std::weak_ptr has been deleted or not. It returns true if the std::shared_ptr that shares ownership of the object's memory has been destroyed or if there were no std::shared_ptr instances that owned the object to begin with. Otherwise, it returns false. This is useful for checking if the object is still accessible before attempting to access it.

  • lock() method is used with std::weak_ptr in C++ to safely access an object if it still exists. It tries to make a std::shared_ptr from a std::weak_ptr. If the object is still around (meaning there's a std::shared_ptr managing it), lock() will give you a new std::shared_ptr to work with that object. If the object is gone (no std::shared_ptr managing it), lock() returns an empty std::shared_ptr. This way, you can avoid accessing objects that no longer exist.

int main() {
    std::weak_ptr<int> weakPtr;
    {
        auto sharedPtr = std::make_shared<int>(42); // Create a shared_ptr
        weakPtr = sharedPtr;
        
        auto lockedSharedPtr = weakPtr.lock();
        if (lockedSharedPtr)
            std::cout << "Locked value: " << *lockedSharedPtr << std::endl;
    } // sharedPtr goes out of scope and is destroyed here.

    auto lockedSharedPtrAfter = weakPtr.lock(); // Try to lock weakPtr again after sharedPtr is destroyed.
    if (!lockedSharedPtrAfter)
        std::cout << "The object has been destroyed and cannot be locked." << std::endl;

    return 0;
}

The result of our example work:

Locked value: 42
The object has been destroyed and cannot be locked.
  • use_count(): - this method returns the number of std::shared_ptr instances that currently share ownership of the object. This can be useful for diagnostics or validating the state of ownership.

  • reset(): - this method resets the std::weak_ptr, breaking the existing weak reference to an object. After calling reset(), the std::weak_ptr will be empty (i.e., not pointing to any object), and expired() will return true. This can be useful when you explicitly want to release a weak reference before the shared_ptr's lifetime ends.

Class Book

To illustrate std::weak_ptr, let's implement an extended version of the library management system by introducing a function where a book can optionally remember the last visitor who borrowed it (the book can store a reference to the last visitor).

However, to prevent a circular reference (where a Book has a std::shared_ptr to a Patron, and a Patron has a std::shared_ptr to a Book), we'll use std::weak_ptr for the back-reference from the book to the patron.

First of all, let's implement the Book class:

// #include all header files and libraries

class Patron; // Forward declaration

class Book {
private:
    std::string title;
    std::string author;

public:
    std::weak_ptr<Patron> lastPatron; // Weak reference to avoid ownership
    Book(const std::string& title, const std::string& author)
        : title(title), author(author) {}
    //Here we declare a method, its implementation will be below the Patron class definition.
    void display() const;

    ~Book() {
        std::cout << "Destroying Book: " << title << std::endl;
    }
};

There are two key things we should be looking at:

  1. We create a smart pointer lastPatron of type Patron, however at the moment the class Patron is not declared or described. We can put the Patron class before the Book class, however we will run into a similar problem for the book class. So we use the "Forward declaration" for the Patron class.

  2. We only specified the prototype of the display() method. We want to check the owners in it and output the last user. Here is how our display() method could look like:

void display() const {
    std::cout << "Title: " << title << ", Author: " << author;
    // Use std::weak_ptr::expired() to check if the referenced Patron object has been deleted
    if (!lastPatron.expired()) {
        auto patronPtr = lastPatron.lock(); // Safely acquire a shared_ptr if the Patron still exists
        std::cout << ", Last Borrowed By: " << patronPtr->name;
    } else {
        std::cout << ", Last Patron's info is expired or not set";
    }
    std::cout << std::endl;
}

Here we are trying to use the name from the Patron(patronPtr->name) class, however, as we already know the implementation of the class has not been presented yet. Therefore, if we try to implement the display() method inside the Book class, we will get an error:

member access into incomplete type 'std::shared_ptr<Patron>::element_type' (aka 'Patron')

This happens because the compiler scans our program sequentially and when it reaches the line std::cout << ", Last Borrowed By: " << patronPtr->name; it does not know about the implementation of the Patron class.

The solution to this problem is quite simple, we simply put the implementation of the display() method behind the implementation of the Patron class:

// #include all header files and libraries

class Patron; // Forward declaration

class Book {
// Full implementation of the class
};

class Patron{
// Full implementation of the class
};

// Now that Patron is fully defined, we can define Book::display
void Book::display() const {
    std::cout << "Title: " << title << ", Author: " << author;
    // Use std::weak_ptr::expired() to check if the referenced Patron object has been deleted
    if (!lastPatron.expired()) {
        auto patronPtr = lastPatron.lock(); // Safely acquire a shared_ptr if the Patron still exists
        std::cout << ", Last Borrowed By: " << patronPtr->name;
    } else {
        std::cout << ", Last Patron's info is expired or not set";
    }
    std::cout << std::endl;
}

Class Patron

Let's implement our Patron class:

class Patron : public std::enable_shared_from_this<Patron> {
public:
    std::string name;
    std::vector<std::shared_ptr<Book>> borrowedBooks;

    Patron(const std::string& name) : name(name) {}

    void borrowBook(std::shared_ptr<Book>& book) {
        borrowedBooks.push_back(book);
        book->lastPatron = shared_from_this(); // Sets this Patron as the last one to borrow the book
    }

    void showBorrowedBooks() const {
        std::cout << name << " has borrowed:" << std::endl;
        for (const auto& book : borrowedBooks) {
            book->display();
        }
    }
};

The use of std::enable_shared_from_this in the Patron class facilitates obtaining a std::shared_ptr to the current object from within member functions of the object itself. This is particularly useful when you need to pass a std::shared_ptr to the current object to other functions or objects that store std::shared_ptrs, ensuring that the reference count is maintained correctly.

When a Patron borrows a book, the Book class stores a weak pointer (std::weak_ptr) back to the Patron to avoid circular references, which would prevent the automatic cleanup of resources. However, to set this weak pointer, the Book needs a std::shared_ptr to the Patron. Directly creating a new std::shared_ptr to this inside a member function (e.g., std::shared_ptr<Patron>(this)) is incorrect and dangerous because it creates a new control block for the shared pointer, leading to multiple control blocks for the same raw pointer, which can cause the object to be deleted more than once.

By inheriting from std::enable_shared_from_this, a class provides its instances the ability to safely generate additional std::shared_ptr instances to this, all sharing the same control block as any existing std::shared_ptr instances that manage the this pointer. This ensures that all std::shared_ptr instances referring to the object cooperate in the reference count management.

In the Patron class, when a book is borrowed, we want the book to keep a weak reference to the patron. To do this correctly, we call shared_from_this() to get a std::shared_ptr that shares ownership with any existing std::shared_ptrs that own *this:

void borrowBook(std::shared_ptr<Book>& book) {
    borrowedBooks.push_back(book);
    // Sets this Patron as the last one to borrow the book
    book->lastPatron = shared_from_this(); 
}

The Book object can keep a weak reference to the Patron that last borrowed it without interfering with the ownership and lifetime of the Patron object. The lifetime of the Patron object is properly managed, with all std::shared_ptr instances to a given Patron sharing the same control block, thus preventing premature destruction or double deletion of the Patron object.

In summary, std::enable_shared_from_this is used to safely manage shared ownership and weak references in scenarios where objects need to be able to provide std::shared_ptrs to themselves without violating the principles of safe memory management in C++.

Full program

Let's now add the code for the main() function and combine all our pieces into a single working program that allows us to manage and track our library:

#include <iostream>
#include <string>
#include <memory>
#include <vector>

class Patron; // Forward declaration

class Book {
private:
    std::string title;
    std::string author;

public:
    std::weak_ptr<Patron> lastPatron; // Weak reference to avoid ownership
    Book(const std::string& title, const std::string& author)
        : title(title), author(author) {}
    //Here we declare a method, its implementation will be below the Patron class definition.
    void display() const;

    ~Book() {
        std::cout << "Destroying Book: " << title << std::endl;
    }
};

class Patron : public std::enable_shared_from_this<Patron> {
public:
    std::string name;
    std::vector<std::shared_ptr<Book>> borrowedBooks;

    Patron(const std::string& name) : name(name) {}

    void borrowBook(std::shared_ptr<Book>& book) {
        borrowedBooks.push_back(book);
        book->lastPatron = shared_from_this(); // Sets this Patron as the last one to borrow the book
    }

    void showBorrowedBooks() const {
        std::cout << name << " has borrowed:" << std::endl;
        for (const auto& book : borrowedBooks) {
            book->display();
        }
    }
};

// Now that Patron is fully defined, we can define Book::display
void Book::display() const {
    std::cout << "Title: " << title << ", Author: " << author;
    // Use std::weak_ptr::expired() to check if the referenced Patron object has been deleted
    if (!lastPatron.expired()) {
        auto patronPtr = lastPatron.lock(); // Safely acquire a shared_ptr if the Patron still exists
        std::cout << ", Last Borrowed By: " << patronPtr->name;
    } else {
        std::cout << ", Last Patron's info is expired or not set";
    }
    std::cout << std::endl;
}

int main() {
    auto book1 = std::make_shared<Book>("The Great Gatsby", "F. Scott Fitzgerald");
    auto book2 = std::make_shared<Book>("1984", "George Orwell");

    auto alice = std::make_shared<Patron>("Alice");
    alice->borrowBook(book1);
    alice->borrowBook(book2);

    auto bob = std::make_shared<Patron>("Bob");
    bob->borrowBook(book1); // Both Alice and Bob share book1

    alice->showBorrowedBooks();
    bob->showBorrowedBooks();

    // Display book details to show the last patron
    book1->display();
    book2->display();

    return 0;
}

The result of executing our code:

Alice has borrowed:
Title: The Great Gatsby, Author: F. Scott Fitzgerald, Last Borrowed By: Bob
Title: 1984, Author: George Orwell, Last Borrowed By: Alice
Bob has borrowed:
Title: The Great Gatsby, Author: F. Scott Fitzgerald, Last Borrowed By: Bob
Title: The Great Gatsby, Author: F. Scott Fitzgerald, Last Borrowed By: Bob
Title: 1984, Author: George Orwell, Last Borrowed By: Alice
Destroying Book: 1984
Destroying Book: The Great Gatsby

Reasons not to use raw pointers

We have looked at all the types of smart pointers used in modern C++ programming, let's take a look at the key features that make it definitely worth not using raw pointers:

  1. Its declaration does not specify whether it points to a single object or to an array.

  2. Its declaration says nothing about whether you need to destroy what it points to. What it points to when you are done using it, that is, whether the pointer owns what it points to.

  3. If you determine that you need to destroy what the pointer points to, there is no way to tell how to do it. Should you use delete, or is there another destruction mechanism (such as a special destruction function to which you should pass the pointer)?

  4. If you were able to figure out that you should use delete, Reason 1 means that you cannot determine whether you should use the single-object form ("delete") or the array form ("delete []"). If you use the wrong form, the results will be undefined.

  5. Assuming that a pointer owns what it points to and knowing how to destroy it, it is hard to guarantee that you perform the destruction exactly once along all paths in the code (including those associated with exceptions). Skipping a path leads to resource leaks, and performing destruction more than once leads to undefined behavior.

  6. There is usually no way to determine that a pointer is "dangling," that is, pointing to memory that no longer holds the object it was supposed to point to. Dangling pointers occur when objects are destroyed and pointers continue to point to them.

Conclusion

In C++, managing memory efficiently is crucial to avoid errors and leaks. std::weak_ptr is a tool that helps programmers do just that, without taking full control or ownership of the memory it points to.

This is like having a key to a shared space that doesn't let you change the locks. It's particularly useful in preventing problems where programs keep running in circles, holding onto memory they don't need anymore.

By understanding and using std::weak_ptr, programmers can write cleaner, safer code that's less prone to bugs and easier to manage, moving away from older, riskier ways of handling memory with raw pointers.

2 learners liked this piece of theory. 0 didn't like it. What about you?
Report a typo