These modern C++ constructs express relationships among classes more intelligibly than raw pointers.
Tutorial | Jul 5, 2020 |
c++ shared-ptr weak-ptr unique-ptr reference smart-pointer c++11
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
sole / sEul; NAmE soul / ◙ adj. [only before noun]
- only; single
• 仅有的;唯一的:
»the sole surviving member of the family
那一家唯一在世的成员
»My sole reason for coming here was to see you.
我到这儿唯一的原因就是来看你。
»This is the sole means of access to the building.
这是这栋建筑物唯一的入口。
- belonging to one person or group; not shared
• 独占的;专有的;全权处理的:
»She has sole responsibility for the project.
那个项目由她一人负责。
»the sole owner
拥有全部产权的物主
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:
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:
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
A reference_wrapper
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
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