std::shared_ptr

9 minutes read

Raw pointers are an extremely tricky tool. With just a bit of carelessness or forgetfulness, they can crash the program or, even worse, produce unexpected results.

Smart pointers provide a solution to these issues. These are pattern wrappers for raw pointers that act like raw pointers but steer clear of many pitfalls. Smart pointers can perform almost all tasks raw pointers do, but with a significantly reduced risk of errors.

Smart pointers

There are three main types of smart pointers in C++:

  • std::unique_ptr: A smart pointer that owns and manages another object through a pointer and disposes of that object when the unique_ptr goes out of scope.

  • std::shared_ptr: This manages a dynamically allocated object through reference counting. It allows multiple pointers to share ownership and ensures automatic deletion when the last shared_ptr is destroyed or reset.

  • std::weak_ptr: A non-owning reference to an object managed by std::shared_ptr, useful for breaking cyclic references without affecting the object's lifetime.

Here are a few examples of how smart pointers are used:

#include <iostream>
#include <memory> // For smart pointers

class MyClass {
public:
    MyClass() { std::cout << "MyClass constructed" << std::endl; }
    ~MyClass() { std::cout << "MyClass destroyed" << std::endl; }
};

int main() {
    std::unique_ptr<MyClass> myPtr(new MyClass());
}

In this example, when the main() function exits, myPtr will be automatically destroyed, and the memory allocated for MyClass will be automatically deallocated. Here's what happens when this code runs:

MyClass constructed
MyClass destroyed

If we prefer to use std::shared_ptr, our main() function might look like this:

int main() {
    // We can continue to use new, but make_shared is preferable
    std::shared_ptr<MyClass> sharedPtr1 = std::make_shared<MyClass>(); 
    
    {
        // Now sharedPtr1 and sharedPtr2 share ownership
        // Reference count is now 2
        std::shared_ptr<MyClass> sharedPtr2 = sharedPtr1; 
    } 
    // sharedPtr2 goes out of scope, reference count becomes 1

}

In this example, we used { } to artificially create a local scope for sharedPtr2. The result of this code is similar:

MyClass constructed
MyClass destroyed

std::weak_ptr must be converted to std::shared_ptr to access the referenced object. For std::weak_ptr, the main() function will look like this:

int main() {
    std::shared_ptr<MyClass> sharedPtr = std::make_shared<MyClass>();
    std::weak_ptr<MyClass> weakPtr(sharedPtr);

    // To use weakPtr, convert it to a shared_ptr first; using weakPtr.lock()
    if (std::shared_ptr<MyClass> tempPtr = weakPtr.lock()) {
        // Now tempPtr owns the object
    } // tempPtr goes out of scope and releases ownership
}

The result of the program will be the same as in the previous two examples.

Now let's focus on std::shared_ptr.

std::shared_ptr

For most scenarios, using std::unique_ptr is sufficient. They are fast, easy to use, and most importantly, safe. However, some more specific or complex scenarios may require shared ownership. That's when std::shared_ptr comes to our rescue.

Unlike std::unique_ptr, std::shared_ptr is designed for scenarios where multiple owners might need to share a single resource. std::shared_ptr uses reference counting to keep track of how many std::shared_ptr objects share the same dynamically allocated resource. When the last std::shared_ptr owning the resource is destroyed or reset, the resource gets deleted.

Using std::shared_ptr is like having a shared membership to a club. Many people can have the membership and access the club together. When the last person leaves, the club closes and cleans up automatically.

Key Features of std::shared_ptr:

  • Shared ownership: Multiple std::shared_ptr instances can own the same resource. The resource is released when the last std::shared_ptr owning it is destroyed or reset.

  • Reference counting: This counts the number of links to the shared resource. When the reference count hits zero, the resource gets deleted.

  • Support for custom deleters: You can specify a custom deleter (function or functor).

Let's look at a real task to see how useful and practical std::shared_ptr is.

Imagine we're creating a basic library management system where books can be shared among several patrons. We can use a std::shared_ptr to access a Book object by multiple users, automating memory management and tracking the number of owners.

First, we need to develop the Book class:

class Book {
private:
    std::string title;
    std::string author;
public:
    Book(const std::string& title, const std::string& author)
        : title(title), author(author) {}

    void display() const {
        std::cout << "Title: " << title << ", Author: " << author << std::endl;
    }

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

This is pretty straightforward. It has a title and an author, and a method to display information about the book (display()). We have a destructor here only to show when the object is destroyed (we don't need to free memory manually).

Then we need a Patron class:

class Patron {
private:
    std::string name;
    std::vector<std::shared_ptr<Book>> borrowedBooks;
public:
    Patron(const std::string& name) : name(name) {}

    void borrowBook(const std::shared_ptr<Book>& book) {
        borrowedBooks.push_back(book);
    }

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

Of course, we have the name of the Patron here. The entry std::vector<std::shared_ptr<Book>> borrowedBooks; means we will store smart pointers for our custom type (class) Book in the variable borrowedBooks. The borrowBook() method adds the book to our container, and showBorrowedBooks() displays the list of books for that user.

Finally, to use our classes, let's implement the main function:

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

    Patron alice("Alice");
    alice.borrowBook(book1);
    alice.borrowBook(book2);

    Patron bob("Bob");
    bob.borrowBook(book1); // Both Alice and Bob share book1

    alice.showBorrowedBooks();
    bob.showBorrowedBooks();

    return 0;
}

In the main() function, we create two books using std::make_shared.

Then, we create two patrons, Alice and Bob, who borrow the books. Notice that both Alice and Bob can borrow the same book (book1), demonstrating shared ownership. The book is only destroyed when it is no longer needed by any patron, illustrating the automatic memory management provided by std::shared_ptr. The result of running our program:

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

std::make_shared

We use std::make_shared instead of the usual constructor for several reasons:

  • std::make_shared allocates memory for the object and the control block, which keeps track of the reference counts, in a single operation. This method is more efficient than allocating memory separately for the object and the control block, as occurs when creating a std::shared_ptr with its constructor.

  • std::make_shared is also safer regarding exception handling. If an exception is thrown while constructing the object, std::make_shared prevents memory leaks because the memory allocation and object construction are the same operation.

To use std::make_shared, specify the type of the object you want to create as a template parameter and provide the constructor's arguments (if any) to std::make_shared. It returns a std::shared_ptr that owns the object.

Example usage:

auto ptr = std::make_shared<MyClass>(constructor_arg1, constructor_arg2);

This creates a shared_ptr that manages a new instance of MyClass, with constructor_arg1 and constructor_arg2 passed to the MyClass constructor.

Now, let's address the number of references and their deletion.

First, we will implement the returnBook method for the Patron class, simulating a user returning a book by deleting the book from the vector:

void returnBook(std::shared_ptr<Book> book) {
        auto it = std::find_if(borrowedBooks.begin(), borrowedBooks.end(),
                               [&book](const std::shared_ptr<Book>& ptr) { return ptr == book; });
        if (it != borrowedBooks.end()) {
            borrowedBooks.erase(it);

            std::cout << this->name << " returns the book: " << std::endl;
            (*it)->display();
            std::cout << std::endl;
        }
    }

Here, we used the standard search algorithm from the STL library, std::find_if, passing a lambda function as the third parameter. Upon finding a book in our container, we remove it, dereference the iterator, and call the method to display the book returned (*it)->display();. We can return books with an entry like this:

alice.returnBook(book1);
bob.returnBook(book1);

To check how many pointers own the object, we can use the use_count() method. To reset the local variables of type std::shared_ptr like book1 and book2 in our example, we can use the reset() method for each of them.

Now, let's borrow a couple of books, review the number of references to the objects, remove some of them, and check the number of references again.

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

    std::cout << "b1_count: " << book1.use_count() << " | ";
    std::cout << "b2_count: " << book2.use_count();
    std::cout << std::endl << std::endl;

    Patron alice("Alice");
    alice.borrowBook(book1);
    alice.borrowBook(book2);
    Patron bob("Bob");
    bob.borrowBook(book1);
    std::cout << "Alice and Bob took the books" << std::endl;
    std::cout << "b1_count: " << book1.use_count() << " | ";
    std::cout << "b2_count: " << book2.use_count();
    std::cout << std::endl << std::endl;

    bob.returnBook(book1);
    alice.returnBook(book1);
    std::cout << "b1_count: " << book1.use_count() << " | ";
    std::cout << "b2_count: " << book2.use_count();
    std::cout << std::endl << std::endl;

    std::cout << "Reset the last link to book1" << std::endl;
    book1.reset();
    std::cout << "b1_count: " << book1.use_count() << " | ";
    std::cout << "b2_count: " << book2.use_count();
    std::cout << std::endl << std::endl;

    alice.showBorrowedBooks();
    bob.showBorrowedBooks();

    return 0;
}

Here is the output of our program:

b1_count: 1 | b2_count: 1

Alice and Bob took the books
b1_count: 3 | b2_count: 2

Bob returns the book: Title: The Great Gatsby, Author: F. Scott Fitzgerald
Alice returns the book: Title: 1984, Author: George Orwell
b1_count: 1 | b2_count: 2

Reset the last link to book1
Destroying Book: The Great Gatsby
b1_count: 0 | b2_count: 2

Alice has borrowed:
Title: 1984, Author: George Orwell
Bob has borrowed:
Destroying Book: 1984

The reset() method clears an existing smart pointer, so it stops pointing to the object, decreasing the object's reference count. If the reference count drops to zero, the object is destroyed.

When we call book1.reset(); and the reference count is 1, book1 points to nullptr. If there are no other shared_ptr pointers to the objects, they're immediately destroyed. This is evident in the output, as "Destroying Book: The Great Gatsby" appears before "Alice has borrowed:"

Full example

Let's merge our parts of the program and look at the whole code:

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

class Book {
private:
    std::string title;
    std::string author;
public:
    Book(const std::string& title, const std::string& author)
        : title(title), author(author) {}

    void display() const {
        std::cout << "Title: " << title << ", Author: " << author << std::endl;
    }

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

class Patron {
private:
    std::string name;
    std::vector<std::shared_ptr<Book>> borrowedBooks;
public:
    Patron(const std::string& name) : name(name) {}

    void borrowBook(const std::shared_ptr<Book>& book) {
        borrowedBooks.push_back(book);
    }

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

    void returnBook(std::shared_ptr<Book> book) {
        auto func = [&book](const std::shared_ptr<Book>& ptr){return ptr == book;};
        auto it = std::find_if(borrowedBooks.begin(), borrowedBooks.end(), func);
        if (it != borrowedBooks.end()) {
            borrowedBooks.erase(it);

            std::cout << this->name << " returns the book: ";
            (*it)->display();
        }
    }
};

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

    std::cout << "b1_count: " << book1.use_count() << " | ";
    std::cout << "b2_count: " << book2.use_count();
    std::cout << std::endl << std::endl;

    Patron alice("Alice");
    alice.borrowBook(book1);
    alice.borrowBook(book2);
    Patron bob("Bob");
    bob.borrowBook(book1);
    std::cout << "Alice and Bob took the books" << std::endl;
    std::cout << "b1_count: " << book1.use_count() << " | ";
    std::cout << "b2_count: " << book2.use_count();
    std::cout << std::endl << std::endl;

    bob.returnBook(book1);
    alice.returnBook(book1);
    std::cout << "b1_count: " << book1.use_count() << " | ";
    std::cout << "b2_count: " << book2.use_count();
    std::cout << std::endl << std::endl;

    std::cout << "Reset the last link to book1" << std::endl;
    book1.reset();
    std::cout << "b1_count: " << book1.use_count() << " | ";
    std::cout << "b2_count: " << book2.use_count();
    std::cout << std::endl << std::endl;

    alice.showBorrowedBooks();
    bob.showBorrowedBooks();

    return 0;
}

There are small nuances that you need to keep in mind:

  • Smart pointers add a slight overhead compared to raw pointers, especially std::shared_ptr due to its reference counting.

  • Misuse can still lead to problems, such as circular references with shared_ptr.

  • They may not always be compatible with legacy code or certain APIs that require raw pointers.

Conclusion

Smart pointers are a vital part of modern C++ programming, offering robust memory management while preventing many common issues associated with raw pointers. Understanding and correctly utilizing smart pointers, like unique_ptr and shared_ptr can lead to safer, cleaner, and more efficient C++ code.

Therefore, you should prefer smart pointers to raw pointers.

How did you like the theory?
Report a typo