Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C++

The Shared, The Unique and The Weak

0.00/5 (No votes)
22 Apr 2023CPOL4 min read 3.8K  
Smart memory management
In this post, you will see that the ability to maintain pointers without worrying about when they should be released sounds really utopic, but is it really the entire truth about smart pointers?
Mother ASM to her young C(hild): "Write a good code today, 
and don't forget to release the memory before you come back from school!"

Smart memory management exists in C++ since C++11. The ability to maintain pointers “without worrying when they should be released” sounds really utopic, but is it really the entire truth about “smart pointers”?

Next article in series: The Shared, The Unique and The Weak – Initialization – Part 1

STD::UNIQUE_PTR

Protecting your pointers isn’t always easy. Ownership is a keyword to establish this target. Let’s take the factory pattern as an example:

C++
MyClassI* some_func(int i) {
    switch (i) {
        case 0: return new MyClass1();
        case 1: return new MyClass2();
        default: return new MyClass3();
    }
}

In this case, the responsibility for releasing memory is moved to the caller, without notifying it in any way.

In order to notify the caller that it’s the owner of the returned pointer, we can use std::unique_ptr. This type has a minimal overhead over raw pointers because it directly owns the object it’s pointing to, and just wrapping it with object that contains several access/management functions. Here is how it’d look like with std::unique_ptr:

C++
std::unique_ptr<MyClassI> some_func(int i) {
    switch (i) {
        case 0: return std::make_unique<MyClass1>();
        case 1: return std::make_unique<MyClass2>();
        default: return std::make_unique<MyClass3>();
    }
}

But this almost no-overhead comes with a price. You can't pass a copy to this unique_ptr, which means that you can’t safely access the pointer from multiple places. For example, the following code won’t compile:

C++
struct a { std::unique_ptr<int> iptr; };
void func() {
    auto ptr = std::make_unique<int>();
    a a_var { .iptr = ptr }; // won't compile
    a a_var1 { .iptr = std::move(ptr) }; // compiles
}

Pay attention that after moving a unique pointer, the old one will turn into nullptr.

STD::SHARED_PTR

The ability to use a pointer, with almost no worries at all. You can copy it, move it, change it, while knowing that someone is protecting you from memory leaks. However, this is not always the entire truth.

The following code is a demonstration of the common usage of std::shared_ptr:

C++
{
    std::shared_ptr<int> ptr2;
    {
        auto ptr  = std::make_shared<int>(5);
        {
            auto ptr1 = ptr;
            *ptr1 = 15; // ptr updated too
        } // ptr1 destructor is called, 
          // but ptr is still holding the inner pointer, 
          // so the int allocation is not being released yet.
        ptr2 = ptr;
    }  // ptr destructor is called, but ptr2 is still holding the inner pointer, 
       // so the int allocation is not being released yet.
} // ptr2 destructor is called, and no other shared ptr holds the data, 
  // so the int allocation is being released.

At this point, it’s easy to think that std::shared_ptr is the solution for the universe. No more raw/unique pointers, no more memory leakage, no more worries. But, unfortunately, as I said before (and you can double check me for that), it’s not entirely true. But in order to understand the underlying unspoken issue, we have to dive deeper into the implementation.

STD::SHARED_PTR (Minimal) Implementation

The easiest way to implement the above functionality is to create an internal counter pointer, and share it in copy constructor/assignment operator. Then, every new copy increases its value, and every destructor decreases its value. Once the counter reaches 0, the memory is released. So far, not that hard to implement. But don’t be in a hurry to save the world. Here is a case where this implementation will still cause a memory leak:

C++
struct Node { std::shared_ptr<Node> next; };
{
    std::shared_ptr<Node> n1, n2, n3;
    n1 = std::make_shared<Node>(); // n1 counter pointer = 1
    n2 = std::make_shared<Node>(); // n2 counter pointer = 1
    n3 = std::make_shared<Node>(); // n3 counter pointer = 1
    n1->next = n2; // n2 counter pointer = 2
    n2->next = n3; // n3 counter pointer = 2
    n3->next = n1; // n1 counter pointer = 2
}
/**
Let's observe the destructors call:
n3 destructor is called and decreases n3 internal counter pointer to 1, 
because n2 is still pointing to it (and doesn't release yet the pointer to n1 
because it's underlying Node pointer destructor still haven't been called).
n2 destructor is called and decreases n2 internal counter pointer to 1, 
because n1 is still pointing to it.
n1 destructor is called and decreases n1 internal counter pointer to 1, 
because n3 is still pointing to it.
Now we have no more destructors to call, 
and the circular pointing still exists in the memory, 
without any way to access the elements in this circle. 
This lead us to a memory leakage situation.
*/

So what do we do now? A simple idea to avoid this situation is to use std::unique_ptr. Well… Yea, it’d work, but assuming you need this circular relation in your life like in dependencies diagram or in other real life concepts, what can you do in order to avoid such an issue, without going backward to C with raw pointers and memory management force? The answer is in the next subject.

STD::WEAK_PTR

A little more overhead to std::shared_ptr and we can get a mechanism to solve this issue.

Weak ptr behaves similarly to shared ptr, with some differences, when the major one of them is that it doesn’t own the object it points to. The concept behind this difference, is that if one of the connections in a circle is weak, then the memory leak will be prevented.

So How Does It Work?

  • A weak ptr must be constructed from either a weak ptr or a shared ptr.
  • In order to access the internal object that a weak ptr points to, you have to convert it to a temporary shared ptr.
  • A weak ptr has an additional counter pointer, in order to count all weak ptr references to the same shared ptrs group.
  • Once the weak ptr and the shared ptr counters reach 0, the whole allocated block (which contains the weak/shared ptr counter, the pointer to the underlying object and some more) is being released.

Here is a minimal example of how a weak ptr and a shared ptr are connected:

C++
template <typename T>
struct control_block {
    /* Constructors, T deleter... */
    size_t weak_counter;   // Once its value reaches 0, 
                           // this control_block is being released.
    size_t shared_counter; // Once its value reaches 0, 
                           // underlying_elememt is being released.
    T* underlying_element;
};
template <typename T>
class weak_ptr {
public:
    /* Constructors... */
private:
    control_block<T>* cb;
};
template <typename T>
class shared_ptr {
public:
    /* Constructors, access methods, etc... */
private:
    T* underlying_element; // Might be part of access optimization.
    control_block<T>* cb;
};

So let’s look at our previous issue with this new weak ptr usages:

C++
struct Node { std::weak_ptr<Node> next; };
{
    std::shared_ptr<Node> n1, n2, n3;
    n1 = std::make_shared<Node>(); // n1 s_counter = 1, w_counter = 1
    n2 = std::make_shared<Node>(); // n2 s_counter = 1, w_counter = 1
    n3 = std::make_shared<Node>(); // n3 s_counter = 1, w_counter = 1
    n1->next = n2; // n2 s_counter = 1, w_counter = 2
    n2->next = n3; // n3 s_counter = 1, w_counter = 2
    n3->next = n1; // n1 s_counter = 1, w_counter = 2
}
/**
Let's observe the destructors call:
n3 destructor is called:
1. n3 internal s_counter pointer decreases to 0.
2. The underlying_element is being released.
3. The w_counter decreases to 1 (because n2 still holds a weak ptr pointer to it).
4. n1 w_counter decreased to 1 (because n3 underlying_element has been released). 
n2 destructor is called:
1. n2 internal s_counter pointer decreases to 0.
2. The underlying_element is being released.
3. The w_counter decreases to 1 (because n1 still holds a weak ptr pointer to it).
4. n3 w_counter decreases to 0.
5. n3 control_block is completely released.
n1 destructor is called:
1. n1 internal s_counter pointer decreases to 0.
2. The underlying_element is being released.
3. The w_counter decreased to 0.
4. The internal control_block is completely released.
5. n2 w_counter decreased to 0.
6. n2 control_block is completely released.
All allocated memory has been successfully released.
*/

Important notes that haven’t been discussed here:

  • Once the s_counter reaches 0, any access to weak ptr of the same group will return nullptr.
  • A weak ptr can also help with multi-threaded issues when it comes to shared pointers.
  • The standard recommends to implement an atomic access to the internal control_block counters.

Shared vs. Unique

Shared pointers are easier to use. They almost have no previous knowledge requirements. You can copy them and use them almost like a protected raw pointers (as long as you don’t have circles, as we discussed earlier), while unique pointers can only be moved from one holder to another.

However, shared pointers come with overhead of control_block (with weak and shared counters) that they hold inside (in order to solve circles memory leak), while unique pointers directly hold only the object they are pointing to.

Conclusion

Smart memory management hides a lot of mechanism behind the scenes, and the decision which kind of smart pointer should be used should be considered carefully, when in a matter of safety and efficiency, the priority should be:

  1. std::unique_ptr
  2. std::weak_ptr (if there is a circle consideration)
  3. std::shared_ptr
  4. Raw pointers (sometimes, it’s just necessary in a matter of efficiency)

Feel free to share your opinions and takeaways in the comments, or write any more questions about this subject.

And now, go ahead and save the world! 😉

License

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