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:
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
:
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:
struct a { std::unique_ptr<int> iptr; };
void func() {
auto ptr = std::make_unique<int>();
a a_var { .iptr = ptr }; a a_var1 { .iptr = std::move(ptr) }; }
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
:
{
std::shared_ptr<int> ptr2;
{
auto ptr = std::make_shared<int>(5);
{
auto ptr1 = ptr;
*ptr1 = 15; } ptr2 = ptr;
} }
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:
struct Node { std::shared_ptr<Node> next; };
{
std::shared_ptr<Node> n1, n2, n3;
n1 = std::make_shared<Node>(); n2 = std::make_shared<Node>(); n3 = std::make_shared<Node>(); n1->next = n2; n2->next = n3; n3->next = n1; }
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:
template <typename T>
struct control_block {
size_t weak_counter; size_t shared_counter; T* underlying_element;
};
template <typename T>
class weak_ptr {
public:
private:
control_block<T>* cb;
};
template <typename T>
class shared_ptr {
public:
private:
T* underlying_element; control_block<T>* cb;
};
So let’s look at our previous issue with this new weak ptr usages:
struct Node { std::weak_ptr<Node> next; };
{
std::shared_ptr<Node> n1, n2, n3;
n1 = std::make_shared<Node>(); n2 = std::make_shared<Node>(); n3 = std::make_shared<Node>(); n1->next = n2; n2->next = n3; n3->next = n1; }
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:
std::unique_ptr
std::weak_ptr
(if there is a circle consideration) std::shared_ptr
- 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!