std::shared_ptr’s aliasing constructor

02.01.2025 19:34:00 by Christian

Prerequisites

This blog post assumes that you already have an understanding of the normal use case of a std::shared_ptr, namely managing the lifetime of a shared object that is being used from multiple locations.

The problem

Imagine you have a configuration object in your project that you hold via a std::shared_ptr.

For instance, like so:

struct HeavyObject
{
    // a lot of expensive members here
};

struct Config
{
    HeavyObject heavy_{};
    // other stuff
};

auto configPtr{std::make_shared<Config>()};Code language: C++ (cpp)

Now assume that you need to pass the HeavyObject member of the Config object to some other function within your project. One way to do it would be to pass the configPtr and thus the entire Config object. But this would make your code coupled to the Config class even though you only need to know the HeavyObject class. Since this would lead to tighter coupling and at the same time violate the Law of Demeter, this cannot be the right approach.

Next, you might be thinking of passing the HeavyObject member via HeavyObject*, HeavyObject&, or std::reference_wrapper<HeavyObject> to the function. This could work but it is potentially dangerous because you have to guarantee that the Config object is still alive when accessing the HeavyObject instance via the reference. If its lifetime has already ended, your pointer or reference will be dangling, and accessing it is UB.

In order to tackle this problem, one can use the aliasing constructor of std::shared_ptr.

This is what it looks like for a std::shared_ptr<T>:

template<typename U>
std::shared_ptr<T>( const std::shared_ptr<U>& r, T* ptr ) noexcept;Code language: C++ (cpp)

In order to understand it, we have to have a look at how a std::shared_ptr is usually implemented.

std::shared_ptr’s guts

Every std::shared_ptr has basically two raw pointers as members. Let’s call the first one storedPtr and the second one controlPtr.

The storedPtr points to a T. It is the pointer which is returned by the std::shared_ptr when calling the get() member function. It is the pointer that gets dereferenced when dereferencing the std::shared_ptr via -> or *.

The controlPtr points to a ControlBlock which is needed for managing the lifetime of the T. The ControlBlock contains a pointer to the T whose lifetime is being handled; let’s call this pointer managedPtr.

In the normal use case of a std::shared_ptr, the storedPtr and managedPtr point to the same T instance. In other words, the object that you access via the std::shared_ptr is the same object whose lifetime is being handled by the std::shared_ptr. This is depicted in figure 1.

Figure 1: std::shared_ptr pointing to and managing the lifetime of a T

But this is only a special case. The most general case is depicted in figure 2.

Figure 2: std::shared_ptr pointing to a T and managing the lifetime of a U

The std::shared_ptr points in this case to a T instance but manages the lifetime of a U instance. T and U are in general different types. This configuration can be achieved by using the aliasing constructor of std::shared_ptr.

The solution

So, back to our problem: We want to create a std::shared_ptr that points to a HeavyObject instance within a Config instance. Additionally, it needs to manage the lifetime of the Config instance. This would allow accessing the HeavyObject instance while ensuring that the Config instance is still alive. This is depicted in figure 3.

Figure 3: std::shared_ptr pointing to a HeavyObject within a Config object and managing the lifetime of the same Config object

This can be coded like so:

assert(configPtr.use_count() == 1); // Config object is kept alive due to one shared_ptr managing it

std::shared_ptr<HeavyObject> heavyPtr{configPtr, &configPtr->heavy_};

assert(configPtr.use_count() == 2); // heavyPtr is also managing Config object's lifetime, thus use_count increased by one
assert(heavyPtr.use_count() == configPtr.use_count()); // since they share the control block, the use count is the same
assert(heavyPtr.get() == &configPtr->heavy_); // heavyPtr points to heavy_ in the guts of Config instanceCode language: C++ (cpp)

Conclusion

This concludes the blog post. Now you know how to use std::shared_ptr’s aliasing constructor to safely pass around members of objects without triggering UB due to lifetime issues. In case you found this informative, you can read the next blog post, where you can learn more peculiar facts about the std::shared_ptr‘s aliasing constructor.


Leave a Reply

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