unique_ptr, shared_ptr, weak_ptr, or reference_wrapper for class relationships

These modern C++ constructs express relationships among classes more intelligibly than raw pointers.

Tutorial | Jul 5, 2020 | nextptr 

1. Overview

Classes in C++ can be related through inheritance, by having instance members, or by having referring members (e.g., pointers) to other types.

In this article, we ignore the inheritance and refer to other relationships as associations. Here, we will explore how the Modern-C++ constructs, namely — unique_ptr, shared_ptr, weak_ptr, and reference_wrapper — can be used to create associations between classes with clearer purposes.

2. It's about ownership

Classes communicate with each other by having handles as members that refer to other classes. The choice of those referring-handles (e.g., pointers or references) is mostly driven by ownership or control-over-lifetime semantics.

Before the Modern-C++, programmers had only references (&) and raw pointers to form the associations between classes. References are safe and they sufficiently represent the reference semantics, but they are too restrictive. References are hard to deal with as class members because they make an enclosing class non-assignable and immovable. For that reason, plain references are often avoided as class members. Raw pointers, on the other hand, are highly flexible catchalls but error-prone. Moreover, raw pointers are severely inadequate means to express all the intents.

Associations between classes can be seen through the viewpoint of ownership. An object can solely own another object, or it can share the ownership of an object with others, or it does not own but only refers to an object. The following sections show more appropriate means to express those associations than the raw pointers.

2.1. Sole ownership

A class can directly own another class's instance as a member variable if the intention is to have sole ownership or composition. However, there are situations where a member should be created dynamically — for instance, when the member is polymorphic or it needs to be released when not needed. In those cases, we can use a unique_ptr to control the uniquely owned object. Besides, it is also possible to transfer the exclusive ownership by moving a unique_ptr:

/*Polymorphic parser hierarchy*/
struct Parser {
 //...
};
struct SpecialParserA : Parser {
 //...
};
struct SpecialParserB : Parser {
 //...
};

struct Protocol {
 //....
 Protocol(int parserType /*, more args */) {
  //create a Parser depending on the args
  if(parserType == 1)
   parser = std::make_unique<SpecialParserA>();
  else 
   parser = std::make_unique<SpecialParserB>();

  //...
 }
 std::unique_ptr<Parser> parser;
};

Needless to say that a unique_ptr is better than a raw pointer for safer resource management and capturing the sole-ownership intent. Raw pointers do not establish the unique ownership themselves. A class that uses raw pointers for unique ownership must explicitly implement the move-construction/assignment and disallow copy-construction/assignment — that is too much of boilerplate code.

2.2. Shared ownership

In those situations where a class needs to share the ownership of an instance with other classes, the correct approach is to have a shared_ptr member. Multiple classes can share ownership of an object through many instances of shared_ptr. The owned object is guaranteed to stay alive as long as there is at least one shared_ptr holding it.

In the following example, multiple Sender objects share the ownership of a Connection object:


shared ownership through shared_ptr


struct Connection {
 //...
};

struct Sender {
 std::shared_ptr<Connection> connection;
};

Achieving shared ownership through raw pointers often ends up in reinvention of the wheel.

2.3. Weak shared ownership

Sometimes a class needs to share ownership, but it should not have firm control over the lifetime of the owned object. In those cases, a weak ownership handle is required that can convert to a strong handle on-demand. That weak handle in Modern-C++ is weak_ptr.

A weak_ptr represents a weak form of shared ownership. A weak_ptr can convert to a shared_ptr on-demand. The conversion to shared_ptr successfully happens if there is at least one shared_ptr still holding the managed object.

In the following example, a custom object cache keeps a weak_ptr to each cached item. By doing so, the cache does not ordinarily control an item's lifetime but creates and returns a shared_ptr to it when requested by multiple clients. This way, an item stays in memory for only as long as it is in use by the clients:


weak shared ownership


struct Item {
 //...
};

struct Cache {

 auto getItem(int id) {

  std::shared_ptr<Item> ret; 
  //Search entry in the map
  auto itr = itemsById.find(id);   
  if(itr != itemsById.end()) {
   //Found entry in the map
   //Try to acquire a shared_ptr<Item> from weak_ptr<Item>
   ret = itr->second.lock();
  }

  if(!ret) {
   /*Either item is expired or entry is not found in the map.
     Load fresh item from DB, initialize a shared_ptr, 
       and insert a weak_ptr in the map*/

   ret = std::make_shared<Item>(); //Initialize a shared_ptr     
   itemsById[id] = ret; //Insert a weak_ptr in the map
  }

  //Return the shared_ptr<Item>   
  return ret;
 }

 std::map<int, std::weak_ptr<Item>> itemsById; //Cache entries map
};


struct Client {
 //...
 //The item is acquired from Cache
 std::shared_ptr<Item> item;
};    

2.4. No ownership

If a class merely refers or uses but does not control an object's life, it does not own that object. In classic OOP, it is known as the Using relationship. Strictly going by the design, a reference (&) class member should be ideal for expressing this relation. But in practice, this relation is implemented with raw pointers because references are too restrictive. References cannot rebind, and a class holding a reference member becomes non-assignable and non-movable.

However, the problem with raw pointers is that they are too general and require excessive explicit validation. Raw pointers do not appropriately reflect reference semantics. Modern C++ offers a middle-ground solution for that in the form of std::reference_wrapper<T>.

A reference_wrapper<T> is a copyable and assignable object that wraps a pointer (T*) but imitates a reference (T&). A reference_wrapper can rebind to another object if required.

A reference_wrapper can be stored in an STL container also. For instance, in the following code, a class maintains a vector of reference_wrapper to observers:

struct Observer {
 //...
 void notify();
};

struct Observed {
 //...
 void action() {
  //notify all observers
  for(auto& ob : observers)
   ob.get().notify();
 }
 std::vector<std::reference_wrapper<Observer>> observers;
};

A reference_wrapper cannot be null; it must refer to a valid object. This feature could be limiting in some cases that require nullable references. But if you are using C++17, you don't have to resort to using raw pointers yet. C++17 provides std::optional<T> that can wrap a reference_wrapper to model a nullable reference:

struct Db {
 void persist(const std::string& s); 
};

struct Processor {
 //...
 void process(const std::string& s) {
  //processes data and then optionally saves to DB
  if(dbRef) { //Check if reference is not null
   //save data to DB 
   dbRef->get().persist(s);
  }
 }
 std::optional<std::reference_wrapper<Db>> dbRef;
};

3. Conclusion

Associations among classes can be perceived through the ownership. Modern-C++ offers various means for classes to refer to each other with different semantics, which should be preferred over the raw pointers.

4. Further Reading

std::ref and std::reference_wrapper: common use cases - nextptr

Using weak_ptr for circular references - nextptr

shared_ptr - basics and internals with examples - nextptr

2015 nextptr