Skip to Content
Course content

48: Smart Pointers: shared_ptr

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years back, I was helping a junior dev on a game engine project. He had created a Texture class and was using std::unique_ptr to manage it within a Level class. That seemed fine until he added Sprite objects that all needed to reference that same texture. He started passing raw pointers from the unique_ptr to the sprites. It worked—until the Level was destroyed before the Sprites were. The next time a sprite tried to draw, the program crashed with a segmentation fault because it was staring at a piece of memory that had already been reclaimed. He spent an entire weekend trying to manually track the "lifespan" of the texture, which is a losing battle in any complex system.

This is exactly why std::shared_ptr exists. While unique_ptr is about exclusive ownership, shared_ptr is about shared ownership. It allows multiple parts of your code to hold a pointer to the same resource, and the resource is only destroyed when the very last owner lets go. I like to think of it as a group chat: the chat exists as long as at least one person is still in it. Once the last person leaves, the chat is deleted.

The Mechanics of Reference Counting

Under the hood, std::shared_ptr doesn't just store the raw pointer. It manages a "control block" that keeps a reference count. Every time you copy a shared_ptr, the count increments. When a shared_ptr goes out of scope or is reset, the count decrements. When that count hits zero, the destructor of the managed object is called, and the memory is freed.

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

struct GameAsset {
    std::string name;
    GameAsset(std::string n) : name(n) { std::cout < name > loaded\n"; }
    ~GameAsset() { std::cout < name > destroyed\n"; }
};

void useAsset(std::shared_ptr<GameAsset> asset) {
    std::cout < "Using " < asset->name < " inside a function\n";
    // Reference count is incremented here because we passed by value
}

int main() {
    // Always use std::make_shared instead of 'new' for better performance
    auto texture = std::make_shared<GameAsset>("BrickWall_Diffuse");
    
    {
        std::shared_ptr<GameAsset> anotherReference = texture;
        std::cout < "Reference count: " < texture.use_count() < "\n"; // Outputs 2
    } 
    
    std::cout < "Reference count: " < texture.use_count() < "\n"; // Back to 1
    useAsset(texture);
    return 0;
}

You'll notice I used std::make_shared. Please, do yourself a favor and make this a habit. It performs a single memory allocation for both the object and the control block, whereas using std::shared_ptr<T>(new T()) does two separate allocations. It's faster and more exception-safe.

The Trap of Circular References

Now, shared_ptr is powerful, but it has one glaring weakness that has bitten me more times than I care to admit: the circular dependency. Imagine you have a Parent object that holds a shared_ptr to a Child, and that Child holds a shared_ptr back to the Parent.

In this scenario, neither object will ever be deleted. The parent's reference count will never hit zero because the child holds a pointer to it, and the child's count will never hit zero because the parent holds a pointer to it. You've just created a memory leak that the "smart" pointer can't solve. In the real world, this often happens with observers or back-pointers in tree structures. To break this cycle, we use std::weak_ptr, which allows you to reference an object without increasing its reference count. We'll dive deeper into that in the next lesson, but for now, just keep in mind that shared ownership should be used judiciously—don't just use it because you're too lazy to figure out who actually "owns" the object.




📋 Practical Task

Exercise: Building a Shared Configuration Registry

Imagine you are building a server application where multiple different modules (e.g., DatabaseModule, NetworkModule, and LoggingModule) all need access to a single AppConfig object. If the configuration is updated or the system shuts down, you want to ensure the memory is managed correctly without any module holding a dangling pointer.

Your Task:

  1. Create a class AppConfig that contains a std::string serverName and a int port. Add a constructor and a destructor that prints a message when the config is created and destroyed.
  2. Create three different classes: DatabaseModule, NetworkModule, and LoggingModule. Each of these classes should have a constructor that accepts a std::shared_ptr<AppConfig> and stores it as a member variable.
  3. In your main function:
    • Create a single AppConfig instance using std::make_shared.
    • Instantiate the three modules, passing the shared config to each.
    • Print the use_count() of the config pointer at various stages (after creation, after module instantiation, and after one module is destroyed).
    • Ensure that the AppConfig is only destroyed after all modules have gone out of scope.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.