Pointer types
To begin, let’s summarize modern C++ pointers (including ordinary, non-smart ones) and their general intended usage here.
std::unique_ptr
- exclusive ownership
Expresses exclusive ownership of an object. Can point to only one object, and the object is automatically destroyed when the pointer itself goes out of scope.
The pointer cannot be copied (but it can be moved).
Performance is the same as a raw pointer during standard operations, eg: dereferencing.
*
(raw pointer) - no ownership
Should be used to express that the object is not owned by the pointer at all. We’re all already familiar with this one, since it’s not actually a smart pointer. The distinction here is that from now on, we’re going to agree that we interpret its use to mean that it has no ownership of an object at all.
&
(reference) - no ownership
Also used to express no ownership at all. The difference is that a raw pointer is nullable.
One should prefer references whenever a null
value is not a requirement of the design, since it will be valid at runtime and requires no checks for nullptr
.
std::shared_ptr
and std::weak_ptr
- shared ownership
Expresses shared ownership. An object can be pointed to by one or more shared_ptr
. The object is automatically destroyed when all shared pointers that point to it, go out of scope.
Shared pointers can be copied.
Performance is less than a raw pointer, because there is additional logic surrounding the counting of references when using them.
Use shared_ptr
sparingly
Overuse of shared pointers may indicate bad design, and can degrade application performance. Additionally, they can introduce unique bugs, especially the circular dependency issue, where two smart pointers point at each other and the memory is locked and will never free.
Use std::weak_ptr
to fix circular shared pointers
The STL introduced std::weak_ptr
as a counterpart to shared_ptr
in order to fix the circular dependency issue when two smart pointers point at one-another. Pairing a shared pointer with a weak_ptr
will allow the memory to free in this circumstance, when used properly.
\ RAII and smart pointer scope
The important thing to remember is that smart pointers leverage RAII to make managing memory more intuitive.
In general, this means that when smart pointers go out of scope (eg: reaching the end of a code block), the pointer is “smart” and does something automatically in response to the scope change.
Best practices
Programmers have been converging on a set of best practices for managing memory using smart pointers in C++ since their introduction in C++11.
Like many things in the language, some of the details are still heavily debated in the community. Let’s go over some of the most important best practices to remember.
Object ownership is the key
When we speak of memory management in C++, the best way to think is in terms of ownership of resources (those resources usually being objects).
The simple explanation is that much of C++’s modern memory management design relies on the concept of object ownership being at the heart of memory allocation and deallocation, by coupling object lifecycle with the RAII idiom.
Smart pointers in C++ therefore by design all rely on the concept of object ownership. eg: std::unique_ptr
expresses a pointer that uniquely (exclusively) owns an object.
Using function signatures to clearly express ownership
One of the benefits of consistently using smart pointers in the way we’ve described is that your function signatures will begin to document themselves in terms of their memory management expectations and design. The semantics of smart pointers can make object ownership intent of a function very obvious when used consistently.
Let’s explore some of the most common scenarios.
Functions which transfer ownership
These functions generally feature std::unique_ptr
as parameter, and express that unique ownership is either given or taken by the function.
\ Giving exclusive ownership (factory function)
// prepare a blank cheque
std::unique_ptr<Cheque> make_blank_cheque() {
auto chq = std::make_unique<Cheque>();
// ...
return std::move(chq);
}
\ Taking exclusive ownership (consumer)
// at the end of this function, the chq object
// is deleted since it runs out of scope
float deposit_cheque(std::unique_ptr<Cheque> chq) {
return balance;
}
// usage - caller must explicitly consent to moving
// the object (will not compile otherwise)
auto chq = make_blank_cheque();
deposit_cheque(std::move(chq));
Functions which do not modify ownership
Functions which do work on objects (without modifying memory ownership) fall into this category. They are characterized by taking raw pointers and references as parameter.
The unspoken rule is that these functions promise not to modify the object’s lifetime (eg: delete it).
\ Non-ownership: & or * as parameter
// & or * ==> no ownership
float get_account_number(Cheque& chq);
float get_account_number(Cheque* chq);
// use with .get() on a std::unique_ptr
auto chq = std::make_unique<Cheque>();
auto num = get_account_number(chq.get());
Shared ownership functions
You’ve probably guessed, these will feature std::shared_ptr
in their signatures.
\ Creating a shared ownership resource
// image should be available to all, until nobody needs it
std::shared_ptr<Image> image_load(const std::string& path) {
return std::make_shared<Image>(path);
}
Passing a shared pointer to a function will implicitly make a copy of the pointer (and increase the reference count).
\ Delegating partial ownership of a shared resource
void renderThumbnail(std::shared_ptr<Image> img) {
// ...
}
void renderFull(std::shared_ptr<Image> img) {
// ...
}
Beware of shared_ptr
Best practices suggest to be suspicious of using shared_ptr
very often, for most problems. Often, the problem can be solved with a unique_ptr
exchanging ownership or providing non-owning access instead.
They are best reserved for particular scenarios, eg: in a data structure where nodes don’t have a clear single owner. For example, a tree or list where a node might be owned by one or more other nodes at runtime, depending how it is used.
\ One of the problems with shared_ptr
If we continue to think in terms of object ownership, overuse of
std::shared_ptr
muddies our thinking because it expresses a many-to-one relationship where many objects own another object. It often becomes hard to keep track and reason about how the owned object’s lifecycle is managed. If you encounter that, it may be an indication that a better memory ownership design exists.
Summary of best practices
Let’s summarise some best practices when managing memory and object ownership in modern C++.
General
- Do not use
new
ordelete
- Smart pointers can be used to make object ownership behaviour of your functions more clear
- Use the standard allocation functions given by the STL, eg:
std::make_unique
andstd::make_shared
- Prefer
unique_ptr
whenever possible - Prefer
&
over*
for visiting functions. Use*
when the value needs to supportnullptr
- Be suspicious of
shared_ptr
usage - Lean on RAII as much as possible to make memory management easy
- Stack allocation (if possible) is still always better than heap allocation. Avoid using pointers at all if you can.
- Provide documentation for the behaviour ownership modifying functions even if it seems clear to you
References
- Pikus, Fedor G. (2023) - Hands-On Design Patterns with C++ exp