C++ smart pointers (3) – a preliminary study on unique_ptr

Unlike the shared pointer shared_ptr, which is used for shared objects, unique_ptr is used for exclusive objects.

Article directory

  • 1. Purpose of unqiue_ptr
  • 2. Use unique_ptr
    • 2.1 Initialize unique_ptr
    • 2.2 Access data
    • 2.3 As a member of a class
    • 2.4 Processing arrays
  • 3. Transfer of ownership
    • 3.1 Simple syntax
    • 3.2 Transfer ownership between functions
      • 3.2.1 Transfer to function body
      • 3.2.2 Transferring out the function body
  • 4.Delete
    • 4.1 default_delete<>
    • 4.2 Deleters of other related resources
  • 5. Simple analysis of unique_ptr and shared_ptr performance
  • 6. Appendix
  • 7. References

1. The purpose of unqiue_ptr

First of all, if we use ordinary pointers in functions, there will be many problems, such as the following function void f():

void f()
{<!-- -->
ClassA* ptr = new ClassA; // create an object explicitly
... // perform some operations
delete ptr; // clean up(destroy the object explicitly)
}

Aside from using ordinary pointers, it is easy to forget to use delete and cause memory leaks. If we make an error when performing some operations, it will also cause memory leaks.

Then it is necessary to introduce exception handling operations, such as try...catch..., then the program will look redundant and complicated.

The convenience brought by the two smart pointers shared_ptr and weak_ptr has been discussed before. However, unlike the shared object scenario, unique_ptr mainly It is used in the scenario of exclusive object ownership, that is, only one unique_ptr in the program has ownership of the object. Ownership can only be transferred, but ownership cannot be shared. Release resources and space after shared_ptr no longer owns the object’s smart pointer.

For example, we use unique_ptr to rewrite the above code:

// header file for unique_ptr
#include <memory>
void f()
{<!-- -->
// create and initialize an unique_ptr
std::unique<ClassA> ptr(new ClassA);
... // perform some operations
}

That’s all, so we save the delete and exception handling parts. Isn’t it very nice~

2. Use unique_ptr

2.1 Initialize unique_ptr

unique_ptr does not allow the use of assignment syntax to initialize an ordinary pointer. You can only directly substitute the value into the constructor for initialization:

std::unique_ptr<int> up = new int; // ERROR
std::unique_ptr<int> up(new int); // OK

Of course, a unique_ptr does not necessarily own an object, that is, it can be empty. Then use the default constructor for initialization:

std::unique_ptr<std::string> up;

You can assign a value using nullptr, or call reset(), which is equivalent:

up = nullptr;
up.reset();

2.2 Access data

Similar to ordinary pointers, you can use * to dereference the object pointed to, or you can use -> to access the members of an object:

// create and initialize (pointer to) string:
std::unique_ptr<std::string> up(new std::string("nico"));
(*up)[0] = ’N’; // replace first character
up->append("lai"); // append some characters
std::cout << *up << std::endl; // print whole string

unique_ptr does not allow pointer arithmetic operations, such as + + , which can be seen as an advantage of unique_ptr, since pointer arithmetic operations are prone to errors.

If you want to check whether a unique_ptr owns an object before accessing the data, you can call the operator bool():

if (up) {<!-- --> // if up is not empty
std::cout << *up << std::endl;
}

Of course, you can also compare unique_ptr with nullptr, or compare the native pointer obtained by get() with nullptr to achieve the purpose of checking:

if (up != nullptr) // if up is not empty
if (up.get() != nullptr) // if up is not empty

2.3 As a member of a class

We can also avoid memory leaks by applying unique_ptr to classes. Also, if you use unique_ptr instead of a normal pointer, the class will not need a destructor because when the object is deleted, its members are automatically deleted.

Beyond that, under normal circumstances, the destructor is only called when the constructor completes. If an exception occurs inside a constructor, the destructor will only be called for those objects that have completely completed construction (so when an exception occurs in the constructor of ClassB below, the destructor will not be called at this time). This will lead to a memory leak if a native pointer is used, and the first new in the constructor succeeds and the second new fails:

class ClassB {<!-- -->
private:
ClassA* ptr1; // pointer members
ClassA* ptr2;
public:
// constructor that initializes the pointers
// - will cause resource leak if second new throws
ClassB (int val1, int val2)
: ptr1(new ClassA(val1)), ptr2(new ClassA(val2)) {<!-- -->
}
// copy constructor
// - might cause resource leak if second new throws
ClassB (const ClassB & x)
: ptr1(new ClassA(*x.ptr1)), ptr2(new ClassA(*x.ptr2)) {<!-- -->
}
// assignment operator
const ClassB & amp; operator= (const ClassB & amp; x) {<!-- -->
*ptr1 = *x.ptr1;
*ptr2 = *x.ptr2;
return *this;
}
~ClassB () {<!-- -->
delete ptr1;
delete ptr2;
}
...
};

You can avoid the above situation by replacing the native pointer with unique_ptr:

class ClassB {<!-- -->
private:
std::unique_ptr<ClassA> ptr1; // unique_ptr members
std::unique_ptr<ClassA> ptr2;
public:
// constructor that initializes the unique_ptrs
// - no resource leak possible
ClassB (int val1, int val2)
: ptr1(new ClassA(val1)), ptr2(new ClassA(val2)) {<!-- -->
}
// copy constructor
// - no resource leak possible
ClassB (const ClassB & x)
: ptr1(new ClassA(*x.ptr1)), ptr2(new ClassA(*x.ptr2)) {<!-- -->
}
// assignment operator
const ClassB & amp; operator= (const ClassB & amp; x) {<!-- -->
*ptr1 = *x.ptr1;
*ptr2 = *x.ptr2;
return *this;
}
// no destructor necessary
// (default destructor lets ptr1 and ptr2 delete their objects)
...
};

It should be noted that if copy and assignment functions are not provided, it is obviously impossible to use the default copy and assignment copy constructor (it is impossible to copy and construct an exclusive pointer with an exclusive pointer), so only the move constructor is provided by default .

2.4 Processing Arrays

By default, unique_ptrs calls delete after losing ownership of the object, but for arrays, it should call delete[], so the following The code compiles, but there are runtime errors:

std::unique_ptr<std::string> up(new std::string[10]); // runtime ERROR

But in fact, we don’t have to be like shared_ptr. In this case, we need to implement delete[] through a custom deleter. The C++ standard library has specialized unique_ptr for processing arrays, so you only need to declare it as follows:

std::unique_ptr<std::string[]> up(new std::string[10]); // OK

However, it should be noted that this specialization will result in that we cannot use * and -> to access the array, but must use [], This is actually the same as our normal access to the array:

std::unique_ptr<std::string[]> up(new std::string[10]); // OK
...
std::cout << *up << std::endl; // ERROR: * not defined for arrays
std::cout << up[0] << std::endl; // OK

Note: Finally, it should be noted that coders need to ensure the legality of the index. Wrong indexes will bring undefined results.

3. Transfer of ownership

3.1 Simple syntax

According to the previous explanation, we know that we need to ensure that no two unique_ptrs are initialized with the same pointer:

std::string* sp = new std::string("hello");
std::unique_ptr<std::string> up1(sp);
std::unique_ptr<std::string> up2(sp); // ERROR: up1 and up2 own same data

Because it can only be owned uniquely and cannot be shared, general copying and assignment operations are definitely not possible. But C++11 has a new syntax - move semantics, which allows us to use constructors and assignment operations to transfer object ownership in unique_ptrs:

// initialize a unique_ptr with a new object
std::unique_ptr<ClassA> up1(new ClassA);
// copy the unique_ptr
std::unique_ptr<ClassA> up2(up1); // ERROR: not possible
// transfer ownership of the unique_ptr
std::unique_ptr<ClassA> up3(std::move(up1)); // OK

The above uses the move constructor, and the following uses the assignment operator to have similar performance:

// initialize a unique_ptr with a new object
std::unique_ptr<ClassA> up1(new ClassA);
std::unique_ptr<ClassA> up2; // create another unique_ptr
up2 = up1; // ERROR: not possible
up2 = std::move(up1); // assign the unique_ptr
// - transfers ownership from up1 to up2

Of course, if up2 previously held ownership of another object, then after up1's ownership is transferred to up2, the object up2 previously owned will be released:

// initialize a unique_ptr with a new object
std::unique_ptr<ClassA> up1(new ClassA);
// initialize another unique_ptr with a new object
std::unique_ptr<ClassA> up2(new ClassA);
up2 = std::move(up1); // move assign the unique_ptr
// - delete object owned by up2
// - transfer ownership from up1 to up2

Of course, we cannot assign an ordinary pointer to unique_ptr. We can construct a new unique_ptr to assign a value:

std::unique_ptr<ClassA> ptr; // create a unique_ptr
ptr = new ClassA; // ERROR
ptr = std::unique_ptr<ClassA>(new ClassA); // OK, delete old object
// and own new

A special syntax, we can use release() to return the ownership of the object owned by unique_ptr to the ordinary pointer. Of course, it can also be used to create new Smart pointer:

std::unique_ptr<std::string> up(new std::string("nico"));
...
std::string* sp = up.release(); // up loses ownership

3.2 Transfer ownership between functions

There are two situations: transferring ownership into the function body and transferring ownership out of the function.

3.2.1 Transfer to function body

In the following code, we use sink(std::move(up)) to transfer the ownership of up outside the function body to the function sink:

void sink(std::unique_ptr<ClassA> up) // sink() gets ownership
{<!-- -->
...
}
std::unique_ptr<ClassA> up(new ClassA);
...
sink(std::move(up)); // up loses ownership
...

3.2.2 Transfer function body

For example, in the following code, source() returns a unique_ptr, and we use p to receive it to obtain ownership of the corresponding object:

std::unique_ptr<ClassA> source()
{<!-- -->
std::unique_ptr<ClassA> ptr(new ClassA); // ptr owns the new object
...
return ptr; // transfer ownership to calling function
}
void g()
{<!-- -->
std::unique_ptr<ClassA> p;
for (int i=0; i<10; + + i) {<!-- -->
p = source(); // p gets ownership of the returned object
// (previously returned object of f() gets deleted)
...
}
} // last-owned object of p gets deleted

Of course, every time you acquire ownership of a new object, the old object will be released. At the end of the g() function, the last acquired object will also be released.

Here, there is no need to use move semantics in the source() function, because according to C++11 syntax rules, the compiler will automatically try to perform a move.

4. Deleter

unique_ptr<> Templates the type of object referenced by the initial pointer and the type of deleter:

namespace std {<!-- -->
template <typename T, typename D = default_delete<T>>
class unique_ptr
{<!-- -->
public:
typedef ... pointer; // may be D::pointer
typedef T element_type;
typedef D deleter_type;
...
};
}

For the array specialization, it has the same default deleter, which is default_delete:

namespace std {<!-- -->
template <typename T, typename D>
class unique_ptr<T[], D>
{<!-- -->
public:
typedef ... pointer; // may be D::pointer
typedef T element_type;
typedef D deleter_type;
...
};
}

4.1 default_delete<>

Let's delve deeper into the declaration of unique_ptr:

namespace std {<!-- -->
// primary template:
template <typename T, typename D = default_delete<T>>
class unique_ptr
{<!-- -->
public:
...
T & amp; operator*() const;
T* operator->() const noexcept;
...
};
// partial specialization for arrays:
template<typename T, typename D>
class unique_ptr<T[], D>
{<!-- -->
public:
...
T & amp; operator[](size_t i) const;
...
}
}

Among them, the content of std::default_delete<> is as follows. For T, call delete. For T[], call delete[]

namespace std {<!-- -->
// primary template:
template <typename T> class default_delete {<!-- -->
public:
void operator()(T* p) const; // calls delete p
...
};
// partial specialization for arrays:
template <typename T> class default_delete<T[]> {<!-- -->
public:
void operator()(T* p) const; // calls delete[] p
...
};
}

4.2 Deleters of other related resources

This is mentioned in the explanation of shared_ptr. It is the same as shared_ptr in that we can customize deleter. The difference is that shared_ptr needs to be provided in the template. The category of deleter. For example, use a function object and pass in the name of the class:

class ClassADeleter
{<!-- -->
public:
void operator () (ClassA* p) {<!-- -->
std::cout << "call delete for ClassA object" << std::endl;
delete p;
}
};
...
std::unique_ptr<ClassA,ClassADeleter> up(new ClassA());

For another example, using a function or lambda expression, we can specify something like void(*)(T*) or std::function or Use decltype:

std::unique_ptr<int,void(*)(int*)> up(new int[10],
[](int* p) {<!-- -->
...
delete[] p;
}); // 1

std::unique_ptr<int,std::function<void(int*)>> up(new int[10],
[](int* p) {<!-- -->
...
delete[] p;
}); // 2

auto l = [](int* p) {<!-- -->
...
delete[] p;
};
std::unique_ptr<int,decltype(l)>> up(new int[10], l); // 3

There is also a trick: use an alias template to avoid specifying the type of deleter

template <typename T>
using uniquePtr = std::unique_ptr<T,void(*)(T*)>; // alias template
...
uniquePtr<int> up(new int[10], [](int* p) {<!-- --> // used here
...
delete[] p;
});

5. Simple analysis of unique_ptr and shared_ptr performance

The shared_ptr class is implemented in a non-intrusive way, which means that the objects managed by this class do not need to meet a specific requirement, such as a public base class, etc. The huge advantage of this is that the shared pointer can be used for any type, including basic data types. The cost is that the shared_ptr object requires multiple internal members:

  • a plain pointer to the referenced object
  • A counter where all shared pointers refer to the same object
  • Due to the existence of weak_ptr, another counter is needed

Therefore, shared and weak pointers require an additional helper object internally, with pointers referencing it internally. This means that some specific optimizations are not possible (including empty base class optimization, which allows to eliminate any memory overhead).

unique_ptr does not require this overhead. Its "intelligence" is based on unique constructors and destructors, as well as the removal of copy semantics. A unique pointer with a stateless or null deleter will consume the same amount of memory as a native pointer. And compared to using native pointers and manual delete, there is no additional runtime overhead.

However, to avoid introducing unnecessary overhead, you should use function objects (including lambda expressions) for deleters to achieve the best optimization that brings ideally zero overhead.

6. Appendix

A. unique_ptr operation table

7. References

"The C++ Standard Library" A Tutorial and Reference, Second Edition, Nicolai M. Josuttis.