The Introduction of C++ Smart Pointers

Forward

In order to utilize the RAII concept to the fullest, C++ 11 introduced smart pointers.

This article will introduce RAII and three types of smart pointers:

  • std::unique_ptr
  • std::shared_ptr
  • std::weak_ptr

Besides, the article will also introduce those methods below:

  • std::make_unique
  • std::make_shared

RAII (Resources Acquisition Is Initialization)

RAII is the concept of C++, which refers to the idea of acquiring resources during initialization and automatically releasing them during destruction.

There are some examples of RAII in C++:

  • std::unique_lock or std::lock_guard to lock and unlock mutexes automatically in multithreading;
  • smart pointers for automatic memory allocation and deallocation;
  • std::jthread to automatically execute join.

RAII is to better organize the code and reduce the possibility of programmer errors. For example, a programmer may forget to release the memory or lock that has been allocated, while using RAII, the resource will be automatically released when the object is destructed.

Smart Pointers

When we need to allocate memory to use, we often use the new keyword to allocate memory in C++:

class Base {
public:
    int num{10};
    Base() {}
    ~Base() { std::cout << "~Base()" << std::endl; }
};
Base *b = new Base;
delete b;

For the above code, we need to use new to allocate memory, and delete to release the memory.

However, We may forget to release the memory when we use return or throw to exit the function:

// Memory leakage.
int test() {
    Base *b = new Base;
    if (!check()) { // Do some check, but failed.
        // delete b; // This may be forgotten easily.
        return -1;
    }
    delete b;
    return 0;
}

Smart pointers are designed to solve this problem.

std::unique_ptr

We can use std::unique_ptr to substitute the original pointer:

int test()
{
    std::unique_ptr<Base> b(new Base);
    if (!check()) { // Do some check, but failed.
        return -1;
    }
    return 0;
}

With this code above, we do not need to use delete to release the memory.

The usage of std::unique_ptr is similar to that of a normal pointer:

std::unique_ptr<Base> b(new Base);
std::unique_ptr<int> p(new int);
*p = 10;
std::cout << *p << std::endl;
std::cout << b->num << std::endl;

We can also use std::unique_ptr to manage arrays, too:

std::unique_ptr<int[]> p(new int[10]);
for (int i = 0; i < 10; i++) {
    p[i] = i;
}
for (int i = 0; i < 10; i++) {
    std::cout << p[i] << std::endl;
}

std::unique_ptr can also bind to a raw pointer directly, but we need to be careful that if the lifecycle of the smart pointer ends, the raw pointer will become a dangling pointer:

int *p = new int{0};
{
    std::unique_ptr<int> up(p);
    std::cout << *up << std::endl;
}
// We cannot use p here, it's a dangling pointer.
// The dereference of a dangling pointer is an UB.

We can also use std::unique_ptr to manage polymorphic types:

class Base {
public:
    Base() { std::cout << "Base()" << std::endl; }
    virtual ~Base() { std::cout << "~Base()" << std::endl; }
    virtual void test() { std::cout << "Base test()" << std::endl; }
};
class Derived : public Base {
public:
    void test() { std::cout << "Derived test()" << std::endl; }
};

std::unique_ptr<Base> base(new Derived);
base->test(); // "Derived test()"

It is worth noting that you should not bind two unique_ptr to the same address in memory, this will cause double free. In the standard library, the copy constructor and copy assignment operator of std::unique_ptr are deleted, which can prevent this problem to some extend. With the reason, it is not recommended to bind a std::unique_ptr to a raw pointer directly.

The Deleter of std::unique_ptr

std::unique_ptr is a template class, the first template parameter is the type of the pointer, and the second template parameter is a deleter.

The default deleter is like delete or delete[].

You can also use a customized deleter to release the resource, for example:

// The customized deleter to close a file.
void close_file(std::FILE* fp) {
    std::fclose(fp);
    std::cout << "File closed" << std::endl;
}

{
    using unique_file_t = std::unique_ptr<std::FILE, decltype(&close_file)>;
    // Make sure there is demo.txt in current directory.
    // Otherwise the fp is nullptr
    unique_file_t fp(std::fopen("demo.txt", "r"), &close_file);
} // Here fp is finalized, so the close_file() will be called.

std::shared_ptr

std::shared_ptr is a smart pointer that can be shared by multiple pointers. You can bind many std::shared_ptr to a same address in memory.

There is a variable representing the count of reference in std::shared_ptr (indicate how many std::shared_ptr objects are bound to the same address). When a new std::shared_ptr object is created (it needs to be copied from another std::shared_ptr), the reference count will increase by \(1\). When a std::shared_ptr object is destroyed, the reference count will decreased by \(1\).

It is still not recommended to bind a std::shared_ptr to a raw pointer directly, you should use std::make_shared instead.

There is an example for std::shared_ptr:

int *p = new int;
std::shared_ptr<int> sp1(p);
{
    // This is wrong, when we bind p with a shared_ptr, its ref_count is 1.
    // So this will cause double free.
    // Only copy from a shared_ptr can make the ref_count increase correctly.
    // std::shared_ptr<int> sp2(p);

    std::shared_ptr<int> sp2(sp1);
    std::cout << sp2.use_count() << std::endl; // 2
    std::cout << sp1.use_count() << std::endl; // 2
}
std::cout << sp1.use_count() << std::endl; // 1

std::shared_ptr is designed to be used in a multi-threaded environment, and it is thread-safe. But the data it points to still needs to be synchronized by other means.

std::weak_ptr

std::weak_ptr can be created from a std::shared_ptr object or copied from another std::weak_ptr object.

When a std::weak_ptr object is created from a std::shared_ptr object, it will not increase the reference count of the std::shared_ptr object. For example:

std::shared_ptr<Base> sp(new Base);
std::weak_ptr<Base> wp = sp;
std::cout << sp.use_count() << std::endl; // 1
std::cout << wp.use_count() << std::endl; // 1

You can use the std::weak_ptr<T>::lock to create a new std::shared_ptr object, which will increase the reference count of the std::shared_ptr object, if the memory has not been released yet, and the reference count will be increased by 1:

std::shared_ptr<Base> sp(new Base);
std::weak_ptr<Base> wp = sp;
std::shared_ptr<Base> newSp = wp.lock();
std::cout << newSp.use_count() << std::endl; // 2
std::cout << wp.use_count() << std::endl; // 2
std::cout << sp.use_count() << std::endl; // 2
sp.reset();
// Still available here.
std::cout << newSp.use_count() << std::endl; // 1
std::cout << wp.use_count() << std::endl; // 1

If the memory has been released when calling std::weak_ptr<T>::lock, the std::weak_ptr object holds a nullptr pointer. Therefore we should check if the created std::shared_ptr object is nullptr before using:

std::shared_ptr<Base> sp(new Base);
std::weak_ptr<Base> wp = sp;
sp.reset(); // "~Base()"
std::shared_ptr<Base> newSp = wp.lock(); // newSp is bind with nullptr;
if (newSp != nullptr) {
    // do something...
}
std::cout << sp.use_count() << std::endl; // 0

std::weak_ptr<T>::lock is thread-safe, which means that if the returned std::shared_ptr object is not bound to nullptr, the resource is not released at this time. If the returned std::shared_ptr is bound to nullptr, the resource is released at this time. The access to the data still needs to be synchronized by other means.

Better Ways to Create Smart Pointers

In the previous examples, we use new and the constructors to create smart pointers. However, this is not very good, we can use std::make_unique and std::make_shared to create smart pointers so that we do not need to use new and delete keywords.

Using the new and constructors may be unsafe when exceptions are thrown. For example:

// In some header file:
void f(std::unique_ptr<T1>, std::unique_ptr<T2>);

// At some call site:
f(std::unique_ptr<T1>{ new T1 }, std::unique_ptr<T2>{ new T2 });

The compiler may optimize the function call with the order below:

  • Allocate memory for T1
  • Allocate memory for T2
  • Construct T1
  • Construct T2
  • Construct unique_ptr<T1>
  • Construct unique_ptr<T2>
  • Call f()

If there is an exception thrown when constructing T1 or T2, the memory may never be released. Using std::make_unique can solve this problem.

std::make_unique (since C++14)

This is very easy to use, for example:

class Base {
public:
    int num1;
    int num2;
    Base() = default;
    Base(int i, int j) : num1(i), num2(j) {}
};
std::unique_ptr<Base> up = std::make_unique<Base>(1, 2); // new Base(1, 2);
// create an array.
// only one parameter is OK, the parameter is the size of the array.
// make sure that the Base() constructor exists.
std::unique_ptr<Base[]> upArray = std::make_unique<Base[]>(3); // new Base[3];

std::make_shared (since C++11)

The usage of std::make_shared is similar to that of std::make_unique.

One Funny Thing

Herb Sutter, chair of the ISO C++ standards committee, writes on his blog that:

The C++11 doesn’t include std::make_unique is partly an oversight, and it will almost certainly be added in the future.

References




    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • gcs-front-end Development
  • gcs Documentation
  • gcs-back-end Development
  • Q&A
  • Contributing to blink-cmp-git