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.
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
.
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.
By setting storedPtr
and controlPtr
to nullptr
, you can achieve the following four configuration cases:
Config | storedPtr | controlPtr | manages lifetime | points to something | use case |
---|---|---|---|---|---|
0 | nullptr | nullptr | no | no | default constructor of std::shared_ptr |
1 | valid pointer | nullptr | no | yes | peculiar case 1 |
2 | nullptr | valid pointer | yes | no | peculiar case 2 |
3 | valid pointer | valid pointer | yes | yes | normal use cases (including the one from the last blog post) |
Now, let’s examine both peculiar use cases and see what problems they might solve.
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.
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>
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.
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.