Modern C++ - Smart pointers

Smart pointers: ensure that programs are free of memory and resource leaks and are exception-safe.

Header file: <memory>

  • unique_ptr: Allows exactly one owner of the underlying pointer. Cannot be copied.
  • shared_ptr: Reference-counted smart pointer. The raw pointer is not deleted until all owners have gone out of scope or have otherwise given up ownership.
  • weak_ptr: provides access to an object that is owned by one or more shared_ptr instances, but does not participate in reference counting. Useful to break circular reference.

Smart pointers are crucial to the RAII (Resource Acquisition Is Initialization) programming idiom. RAII ensures that resource acquisition occurs at the same time that the object is initialized, so that all resources for the object are created and made ready in one line of code.

std::unique_ptr<A> p(new A());

NOTE: Do not use the new or malloc expression on the smart pointer itself.

Different from Java/C#: no separate garbage collector runs in the background; memory is managed through the standard C++ scoping rules so that the runtime environment is faster and more efficient.

std::make_unique is the new new

(since C++14)

When creating a new object to store in a unique_ptr, use std::make_unique.

std::unique_ptr<Foo> v = std::make_unique<Foo>();
// or
auto v = std::make_unique<Foo>();

The only exception to this rule is when implementing a factory function that uses a protected or private constructor. In this case, you have to use new, but you should immediately wrap the result using absl::WrapUnique.

When to use std::unique_ptr?

If it is never std::moved to or from another std::unique_ptr, it likely should NOT be a std::unique_ptr.

std::unique_ptr conveys transferrable ownership which is unhelpful if ownership isn't being transferred.

Smart Pointers and the raw pointers

The stored pointer points to the object managed by the unique_ptr, if any, or to nullptr if the unique_ptr is empty.

std::unique_ptr::get does not make unique_ptr release ownership of the pointer (i.e., it is still responsible for deleting the managed data at some point). Therefore, the value returned by this function shall not be used to construct a new managed pointer.

In order to obtain the stored pointer and release ownership over it, call std::unique_ptr::release instead.

Move

A call to std::move isn’t actually a move itself, it’s just a cast to an rvalue-reference. It’s only the use of that reference by a move constructor or move assignment that does the work.

std::vector<int> foo = GetSomeInts();
std::move(foo); // Does nothing.
// Invokes std::vector<int>’s move-constructor.
std::vector<int> bar = std::move(foo);

Prefer to pass and return std::unique_ptr values

Prefer to pass and return unique_ptr values, NOT pointers or references to unique_ptr

For example, use std::unique_ptr<Foo>, NOT std::unique_ptr<Foo>& or std::unique_ptr<Foo>*.

// unique_ptr as the return value
std::unique_ptr<Foo> GetFoo();

// unique_ptr as an argument
void Bar(std::unique_ptr<Foo> arg);

std::unique_ptr may make the class non-copyable

Having a std::unique_ptr member variable makes this class non-copyable.

Even without explicitly deleting the copy constructor and copy assignment operator. Because std::unique_ptr is not copyable.

When to std::move a std::shared_ptr?

Copying a std::shared_ptr invokes atomic reference count increase and decrease (lock xadd on x86).

Moving a std::shared_ptr is cheap, since it performs a simple pointer swap and avoids touching atomic counters.

std::move also signals the intent to transfer ownership rather than creating an additional co-owner.

std::shared_ptr & std::weak_ptr Control Block Internals

Under the hood, a std::shared_ptr is a fat pointer (16 bytes on 64-bit systems) containing two pointers:

  1. Pointer to the managed object (T*).
  2. Pointer to a dynamically allocated Control Block.
+-----------------------------------------------------------+
|               std::shared_ptr (16 bytes)                  |
|  +---------------------------+-------------------------+  |
|  | Pointer to Managed Object | Pointer to Control Block|  |
|  +-------------+-------------+------------+------------+  |
+----------------|--------------------------|---------------+
                 |                          |
                 v                          v
          +-------------+      +---------------------------+
          |  Object T   |      |       Control Block       |
          +-------------+      | - Strong Count (Atomic)   |
                               | - Weak Count (Atomic)     |
                               | - Custom Deleter          |
                               | - Custom Allocator        |
                               +---------------------------+
  • Strong Count: Number of active std::shared_ptr instances managing the object. When this hits zero, the managed object T is destroyed via its destructor (T::~T()).
  • Weak Count: Number of active std::weak_ptr instances plus 1 (if strong count > 0). The control block itself is not freed from heap memory until the weak count reaches zero.

std::make_shared vs std::shared_ptr<T>(new T) Memory Layout

Understanding the memory allocation difference between std::make_shared and new is vital for systems programming:

1. std::shared_ptr<T>(new T): Two Separate Heap Allocations
   [ Heap Chunk 1: Object T ] <---- Pointer 1
   [ Heap Chunk 2: Control Block ] <- Pointer 2

2. std::make_shared<T>(): Single Contiguous Heap Allocation
   +------------------------------------+-----------------------------+
   |             Control Block          |           Object T          |
   +------------------------------------+-----------------------------+
   ^
   Single Heap Allocation Chunk
  • Advantages of std::make_shared:
    • Efficiency: Performs 1 heap allocation instead of 2.
    • Cache Locality: The control block and object data reside on adjacent memory addresses, improving CPU L1/L2 cache hit rates.
    • Exception Safety: Eliminates the risk of memory leaks if an exception is thrown between new T and the shared_ptr constructor.
  • The Weak Pointer Gotcha of std::make_shared:
    • Because T and the Control Block share a single contiguous memory chunk, the underlying heap memory cannot be deallocated until BOTH strong count AND weak count reach zero.
    • If a std::weak_ptr outlives all std::shared_ptr instances, T's destructor is executed, but sizeof(T) bytes remain occupied in RAM until the last weak_ptr is destroyed. If T is massive (e.g., an 8 MB video frame), prefer std::shared_ptr<T>(new T) so the memory for T is freed immediately when the strong count reaches zero.