0%

Unique Pointer in C++

This is my learning notes from the course - Mastering C++ Standard Library Features.

std::unique_ptr

  • Resources and “unique ownership”
  • What std::unique_ptr and std::make_unique are?
  • How move semantics make std::unique_ptr possible?
  • Run-time overhead

Resources and “Unique Ownership”

  • Every *”resource”* needs to be acquired and then released
  • Dynamic memory, files, and threads are all examples of resources
  • *”Unique ownership”*: there is only a single owner of a particular resource, who is responsible for both its acquisition and release
  • Access to the resource is only possible through its owner

Here’s an example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <memory>

struct foo
{
foo() { std::cout << "foo::foo()\n"; }
~foo() { std::cout << "foo::~foo()\n"; }
};

void not_so_smart()
{
foo *f = new foo;
// ... use `f` ...
delete f; // explicitly call delete to release the resource
}

void smart()
{
std::unique_ptr<foo> f{new foo};
// ... use `f` ...
// will automatically call delete when `f` is out of scope
}

int main()
{
not_so_smart();
smart();
}

From the above example, we can see that:

  1. To use smart pointers, the <memory> header must be included.
  2. std::unique_ptr<T> is a move-only class that represents unique ownership over a dynamically allocated object.
  3. When the unique_ptr instance f goes out of scope, it will automatically call delete for us, making sure that we do not forget to free the previously allocated memory.
  4. not_so_smart() and smart() functions are equivalent in behavior and performance. The main difference is that smart is way less error-prone than the other function and way easier to understand.

As you can see, std::unique_ptr<T> requires an explicit template parameter for the owned type. It has a constructor taking a T* that takes ownership of the passed pointer. For more details, please see https://en.cppreference.com/w/cpp/memory/unique_ptr

Move Semantics

As previously mentioned, std::unique_ptr<T> is a move-only class. Why?

The idea of “copying” doesn’t make sense in the context of “unique ownership”. How can we copy an unique owner of a resource? The whole point is that the resource will be accessible only through a single owner at any point in time, that is responsible for acquiring and releasing it.

1
2
3
4
5
6
7
8
void does_not_compile()
{
std::unique_ptr<foo> f{new foo};
auto f2 = f; // Compilation Error!

// `f` is lvalue (copy)
// cannot "copy" std::unique_ptr `f` to `f2`
}

On the other hand, the concept of “moving” is well-defined and makes sense for unique_ptr. The idea is using *”move semantics”* to represent ownership transfer from a unique_ptr to another.

1
2
3
4
5
void ownership_transfer()
{
std::unique_ptr<foo> f{new foo};
auto f2 = std::move(f);
}

After std::move(f) in the above example, f loses its ownership of the heap allocated foo instance. f2 becomes the only new unique owner. In this situation, when f2 goes out of scope, ~foo() will be invoked. When f goes out of scope, its destructor will not produce any side effect.

Move semantics allow us to safely and intuitively return and accept std::unique_ptr instances from/to functions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::unique_ptr<foo> bar()
{
std::unique_ptr<foo> f{new foo};
return f; // the return of function call is rvalue (move)
}

void take_ownership(std::unique_ptr<foo> f) // pass by value
{
std::cout << "took ownership of `f`\n";
}

int main()
{
auto f = bar(); // bar() is rvalue (move)
// take_ownership(f); // `f` is lvalue (copy) -> Compilation Error!
take_ownership(std::move(f)); // `std::move(f)` is rvalue (move)
}

std::make_unique

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <stdexcept>
#include <memory>

void foo(std::unique_ptr<int>, int);

int bar()
{
throw std::runtime_error{"whoops!"};
}

int main()
{
foo(std::unique_ptr<int>{new int{5}}, bar());
}

Consider the above code in line 14, there are three orders in which the foo() invocation could be executed:

  • Order #0:
    • Allocate memory for new int{5}
    • Construct unique_ptr
    • Invoke bar() and throw
  • Order #1:
    • Invoke bar() and throw
    • Allocate memory for new int{5}
    • Construct unique_ptr
  • Order #2:
    • Allocate memory for new int{5}
    • Invoke bar() and throw
    • Construct unique_ptr

In the case of #0 and #1, everything is perfectly safe. The unique_ptr cleans up after itself if required and no memory leaks occur.

However, in the case of order #2, there is a memory leak! The unique_ptr doesn’t get a chance to complete its construction before the exception is thrown. However, the memory for the int was already allocated, so it gets leaked.

Now, consider the alternative foo() call below. By using std::make_unique prevents the issue explained above - the foo() invocation will not interleave an allocation with the call to bar().

1
2
3
4
int main()
{
foo(std::make_unique<int>(5), bar());
}

Additionally, using make_unique:

  • Makes the code terser and more readable
  • Doesn’t require an explicit new call

How does std::unique_ptr work?

How does std::unique_ptr work? Why are move semantics necessary for its implementation?

Let’s answer those questions by creating our own own barebones my_unique_ptr.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <iostream>

template <typename T>
class my_unique_ptr
{
public:
my_unique_ptr() = default;
my_unique_ptr(T* ptr) : _ptr{ptr} {}

~my_unique_ptr()
{
// Deleting a `nullptr` is perfectly safe
delete _ptr;
}

// Prevent copies from both constructor and assignment operator
my_unique_ptr(const my_unique_ptr&) = delete;
my_unique_ptr& operator=(const my_unique_ptr&) = delete;

// " = delete" syntax is introduced in C++11, which explicitly prevents
// these functions from being callable and in any attempt doing so
// will end up getting compilation error.

// Ownership transfer
my_unique_ptr(my_unique_ptr&& rhs) : _ptr{rhs._ptr}
{
rhs._ptr = nullptr;
}

my_unique_ptr& operator=(my_unique_ptr&& rhs)
{
_ptr = rhs._ptr;
rhs._ptr = nullptr;
return *this;
}

private:
T* _ptr{nullptr};
};

From the above simple implementation, we can see that:

  • The code demonstrates the concepts behind unique_ptr that require move semantics to be implemented.
  • The destructor of my_unique_ptr conditionally releases the resources: if the held pointer is nullptr, nothing will be released.
  • Copies are forbidden to prevent having multiple instances of my_unique_ptr that refer to the same resource.
  • Moves make sure that the *”moved-from object”*’s held pointer is set to nullptr, so that it loses its ownership over the resource and does not release it upon destruction.

Run-Time Overhead

  • unique_ptr is considered a zero-cost abstraction, meaning that it will have no overhead on your application or library, except for very rare cases, which will not be covered here.
  • The example of unique_ptr on Compiler Explorer: http://godbolt.org/g/EdBxSu.
    As you can see from the example on Compiler Explorer, the assembly outputs are exactly the same for both cases.

Summary

  • std:unique_ptr models unique ownership over a dynamically allocated object
  • It takes ownership of dynamically allocated memory with its constructor, and releases it in its destructor
  • Ownership can be transferred to other instances by using std::move
  • Used std::make_unique instead of directly instantiating std::unique_ptr to avoid memory leaks and improve code readability
  • Understood that the std::unique_ptr can exist thanks to move semantics
  • Learned that the std::unique_ptr is almost always a zero-cost abstraction