Advanced C++ Workshop 2023 (03)
View all talks at Advanced C++ Workshop (CSCS).
This talk is on memory management in C++.
Memory/Resource Management
One reason I favor C++ is that it gives you a lot of freedom. You want performance? You can work close to the machine and control allocations. You want expressiveness? You can write object-oriented or functional code, and you have powerful templates. But this freedom comes at a cost: you are responsible for more details. The key is how we utilize modern C++ features to write readable, correct, and efficient code.
One fundamental issue in C++ (and a common source of criticism) is managing lifetimes and resources. Here, “resource” is not only memory, but also things like file descriptors/handles, sockets, locks, threads, GPU objects, and so on — anything that must be released/closed/unlocked/joined according to a protocol.
For a long time, C++ was used like “enhanced C”, so it is easy to end up with C-style manual management and memory-safety bugs. The root problem is not that C++ has no lifetime rules, but that it does not enforce a global ownership/borrowing discipline across the whole program: it is still possible to accidentally keep a pointer/reference past the owning object’s lifetime.
Rust is often considered “superior” in this aspect because safe Rust provides compile-time checked ownership/borrowing that prevents broad classes of bugs (use-after-free, double-free, many data races) by construction. Still, C++ can be written in a memory-safe way as well — especially since C++11, with standard smart pointers and modern RAII patterns. This talk collects best practices to get there.
RAII
First of all, what does it mean by managing resources? Essentially, it involves three steps:
- 🤝initializing/acquiring the resource
- ✊use the resource and keep it alive
- 👋cleanly release the resource when it is not useful anymore
This is a typical lifetime of a resource. It can be simple if all three steps are done in one function, but the real complexity appears when the resource is passed around the program.
In procedure-oriented languages like C, the programmer needs to explicitly call the corresponding API to acquire or release the resource. However, in object-oriented languages like C++, we can notice that it aligns with the object lifetime, which leads to the practice of RAII (Resource Allocation Is Initialization).
RAII binds the resource lifetime to object lifetime, and if followed, it ensures that:
- the resource is acquired/allocated/initialized when the object is initialized (constructor)
- the resource will be available throughout the lifetime of the object
- and the resource will be properly released when the object is destroyed (destructor)
However, it is easier said than done. To properly apply RAII, one has to understand ownership first.
Ownership
So, what does ownership mean, and how does it benefit us?
Ownership means: an object has the responsibility and authority to perform the resource’s release action (and therefore decides the resource’s lifetime). Without a clear ownership model, the developer must manage the resource explicitly everywhere; with ownership, you delegate that responsibility to the owning object’s destructor.
Ownership usually shows up in two forms: unique ownership and shared ownership. Expressing them explicitly makes your program more readable, and often more efficient.
Unique ownership means there can only be one object owning the resource at a time. It strictly binds the lifetime of the resource to the lifetime of the object. Once the object gets destroyed, the resource will also get released.
Shared ownership means that there can be multiple objects owning the resource at a time. It ensures that the resource lives throughout the lifetime of all the objects that own it, and is only released when the last owner gets destroyed.
Smart Pointers
C++ expresses ownership primarily via smart pointers.
std::unique_ptr<T>expresses unique ownership.std::shared_ptr<T>expresses shared ownership (reference-counted).std::weak_ptr<T>expresses non-owning observation of ashared_ptr-managed object. It is mainly used to break reference cycles and to “try to access if still alive” vialock().
shared_ptr uses a control block that stores (conceptually) two counts: a strong count (shared owners) and a weak count (weak observers).
- The managed object is destroyed when the strong count reaches zero.
- The control block is destroyed when both strong and weak counts reach zero.
To make the reference counts safe in the presence of concurrency, these increments/decrements are typically implemented with atomic operations. The overhead is usually small, but can become noticeable when shared_ptr is copied frequently in hot paths or shared heavily across threads.
A misunderstanding
I’m not sure if it is just me, but this seems fairly common. For a long time, once I decided to use smart pointers, I would default to std::shared_ptr<T> and pass it everywhere. For example, in the code below, every raw pointer is wrapped with std::shared_ptr<T>.
1 | void process(std::shared_ptr<Resource> resource) |
But the fact is: using a resource does not mean you must own it.
If process only needs to borrow a resource for the duration of the call, then taking a shared_ptr is often too strong: it advertises shared ownership, incurs reference-count traffic, and suggests the function may store it for later.
In that case, a better API is to express non-ownership (borrowing):
- Prefer
T&when the resource must exist (non-null). - Use
T*when “optional / may be null” is meaningful.
For example, use unique ownership in the caller, and borrow in the callee:
1 | void process(Resource& resource) |
One important caveat: borrowing is only safe if the pointer/reference does not escape the call (e.g., stored globally, captured by async callbacks, etc.). If process must extend lifetime beyond the call, then taking an owning type (unique_ptr by value for transfer, or shared_ptr for sharing) can be appropriate. You can not transfer anything if you do not own anything.
As a rule of thumb: prefer unique ownership by default, and only use shared ownership when you truly need “keep alive as long as anyone uses it” semantics.
Rule of N
If you ever use a static analysis tool, you may notice warnings when you define a custom destructor. It may prompt you to also define copy and move constructors/assignment operators. This is tightly related to resource management and the meaning of copying/moving.
A custom destructor often implies that this object is managing a resource. If so, the default copy implementation may be wrong: you might need a deep copy, shared-handle semantics, or you might want to disable copying completely. This leads to the Rule of Three: if you define any of destructor, copy constructor, or copy assignment, you likely need to define all three (or explicitly delete some).
If you also care about efficient transfers (common in modern C++), you should also consider move operations. That leads to the Rule of Five: destructor + copy ctor/assign + move ctor/assign.
In modern C++, the best target is often the Rule of Zero: do not write any of these manually if you can express your type in terms of RAII members that already handle lifetime correctly (e.g., std::vector, std::string, std::unique_ptr).
Implicit definition
This leads to another question: how does the compiler generate implicit copy/move?
Two important notes:
= defaultis explicitly requesting the compiler-generated behavior. “Implicitly generated” means you did not declare it at all.- The exact rules have details (and some standard-version nuances), but the high-level guidance is stable: once you declare one special member function, the implicit generation rules for the others change.
Practical rule of thumb:
- If your type is meant to be copyable/movable, explicitly
= defaultthe operations you want. - If your type must not be copied, explicitly
= deletecopy operations. - If you define any of destructor/copy/move manually, audit the full set (Rule of Five), because “the compiler will do the right thing” may no longer hold.
Resources
Appendix: Self-Check
Ownership (one precise definition)
Ownership means: an object has the responsibility and authority to perform the resource’s release action (and therefore decides the resource’s lifetime). If an object owns a resource, it must guarantee release exactly once under all control flows (including exceptions).
Borrowing (non-ownership) means: a pointer/reference can be used to access a resource, but it does not control lifetime and must not outlive the owner.
RAII: what “acquire/use/release” means + one concrete example
In RAII, you move “release” into a destructor so it always runs on scope exit (including early returns and exceptions).
Example: mutex locking
1 | std::mutex m; |
The same pattern applies to files/handles/sockets: acquisition establishes the invariant “this object will later release it”, and destruction performs the inverse operation.
API design: choose T&
These parameter types communicate different lifetime/ownership intent:
T&: borrow, non-null, does not extend lifetime (caller guarantees the object outlives the call).T*: borrow, nullable/optional (you must document whatnullptrmeans).std::unique_ptr<T>(by value): transfer unique ownership into the function (callee decides lifetime).std::shared_ptr<T>(by value): share ownership (callee keeps the object alive by holding a counted reference).
If a function only needs to read/write an object during the call, prefer borrowing (T&/T*). If it must keep it beyond the call (store it, run async work), prefer an owning type.
shared_ptr vs weak_ptr: what is destroyed when? + cycles
std::shared_ptr<T> is reference-counted ownership.
- The managed
Tis destroyed when the strong count reaches 0. - The control block is destroyed when both strong and weak counts reach 0.
std::weak_ptr<T> is non-owning observation. It does not keep T alive; instead you call lock() to attempt to obtain a shared_ptr<T> if the object is still alive.
Cycles: if two objects hold shared_ptr to each other, their strong counts never reach 0. Break the cycle by making one direction a weak_ptr.
Rule of Zero / Three / Five: what to do in practice
- Rule of Zero: prefer composing RAII members so you don’t implement destructor/copy/move yourself.
- Rule of Three: if you define destructor/copy ctor/copy assign, you likely need to define (or delete) the others.
- Rule of Five: if you manually manage a resource, also consider move ctor/move assign.
Practical checklist:
- Decide whether the type should be copyable.
- If not copyable:
Type(const Type&) = delete; Type& operator=(const Type&) = delete; - If copyable/movable: explicitly
= defaultwhat you want, especially once you declare any special member.








