0%

Smart Pointers in C++ - Guidelines

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

Smart Pointers Guidelines

  • How to write robust, safe, and efficient code that uses smart pointers
  • How to return/pass smart pointers from/to functions
  • The role of raw pointers in Modern C++
  • How to choose between raw/unique/shared pointers
  • Data structure and argument passing code examples

No Allocation is Better than Allocation

  • Making use of the stack and value semantic types should be preferred to dynamic allocation
  • Objects on the stack are easier to reason about and more *”predictable”*
  • Dynamic memory usage can have a significant cost: allocations are not free, they can reduce cache locality, and the compiler is often note able to aggressively optimize
  • Sometimes allocations are necessary - allocate only when you need to
  • Always use smart pointers - do not use new/delete

std::unique_ptr as Your First Choice

  • If dynamic allocation is necessary, std::unique_ptr should be your first choice
  • It is a zero-cost abstraction over new/delete (except for some rare cases)
  • It models unique ownership, which is simple and easy to reason about

std::shared_ptr Should Be Used Sparingly

  • std::shared_ptr should be used sparingly
  • It is not a zero-cost abstraction over new/delete - it can have significant overhead due to atomic reference counting operations
  • Shared ownership can be harder to reason about

Always Use std::make_xxx Functions to Create Smart Pointers

  • They prevent potential memory leaks
  • They make the code terser and more readable
  • They do not require new operator to be explicitly called
  • They can greatly improve performance for std::shared_ptr

Role of Raw Pointers in Modern C++

  • In Modern C++, raw pointers are *”optional references”* to an object
  • A raw pointer should never imply ownership
  • As long as raw pointers are not managing memory, they’re fine to use

Examples

Example 1

The first simple data structure is a forward linked list. As you can see, it is a simple template class that contains a value in a unique_ptr to another instance of the linked list called _next. This is very simple to understand and is a zero-cost abstraction over a raw pointer. The same ideas apply to a binary_tree.

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

template <typename T>
struct linked_list
{
T _value;
std::unique_ptr<linked_list<T>> _next;
};

template <typename T>
struct binary_tree
{
T _value;
std::unique_ptr<binary_tree<T>> _left;
std::unique_ptr<binary_tree<T>> _right;
binary_tree<T> *_parent;
};

Now imagine you’re working on a 3D game or 2D application and you have expensive textures and 3D meshes that you want to share between your objects, and you also might want to destroy as soon as possible to prevent extended memory usage.

What you can do is to model them with shared_ptr. So, in this case, the game_object structure will have a shared_ptr to a texture and a shared_ptr to a 3D mesh, and they will be used for rendering. When all the game_objects using a particular texture or mesh are destroyed, then that texture or mesh will be unloaded from memory, so that the memory usage stays low and resources can be efficiently shared between objects.

1
2
3
4
5
6
7
8
9
#include <memory>

struct texture;
struct mesh;
struct game_object
{
std::shared_ptr<texture> _texture;
std::shared_ptr<mesh> _mesh;
};

Finally, here’s the code showing a very simple cache data structure that contains a map that goes from a string to a weak_ptr of T. You can think of this as a cache that models maybe least use relationship, and you can use weak_ptr to try to access an object that may still be valid or invalid, depending on your caching strategy. If it’s valid, you can simply use the lock member function to access the data; if it’s invalid, you can detect it using the expired member function and simply evict the weak_ptr from the cache.

1
2
3
4
5
6
7
8
9
#include <memory>
#include <unordered_map>
#include <string>

template <typename T>
struct cache
{
std::unordered_map<std::string, std::weak_ptr<T>> _items;
};

Example 2

  • Observing / mutating an object: (pass object by reference)

    1
    2
    void f0(int&);
    void f1(const int&);
  • Observing / mutating a smart pointer: (pass pointer by reference)

    1
    2
    3
    4
    5
    6
    void f2(std::unique_ptr<int>&);
    void f3(const std::unique_ptr<int>&);
    void f4(std::shared_ptr<int>&);
    void f5(const std::shared_ptr<int>&);
    void f6(std::weak_ptr<int>&);
    void f7(const std::weak_ptr<int>&);
  • Transferring ownership: (pass by value)

    1
    void f8(std::unique_ptr<int>);
  • Sharing ownership: (pass by value)

    1
    void f9(std::shared_ptr<int>);
  • Observing / mutating an optional object: (pass by raw pointer)

    1
    2
    void f10(int*);
    void f11(const int*);

Summary

  • Learned to follow the rule of zero and the rule of five
  • Understood that the smart pointers can hold generic resources thanks to custom deleters
  • Used = default where possible to let the compiler implicitly generate copy/move operations