std::shared_ptr’s aliasing constructor peculiarities

04.01.2025 21:58:00 by Christian

Prerequisites

This blog post assumes that you already have an understanding of the normal use case of a std::shared_ptr, which is to manage the lifetime of a shared object that is being used from multiple locations. In addition, you should have read the previous blog post in this series.

Refresher

In the last blog post, we learned about the aliasing constructor of std::shared_ptr and the problems it might solve. This time, we want to dive deeper and explore what else can be done with it. To refresh your memory, take a look at the following figure that depicts the most general configuration of std::shared_ptr.

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

It is pointing to a T and manages the lifetime of U. In general, the storedPtr and the controlPtr can be set to nullptr individually by using the aliasing constructor of std::shared_ptr in a clever way.

The configurations

By setting storedPtr and controlPtr to nullptr, you can achieve the following four configuration cases:

ConfigstoredPtrcontrolPtrmanages lifetimepoints to somethinguse case
0nullptrnullptrnonodefault constructor of std::shared_ptr
1valid pointernullptrnoyespeculiar case 1
2nullptrvalid pointeryesnopeculiar case 2
3valid pointervalid pointeryesyesnormal use cases (including the one from the last blog post)
Table 1: Possible configurations of storedPtr and controlPtr and their corresponding use cases

Now, let’s examine both peculiar use cases and see what problems they might solve.

Peculiar use case 1:

Imagine you have a Config class in your project. You’ve designed your other classes so that they expect a std::shared_ptr<Config> during their construction. When integrating all the classes, you decide that you want to create the Config as a static global stack object, like this:

// Somewhere in main.cpp
namespace
{
    Config globalConfig{};
}Code language: C++ (cpp)

Now you need some wrapper code to pass this stack object as a std::shared_ptr to your classes. One way of doing it would be like this:

// Somewhere in main.cpp
namespace {
    Config globalConfig{};
    // Create a shared pointer from the stack object but deactivate the deleter
    // This is achieved by passing a lambda as a deleter that does not delete anything passed to it
    std::shared_ptr<Config> globalConfigPtr{&globalConfig, [](Config*){}};
}

int main()
{
    assert(globalConfigPtr.use_count() == 1); // A control block has been created
    assert(globalConfigPtr.get() == &globalConfig); // Points to the stack object
    auto copy{globalConfigPtr};
    assert(globalConfigPtr.use_count() == 2); // Copying it increases the use count
}Code language: C++ (cpp)

This works, and you can now pass this std::shared_ptr around to your other classes. However, this approach has drawbacks. By doing it this way, a control block is created for the globalConfigPtr, which necessitates a heap allocation. Furthermore, whenever the globalConfigPtr is copied, the use_count() needs to increase in a thread-safe way, which is also an expensive operation. The control block is unnecessary in this scenario because it is guaranteed that the lifetime of the Config objects ends only after the main() function of the program returns.

A better, albeit more obscure way of writing the wrapper code would be like this:

// Somewhere in main.cpp
namespace 
{
    Config globalConfig{};
    std::shared_ptr<Config> globalConfigPtr{std::shared_ptr<void>{}, &globalConfig};
}

int main()
{
    assert(globalConfigPtr.use_count() == 0);
    assert(globalConfigPtr.get() == &globalConfig); // Points to the stack object
    auto copy{globalConfigPtr};
    assert(globalConfigPtr.use_count() == 0); // Copying it does not increase the use count
}Code language: PHP (php)

The controlPtr of globalConfigPtr is set to nullptr by providing a default-constructed std::shared_ptr<void> as the first argument. Actually, you could provide a default-constructed std:shared_ptr of any arbitrary type (see Config 0 in table 1). This way, the std::shared_ptr manages no lifetime but still points to the stack object. Because it is managing no lifetime, no control block will be allocated, no expensive thread-safe synchronization needs to be performed during copying, and last but not least, the std::shared_ptr will never try to delete the global Config stack object.

Peculiar use case 2:

Finding an example for this use case took quite some time. I came up with this, but I have no idea whether or not this will ever be useful in a real-world scenario. In this use case, we want to utilize std::shared_ptr that point to nothing (nullptr) but manage the lifetime of something. In the following example, we even have a form of type erasure as a side effect.

So imagine you have objects of different types that you hold via std::shared_ptr. Let’s say you want to make sure that these objects live at least until a certain controlled point in time. You could realize it like this:

struct StayinAlive
{
    std::vector<std::shared_ptr<void>> vec_{};

    template<typename T&gt
    void keepAlive(const std::shared_ptr<T>& obj) {
        vec_.emplace_back(obj, nullptr);
    }

    void youAreAllowedToDieNow() {
        vec_.clear();
    }
};Code language: C++ (cpp)

Just to point it out: This mechanism only guarantees that the objects within the StayinAlive class live at least until youAreAllowedToDieNow() has been called. The actual time of their destruction depends, of course, on whether or not there is still some user of the objects keeping their use_count() above 0.

Conclusion

In this blog post, we discovered that std::shared_ptr‘s aliasing constructor can construct std::shared_ptr with some unusual or unexpected behavior, which can still be (ab)used to solve some real-world engineering problems.


Leave a Reply

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