C++ and smart pointers – my notes

Some notes, when from my process of learning smart pointers.

Quick Reminder about Memory

heap vs stack cpp

Why do we need pointers?

The general notion of a pointer is something that allows us to refer to an object and to access it according to its type.

  • Observing objects
    • indirection without copying: referencing / keeping track of objects
    • if we want to change the target of an indirection at runtime ⇒ can’t use references
  • Accessing Dynamic Memory
    • access objects of dynamic storage duration i.e., objects whose lifetime is not tied to a variable / a scope
  • Building Dynamic, Node-Based Data Structures
cpp usage of pointers
Usage of pointers

What actually is a pointer?

Pointer to Object of Type T:

  • stores a memory address of an object of type T
  • can be used to inspect/observe/modify the target object
  • can be redirected to a different target (unlike references)
  • may also point to no object at all (be a Null Pointer)
cpp what is pointer
What is pointer?

Raw Pointers: T*

  • essentially an (unsigned) integer variable storing a memory address
  • size: 64 bits on 64 bit platforms
  • many raw pointers can point to the same address / object
  • lifetimes of pointer and taget (pointed-to) object are independent

Smart Pointers

std::unique_ptr<T>

  • used to access dynamic storage, i.e., objects on the heap
  • only one std::unique_ptr per object
  • pointer and target object have same lifetime

std::shared_ptr<T> / std::weak_ptr<T>

  • used to access dynamic storage, i.e., objects on the heap
  • many std::shared_ptrs and/or std::weak_ptrs per object
  • target object lives as long as at least one shared_ptr points to it

Example of std::unique_ptr pointing to aggregate object on the heap

struct point {
  int x;
  int y;
}

auto ptr = std::make_unique<point>(44, 55);

Ownership

There can be more than one pointer pointing to an object. An owning pointer is one that is responsible for eventually deleting the object it refers to. A non-owning pointer (e.g., a T* or a span) can dangle; that is, point to a location where an object has been deleted or gone out of scope.

An object is said to be an „owner” of a resource, if it is its responsibility to de-initialization / destruction / cleanup.

Objects on the heap should be destroyed as soon as no one is pointing to them.

Reading or writing through a dangling pointer is one of the nastiest kinds of bugs. The result of doing so is technically undefined. In practice, that often means accessing an object that happens to occupy the location. Then, a read means getting an arbitrary value, and a write scrambles an unrelated data structure. The best we can hope for is a crash; that’s usually preferable to a wrong result.

The C++ Core Guidelines [CG] offers rules for avoiding this and advice for statically checking that it never happens. However, here are a few approaches for avoiding pointer problems:

  • Don’t retain a pointer to a local object after the object goes out of scope. In particular, never return a pointer to a local object from a function or store a pointer of uncertain provenance in a long-lived data structure. Systematic use of containers and algorithms (Chapter 12, Chapter 13) often saves us from employing programming techniques that make it hard to avoid pointer problems.
  • Use owning pointers to objects allocated on the free store.
  • Pointers to static objects (e.g., global variables) can’t dangle.
  • Leave pointer arithmetic to the implementation of resource handles (such as vectors and unordered_maps).
  • Remember that string_views and spans are kinds of non-owning pointers.

std::unique_ptr<T>

std::unique_ptr is a lightweight mechanism with no space or time overhead compared
to correct use of a built-in pointer. Its further uses include passing free-store allocated objects in and out of functions.

A std::unique_ptr is a handle to an individual object (or an array) in much the same way that a vector is a handle to a sequence of objects. Both control the lifetime of other objects (using RAII) and both rely on elimination of copying or on move semantics to make return simple and efficient.

  • holds one object (allocated on the heap)
  • pointer destroyed => object destroyed
  • unique ownership: only one std::unique_ptr can own an object
  • not copyable (but movable)
void foo() {
    std::unique_ptr<int> p = std::make_unique<int>();
    *p = 20;
} // <- object "p" is destroyed automatically
void foo() {
    auto p1 = std::make_unique<int>(20);
    {
        auto p2 = std::move(p1); // OK -> p2 owns object now, p1 holds nullptr
        // auto p3 = p2;         // COMPILER ERROR
    } // p2 and object is destroyed
} // p1 is destroyed

std::shared_ptr<T>

The std::shared_ptr is similar to std::unique_ptr except that std::shared_ptrs are copied rather than moved. The std::shared_ptrs for an object share ownership of an object; that object is destroyed when the last of its std::shared_ptrs is destroyed.

std::shared_ptr provides a form of garbage collection that respects the destructor-based resource management of the memory-managed objects. This is neither cost free nor exorbitantly expensive, but it does make the lifetime of the shared object hard to predict. Use std::shared_ptr only if you actually need shared ownership.

  • holds one object (allocated on the heap)
  • shared ownership: many std::shared_ptrs keep the same object alive
  • object deleted, if last std::shared_ptr destroyed
  • copyable
cpp shared ownership
Shared ownership
struct A {
    int x;
}

void foo() {
    std::shared_ptr<A> ptr = std::make_shared<A>();
    p->x = 5;
}
void foo() {
    auto p1 = std::make_shared<int>(20);
    {
        auto p2 = p1;         // p2 and p1 share ownership
    } // p2 destroyed, p1 kept object alive
} // p1 and object is destroyed

A std::shared_ptr can tell whether it’s the last one pointing to a resource by consulting the resource’s reference count, a value associated with the resource that keeps track of how many std::shared_ptrs point to it. std::shared_ptr constructors increment this count, std::shared_ptr destructors decrement it, and copy assignment operators do both.

The existence of the reference count has performance implications:

  • std::shared_ptrs are twice the size of a raw pointer, because they internally
    contain a raw pointer to the resource as well as a raw pointer to the resource’s
    reference count.
  • Memory for the reference count must be dynamically allocated.
  • Increments and decrements of the reference count must be atomic, because there can be simultaneous readers and writers in different threads.
shared ptr specs

In C++, std::shared_ptr is a smart pointer that provides shared ownership of an object. It is typically implemented using reference counting, which means that the std::shared_ptr object keeps track of how many std::shared_ptr objects are referring to the same object. The reference count is stored in a separate control block that is created along with the std::shared_ptr object.

The control block is a separate block of memory that is allocated on the heap and contains information about the shared object, such as its reference count, the deleter function (if any), and a pointer to the shared object itself. When a std::shared_ptr object is created, it is initialized with a pointer to the control block, and the control block’s reference count is set to 1.

Each time a new std::shared_ptr object is created that refers to the same shared object, the reference count is incremented by 1. Similarly, when a std::shared_ptr object is destroyed or reset, the reference count is decremented by 1. When the reference count reaches zero, the control block is destroyed and the shared object is deleted (using the deleter function, if any).

Therefore, the reference count is stored in the control block associated with the std::shared_ptr object, and not in the std::shared_ptr object itself. This allows multiple std::shared_ptr objects to refer to the same shared object, while keeping track of the reference count in a central location.

std::make_shared<T>(args…)

Using std::make_shared<T>(args...) is not just more convenient than separately making an object using new and then passing it to a std::shared_ptr – it is also notably more efficient because it does not need a separate allocation for the use count that is essential in the implementation of a std::shared_ptr.

  • forwards all constructor arguments
  • performance advantages
  • important for exception safety

std::weak_ptr<T>

It is basically the std::shared_ptr, but does not keep object alive.

void foo() {
    auto sp = std::make_shared<int>();
    auto wp = std::weak_ptr<int>(sp);

    auto sp2 = wp.lock();   // make std::shared_ptr from std::weak_ptr
    
    sp = nullptr;           // sp2 keeps object alive

    sp2 = nullptr;           // object destroyed

    if (wp.expired()) {      // is object still reachable?
        // std::weak_ptr expired
    }
}

Some Tips

Deep copy (one the heap)

auto p = std::make_unique<point>(44, 55);
auto s = std::make_unique<point>(*p);
cpp deep copy pointer
Deep copy example with pointers

Reference:

Expressive code with C++ Smart Pointers – fluentcpp.com / Jonathan Boccara

https://stroustrup.com/Tour.html

https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#S-resource

https://hackingcpp.com/cpp/lang/pointers.html

https://hackingcpp.com/cpp/std/unique_ownership.html

https://hackingcpp.com/cpp/std/shared_ownership.html

My other cpp articles:

https://mateuszrzeczyca.pl/c-and-shallow-copy-vs-deep-copy/

https://mateuszrzeczyca.pl/c-and-containers-stl-my-notes/