Click here to Skip to main content
15,891,136 members
Articles / Programming Languages / C++

The Shared, The Unique and The Weak – Initialization – Part 1

Rate me:
Please Sign up or sign in to vote.
0.00/5 (No votes)
22 Apr 2023CPOL7 min read 3.7K   1  
Shared ptr allocations and initializations
Shared ptr allocations, initializations, and behind the scenes functionality. Constructors' usage summaries, the aliasing conatructor, make_shared & allocate_shared advantages and disadvantages.

With amazing tools come great abilities. Usually, this sentence indicates that someone already did the hard job for us, but where is the fun in that? So here in C++, someone did the hard job for us, and left us only with the difficult job to use it. So take your snorkel, a deep breath (more than that), wetsuit, and everything that can help you to dive around the island of the shared initialization. 🏝=🏝

Previous article in the series: The Shared, The Unique and The Weak

Next article in the series: The Shared The Unique and The Weak – Initialization – Part 2

STD::SHARED_PTR

We took some pictures from this island on our previous trip. Feel free to look again for a better understanding of why this island exists.

Constructors Types

Empty / Nullptr

You can build an empty shared_ptr instance. This instance’s use_count will be 0, as long as the managed object is nullptr. Copies of this shared_ptr won’t increase its use count. Example:

C++
std::shared_ptr<int> i;           // use_count = 0
auto i1 = i;                      // use_count = 0
std::shared_ptr<int> i3(nullptr); // use_count = 0
i.reset(new int());               // use_count = 1

Initialize Using Pointer

You can pass a raw pointer to the constructor, in order to manage it. Pay attention that if this pointer is managed somewhere else, it might cause an access to a deleted object.

C++
{
    std::shared_ptr<int> i(new int(5));
} // Ok i was successfully released
{
    std::shated_ptr<int> i(new int(2));
    {
        std::shared_ptr<int> i2(i.get()); // '.get' function returns the 
        // inside pointer, without increasing the counter in the control_block.
        // i2.use_count = 1
    }   // i2 releases the int inside.
    // *i = 3; // Access invalidation
}

Pay attention that this method has some drawbacks compared to another initialization procedure, which will be discussed later in this article.

Initialize Using Pointer and Deleter

A custom deleter might use us in some cases. Whether we have a different way to perform a delete count on the managed object, know that the passed pointer is already managed somewhere else, or if we want to perform some actions before/after the pointer is being released.

Let’s take, for example, the access invalidation from the previous section. We can pass to b1 an empty deleter and solve avoid issue:

C++
{
    std::shated_ptr<int> i(new int(2));
    {
        auto empty_deleter = [](int*){};
        std::shared_ptr<int> i2(i.get(), empty_deleter); // '.get' function returns 
        // the inside pointer, without increasing the counter in the control_block.
        // i2.use_count = 1
    }   // i2 call empty_deleter on the managed object, 
        // so the inner int is still in the program access.
    *i = 3; // Legal access
}

Use Pointer, Deleter & Allocator

This allocator might seem a little unnecessary at first glance. Why do we need to specify an allocator, if we are already passing a pointer to it?

The allocator in that case is not for the pointer of the managed object in that case, but for the internal control_block. Assuming you want to get a better debug control on the shared_ptr allocations, or to set a custom allocator to use an already allocated buffer you own to get better performance abilities, this is your friend. A simple debug allocator might look something like that (example taken from cppreference):

C++
template<class T>
struct Mallocator
{
    typedef T value_type;
 
    Mallocator () = default;
 
    template<class U>
    constexpr Mallocator (const Mallocator <U>&) noexcept {}
 
    [[nodiscard]] T* allocate(std::size_t n)
    {
        if (n > std::numeric_limits<std::size_t>::max() / sizeof(T))
            throw std::bad_array_new_length();
 
        if (auto p = static_cast<T*>(std::malloc(n * sizeof(T))))
        {
            report(p, n);
            return p;
        }
 
        throw std::bad_alloc();
    }
 
    void deallocate(T* p, std::size_t n) noexcept
    {
        report(p, n, 0);
        std::free(p);
    }
private:
    void report(T* p, std::size_t n, bool alloc = true) const
    {
        std::cout << (alloc ? "Alloc: " : "Dealloc: ") << sizeof(T) * n
                  << " bytes at " << std::hex << std::showbase
                  << reinterpret_cast<void*>(p) << std::dec << '\n';
    }
};
{
    Mallocator<int> my_alloc;
    std::shared_ptr<int> i(my_alloc.allocate(1), 
    [&my_alloc](int* i){ my_alloc.deallocate(i, 1); }, my_alloc);
}
/*
Possible output (based on architecture type): 
Alloc: 4 bytes at 0x55a9098dceb0 
Alloc: 32 bytes at 0x55a9098dd2e0 
Dealloc: 4 bytes at 0x55a9098dceb0 
Dealloc: 32 bytes at 0x55a9098dd2e0 
*/

Copy Constructor

The copy constructor performs incrementation of the shared ptrs’ counter in the control_block. This way, it can manage and understand when it should call the deleter function on the managed object. This increment might be thread safe using fetch_add atomic operation, according to cppreference. For a further read about this action, you may refer to the previous article in this series.

Move Constructor

The move constructor sets the control_block’s and the managed object’s pointers to the destination shared_ptr and sets to nullptr the source shared_ptr’s pointers. This way, the source shared_ptr no longer holds responsibility for the managed object, and can’t modify or return it. Usage example:

C++
{
    std::shared_ptr<int> i(new int(4)); // use_count = 1
    auto i1 = std::move(i);             // use_count = 1
    // i.use_count() = 0
}

The Aliasing Constructor

The aliasing constructor gives us the ability to point to an object, that its life time depends in another object’s pointer. Assume we have the following classes:

C++
class A {};
class B { public: A a; };

Now we want to hold a pointer to B, and another pointer to B::a. Here's how it looks without the aliasing constructor:

C++
auto empty_a_deleter = [](A* a) {};
std::shared_ptr<B> b(new B());
std::shared_ptr<A> ba(&b->a, empty_a_deleter);

The issue here, is that if b gets deleted before ba, then ba will point to illegal location. In order to solve it, we can use the aliasing constructor:

C++
std::shared_ptr<B> b(new B());   // use_count = 1
std::shared_ptr<A> ba(b, b->a);  // b.use_count = 2
b.release();                     // use_count is back to 1, and ba is still valid

This ability might give some performance advantage alongside safety when you have a need to access a really deep element inside a structure, and don’t want to go through the chain of access each time.

Note: Since C++20, you can use the aliasing constructor on a moved shared_ptr instance:

C++
std::shared_ptr<int> b(new B());              // use_count = 1
std::shared_ptr<int> ba(std::move(b), &b->a); // use_count = 1

Construct From Weak

A shared_ptr instance can be constructed out of weak_ptr instance. The main reason for doing that is to access the managed object from the weak_ptr, but there might be other cases of sharing responsibility for the managed object. To read more about the usage of weak_ptr, you can refer to the previous article in this series. Usage example:

C++
void watch_value(std::weak_ptr<int> iw) {
    if (std::shared_ptr<int> is = iw.lock()) {
        std::cout << "The access is legal, and the value is: " << *is << ".\n";
    } else {
        std::cout << "The access is no longer legal, 
                      the managed object has already been released.\n";
    }
}
void func() {
    std::weak_ptr<int> iw;
    {
        std::shared_ptr<int> i(new int()); // s_counter = 1
        watch_value(i);                    // w_counter = 1
        // w_counter = 0
        iw = i;      // w_counter = 1
    }                // s_countet = 0, the managed object is being released.
    watch_value(iw); // w_counter = 2, but no access to managed object
                     // w_counter = 1
}                    // w_counter = 0, the control_block is being released.

Construct From Unique

The required type of responsibility may be changed during our program’s flow. For example, the Factory Design Pattern. You might want the factory to return a unique_ptr, because you don’t want to create a possible unnecessary overhead. But in an actual use, you might want to hold the returned pointer in multiple places.

Because all of the required heap allocations (the managed object & control_block) are being performed inside the boundaries of this function, this function’s writers can perfom a single allocation that will cover all of the required space. This way of allocation can promise two things, that none of the above mentioned constructors can promise:

C++
class base {};
class derived0 : public base {};
class derived1 : public base {};
class derived2 : public base {};
std::unique_ptr<base> factory(int type) {
    switch (type) {
        case 0: return std::unique_ptr<base>(new derived0());
        case 1: return std::unique_ptr<base>(new derived1());
        default: return std::unique_ptr<base>(new derived2());
    }
}
{
    std::shared_ptr<base> b = factory(0);
    std::unique_ptr<base> bu = factory(1);
    std::shared_ptr<base> bs = std::move(bu);
}

External Shared Initializers

There are several functions that are external to the std::shared_ptr that can help us create a shared_ptr instance in a more cleaned way with some advantages over the shared_ptr constructors. One of them is std::make_shared.

std::make_shared

std::make_shared accepts directly the required params for the specific type constructor (similarly to containers’ emplace functions), and allocates all required memory on the heap inside it.

This way gives some special advantages to the returned shared_ptr instance, if written in a specific way.

  1. Exception safety (until C++17): Order of evaluation may lead to a memory leak in case you pass two shared_ptr instances, which are created using the above constructors, the order of evaluation may be as the following: new int(1), new int(2), std::shared_ptr<int>(new int(1)), … In this case, new int(2) might throw an exception, and we’ve got a memory leak of new int(1). Pay attention that this case is correct only until C++17.
  2. Cache friendly: In case we pass a pointer allocated outside and the control_block is being separately allocated, the access to the managed object might require another page to be loaded to cache. This way, we might get cache miss every time we update the control_block and then try to access the managed object. Like in the following example:
C++
void func(std::shared_ptr<int> is) { // The s_counter inside control_block 
                                     // is being increased by 1
    std::cout << *is << "\n";        // Might need to load another page 
                                     // to access the managed object
} // The s_counter is being decreased by 1

If we allocate both the control_block and the managed object with the same call, they will most likely be located on the same cache line, and such access won’t cause any damage to the performances.

Usage example:

C++
auto is = std::make_shared<int>(8);
struct A { A(int, const std::string&) { /*...*/ } };
auto astr = std::make_shared<A>(1, "str");

However, this solution is almost perfect, but there are multiple missing features that exist in the constructor abilities.

One major issue is that since the control_block and the managed object are being allocated in a single call, they are being forced to be deallocated in a single call too. This statement leads us to the fact that as long as there is any instance that needs one of the two, we won’t be able to release the another one. In practice, that means that if there is at least one weak_ptr that points to the control_block, we won’t be able to release the allocated memory for the managed object. The unneeded memory at this case will be sizeof(T), where T is the managed object type. Pay attention that the managed object destructor will be called once the shared counter reaches 0 using the direct call managed_object_p->~T();

Another major issue is the ability to supply a custom deleter and a custom allocator. But this time, we’ve got good news because in this case, we can use std::allocate_shared.

Note that there are several more differences when using std::make_shared, that you can read about on cppreference.

std::allocate_shared

This method works similarly to std::make_shared, but additionally accepts an allocator, which is being used to allocate the required heap block for the managed object and for the control_block and to release it.

C++
auto is = std::allocate_shared<int>(Mallocator<int>(), 57);

Conclusion

There are many ways to initialize shared_ptr, with advantages and disadvantages, and every usage depends on the needs and the requirements for each situation, so we can get the best results.

In the next part, we’ll take a closer look at the islands of unique and weak initializations techniques and methodologies.

Any thoughts about what is the way you usually use to initialize a shared_ptr instance? Do you think that there are some missing features in this field of initialization? Or if you have any other thoughts, please feel free to share them with us in the comments’ section.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
Israel Israel
Senior C++ developer.
C++ Senioreas blog writer.
Passionate about investigating the boundaries and exploring the advanced capabilities of C++.

Comments and Discussions

 
-- There are no messages in this forum --