Click here to Skip to main content
15,886,578 members
Articles / General Programming / Memory Management

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

Rate me:
Please Sign up or sign in to vote.
0.00/5 (No votes)
22 Apr 2023CPOL6 min read 3.7K   1  
More about smart memory management in C++
This post discusses unique_ptr and weak_ptr constructors, initializers and usage examples, make_unique advantages and disadvantages, unique_ptr custom deleters usage examples and important notes to pay attention to.

Last time, we visited the shared island, and that leaves us with only two more islands to visit, but lucky us, they are not that far from here. So take a deep breath (again), it’s time to sweem around the unique island. 🏝=std::move(🏝)

Previous article: The Shared The Unique and The Weak – Initialization – Part 1

STD::UNIQUE_PTR

I really enjoyed this island on our first trip there.

Constructors

Empty / Nullptr

Same as std::shared_ptr, we can initialize a unique_ptr instance with an empty managed object.

C++
std::unique_ptr<int> iu;
std::unique_ptr<int> iu2 = nullptr;

Construct From Pointer

A simple constructor which accepts a raw pointer:

C++
std::unique_ptr<int> iu(new int(8));

Construct From Pointer & Deleter

A deleter can be passed by value, by reference, or by const reference. The major thing to remember here, is that the deleter is being stored directly in the unique_ptr (unlike std::shared_ptr where it’s being stored in the control_block, and that way will always be with a size of two pointers).

But the fact that the deleter is being stored directly inside the std::unique_ptr creates some limitations when moving the unique_ptr from one instance to another, as a dependency of the way it was stored.

C++
template <typename T>
struct CustomDeleter {
    CustomDeleter() = default;
    CustomDeleter(const CustomDeleter&) = default;
    CustomDeleter(CustomDeleter&) = default;
    CustomDeleter(CustomDeleter&&) = default;
    void operator()(T* p) const { delete p; };
};
{
    CustomDeleter<int> deleter;
    std::cout << "Example 1\n";
    std::unique_ptr<int, CustomDeleter<int>> foo_unique(new int(), 
                         CustomDeleter<int>());  // move CustomDeleter
    std::unique_ptr<int, CustomDeleter<int>> f2 = 
                         std::move(foo_unique);  // move CustomDeleter
    std::cout << "Example 2\n";
    std::unique_ptr<int, CustomDeleter<int>&> 
                         f3(new int(), deleter); // reference CustomDeleter
    std::unique_ptr<int, CustomDeleter<int>&> f4 = 
                         std::move(f3);          // reference CustomDeleter
    // std::unique_ptr<int, CustomDeleter<int>&&> f41 = 
                         std::move(f4);          // Won't compile
    std::unique_ptr<int, CustomDeleter<int>> f5 = 
                         std::move(f4);          // non-const copy CustomDeleter
    // std::unique_ptr<Foo, CustomDeleter<int>&> f6 = std::move(f5); // Won't compile
    std::cout << "Example 3\n";
    std::unique_ptr<int, 
    const CustomDeleter<int>&> f7(new int(), deleter);       // reference CustomDeleter
    std::unique_ptr<int, CustomDeleter<int>> f8 = std::move(f7); // copy CustomDeleter
    //std::unique_ptr<int, CustomDeleter<int>&> f9 = std::move(f8); // Won't compile
}

This type of CustomDeleter is just one of some ways to create a custom deleter for a unique ptr. There are some tradeoffs like readability, needs and more that can affect the specific type that you might choose for every std::unique_ptr instance. However, on top of all those tradeoffs, there is one more thing to consider: the size of the unique_ptr instance, due to the fact that the deleter is being stored directly inside the instance.

Function Pointer

Another pointer to store inside our std::unique_ptr instance. In this case, we’ll get a size of sizeof(void*) * 2. A pointer to the actual data, and a pointer to the delete function.

C++
std::unique_ptr<int, void(*)(int*)> my_unique(new int(), [](int*){/*...*/});
std::function

This one gives us a little more overhead. The pure size of std::function (tested on cpo.sh, 32bit os) is 24 bytes. Adding the alignment with a pointer size, we get a total size of 32 bytes.

However, we can pass it by reference to the unique_ptr instance, and then, we’ll get a total size of two pointers (and the std::function instance is stored outside).

C++
std::unique_ptr<int, std::function<void(int*)>> my_unique(new int(), 
                [](int*){/*...*/}); // By value, size: 32 bytes.
std::function<void(int*)> my_del = [](int*){/*...*/};
std::unique_ptr<int, std::function<void(int*)>&> 
                my_unique2(new int(), my_del); // By reference, size: 8 bytes.
Lambda

Lambda might be a little more trickier, if you are not familiar with the actual structure that's behind it. Therefore, I recommend reading the truth behind it.

When talking about an empty captured lambda, it’ll take by itself a single byte, because it’s the minimal size for an object. But, when it comes to storing it within std::unique_ptr, some compilers (like llvm) might still get a total size of 4 bytes for the unique_ptr instance. If it seems like magic, smells like magic and can disappear like magic, does it really have to be magic? I’ll discuss further about this magical behavior in some compilers in the next article, but for now, let’s assume that there is no such a magic, and just use the expected behavior.

So, if the lambda size is 1 byte, and a pointer size is 4 bytes, with the default alignment, we’ll get a std::unique_ptr instance with a total size of 8 bytes. The same size is used when we pass a lambda by reference.

Note: If the lambda capture is not empty, the lambda size might be different than 1, but the reference size will remain the same.

C++
auto my_deleter = [](int*){};
std::unique_ptr<int, decltype(my_deleter)> my_unique(new int(), my_deleter);
Empty Functor

Same behavior as the lambda (which is in fact, a functor).

C++
template <typename T>
class DeleteFunctor {
public:
    void operator()(T* p) const {
        delete p;
    }
};
{
    std::unique_ptr<int, DeleteFunctor<int>> mu(new int(), DeleteFunctor<int>());
}

Construct From Unique

As we encountered before, we can move the ownership from one uniqe_ptr instance to another. There is no way to copy one to another, as it breaks the idea of a unique ownership. Once we moved the ownership, the origin unique_ptr will be reset to nullptr.

C++
std::unique_ptr<int> mu(new int());
auto mu1 = std::move(mu);
// auto mu2 = mu1; // Won't compile

External Unique Initializers

Similarly to std::shared_ptr, unique_ptr can be initialized using an external initializer std::make_unique.

std::make_unique

Last time, we saw all of the std::make_shared advantages. However, unique_ptr doesn’t manage any pointer except for the managed object. So why should you use this method? Let’s take, for example, the following function:

C++
void my_func(std::unique_ptr<int> iptr1, std::unique_ptr<int> iptr2);
// Possible call 1:
my_func(std::unique_ptr<int>(new int(1)), std::unique_ptr<int>(new int(2)));
// Possible call 2:
my_func(std::make_unique<int>(1), std::make_unique<int>(2));

There are three main benefits for the second call over the first call.

  1. The first advantage is easy to spot – less redundant initialize. When using std::unique_ptr constructor, we have to repeat on the concrete type two times. Once as part of the template, and again as part of the new call.
  2. The second advantage is the ability to avoid the new keyword, as part of code maintenance, and using good practice conventions.
  3. The third advantage is the only advantage that may prevent an actual and an immediate hidden bug. But in order to understand it, and to make the bug a little more visible, there is a rule we should know.

Order Of Evaluation (until C++17)

From cppreference: “Order of evaluation of any part of any expression, including order of evaluation of function arguments is unspecified (with some exceptions listed below). The compiler can evaluate operands and other subexpressions in any order, and may choose another order when the same expression is evaluated again.

Now, let’s take a second look on the first call example:

C++
my_func(std::unique_ptr<int>(new int(1)), std::unique_ptr<int>(new int(2)));
/* Possible order of evaluation:
1. new int(1)
2. new int(2) // May throw
3. std::unique_ptr<int>(new int(2))
4. std::unique_ptr<int>(new int(1))
*/

Now, assuming the second expression (new int(2)) throws exception, there will be a memory leak of new int(1). This issue doesn’t exist when using std::make_unique instead.

C++17 note: Since C++17, there are several additional rules to the evaluation order, and one of them solves exactly this issue:

From cppreference: “15) In a function call, value computations and side effects of the initialization of every parameter are indeterminately sequenced with respect to value computations and side effects of any other parameter.”

That means, that the compiler must handle each parameter end-to-end before continue to the next one. The order of the handled parameters remains unspecified, but it can’t partially handle one parameter and continue to another. (Special thanks for Yehezkel Bernat for that note.)

Downsides of std::make_unique

However, this std::make_unique function has a limitation that may not fit for any case: You can’t pass a custom deleter with it. In std::shared_ptr, this issue has been solved using std::allocate_shared, but there is no something like that (yet) for the unique case. There is a proposal about it, that didn’t make so far it to the standard P0211.

STD::WEAK_PTR

std::weak_ptr is responsible for holding a pointer which is managed outside by a shared_ptr. A weak ptr can’t be initialized without a shared_ptr instance, or another weak_ptr instance, unless it is initialized with nullptr. In order to access the underlying pointer, we have to create a shared_ptr instance out of it, and access it through it. You can read further about it on the first article in series. 🏝=🏝.lock().

Constructors

Default Constructor

A default constructor initializes a weak_ptr instance with a nullptr control_block.

Initialize From Shared

weak_ptr purpose is to be created out of a shared_ptr instance, in order to get an access to a managed object without an ownership. The common use case is to solve the circular pointing issue.

C++
auto is = std::make_shared<int>(); // shard_counter = 1, weak_counter = 0
std::weak_ptr<int> iu = is;        // shard_counter = 1, weak_counter = 1

Copy From Weak

A weak_ptr can get a copy of another weak_ptr instance. If the other instance owns an initialized control_block, this call will increase the weak counter inside the shared control_block.

C++
auto is = std::make_shared<int>(); // shard_counter = 1, weak_counter = 0
std::weak_ptr<int> iu = is;        // shard_counter = 1, weak_counter = 1
auto iu1 = iu;                     // shard_counter = 1, weak_counter = 2

Move From Weak

We can move one weak_ptr instance to another, and by that, the original weak_ptr’s control_block will turn into nullptr.

C++
auto is = std::make_shared<int>(); // shard_counter = 1, weak_counter = 0
std::weak_ptr<int> iu = is;        // shard_counter = 1, weak_counter = 1
auto iu1 = std::move(iu);          // shard_counter = 1, weak_counter = 1
// iu: shared_counter = 0, weak_counter = 0

Conclusion

There is more than it usually seems in smart pointers, and there are a lot of ways to create and use them. However, there is still some hidden magic we haven’t talked about yet, and in the next article, we’ll reveal one hidden secret that some of the compilers use in order to optimize the memory space that a unique_ptr instance takes. So stay tuned, and feel free to share (ptr) your thoughts in the comments.

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 --