Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 4

Smart Pointers: Managing Heap Memory Ownership

Recommended resources

Hello, and welcome back. In our last lesson, we explored the Resource Acquisition Is Initialization (RAII) principle, establishing it as the cornerstone of safe resource management in C++. We saw how fragile manual cleanup is and built a custom RAII wrapper, DeviceBuffer, to safely manage a cudaMalloc'd resource. In doing so, we encountered the critical concept of ownership and the dangers of shallow copying, which we solved by deleting the copy operations to enforce a unique ownership policy.

This lesson builds directly on that foundation. Instead of having to write custom RAII wrappers for every resource, the C++ Standard Library provides a set of powerful, general-purpose tools to do this for us. Today, we will focus on the two most important tools for managing heap-allocated memory: std::unique_ptr and std::shared_ptr. These "smart pointers" are lightweight classes that wrap a raw pointer, automating memory deallocation through RAII. By the end of this lesson, you will know how to use them to express clear ownership semantics—either exclusive or shared—and eliminate manual new and delete from your code, making it safer, cleaner, and more expressive.

The Problem with Raw Pointers

Before we dive into the solution, it's worth taking a moment to fully appreciate the problem smart pointers solve. As we've touched on, managing memory with raw pointers (new and delete) is notoriously error-prone. Common bugs include:

  • Memory leaks: Forgetting to delete memory, especially in functions with multiple return paths or in the face of exceptions.
  • Double frees: Deleting the same memory twice, leading to undefined behavior and likely a crash.
  • Dangling pointers: Using a pointer after the memory it pointed to has been deleted.
  • Unclear ownership: When looking at a function that takes a raw pointer, it's impossible to know who is responsible for deleting it. Does the function take ownership? Is it just borrowing a reference? This ambiguity is a recipe for bugs.

The following video from a CppCon talk by David Olsen provides a great series of short, compelling examples that highlight these issues.

Back to Basics: C++ Smart Pointers - David Olsen - CppCon 2022

This segment from David Olsen's "Back to Basics: C++ Smart Pointers" clearly illustrates why raw pointers are problematic.

Please watch the introduction, from the beginning up to the section on "What's wrong with T*?". The speaker presents three simple code snippets and challenges the audience to find the bugs, which perfectly demonstrate memory leaks and ownership ambiguity. This sets the stage for why a better system is needed.

As you can see, even simple code can harbor subtle and dangerous memory management bugs. The core issue is that a raw pointer T* is just an address; it carries no information about ownership or lifetime. Smart pointers solve this by encoding ownership semantics into the type system itself.


std::unique_ptr: Exclusive Ownership

The simplest and most common smart pointer is std::unique_ptr. It provides a direct, standardized implementation of the unique ownership model we enforced on our DeviceBuffer class in the previous lesson.

A std::unique_ptr<T> is a lightweight object that exclusively owns a heap-allocated object of type T.

  • When the unique_ptr is destroyed (e.g., goes out of scope), its destructor automatically calls delete on the raw pointer it holds, freeing the memory.
  • It is move-only. You cannot copy a unique_ptr, because that would violate its guarantee of unique ownership. You can, however, move it, which transfers ownership from the source pointer to the destination.

The preferred way to create a unique_ptr is with the helper function std::make_unique. This function allocates memory for the object, constructs the object in that memory, and returns a unique_ptr that owns it.




#include <memory>

struct Widget { /*...*/ };

void process_widget() {
    // Create a Widget on the heap, owned by a unique_ptr.
    auto my_widget_ptr = std::make_unique<Widget>();

    // Use the pointer like a normal pointer.
    my_widget_ptr->do_something();

    // No need to call delete!
    // When my_widget_ptr goes out of scope, the Widget is automatically destroyed.
}

Transferring Ownership

Since unique_ptr is move-only, if you want to transfer ownership, you must do so explicitly using std::move. This makes the transfer of ownership a clear, syntactically distinct operation. Once moved from, the original unique_ptr becomes null and no longer owns the object.

auto ptr1 = std::make_unique<int>(42);

// std::unique_ptr<int> ptr2 = ptr1; // COMPILE ERROR: unique_ptr is not copyable.

// Transfer ownership from ptr1 to ptr2.
std::unique_ptr<int> ptr2 = std::move(ptr1);

// Now, ptr2 owns the integer. ptr1 is null.
// if (ptr1) { /* this block is not entered */ }
This diagram illustrates the transfer of ownership via `std::move`. Initially, `unique1` owns the memory. After the move, `unique2` takes ownership and `unique1` is set to `nullptr`.

The following video provides a more thorough walkthrough of unique_ptr, covering its core features and showing how to use it to refactor raw-pointer code.

Back to Basics: C++ Smart Pointers - David Olsen - CppCon 2022

This part of David Olsen's talk provides a complete introduction to std::unique_ptr.

Please watch the section from the beginning of the unique_ptr segment to its conclusion. Pay close attention to: How unique_ptr automates cleanup (RAII). The concepts of move-only types and transferring ownership. The use of std::make_unique as best practice. How to pass and return unique_ptr from functions to transfer ownership. The "gotchas" at the end, which are important practical advice.

As the video explains, unique_ptr has virtually zero overhead compared to a raw pointer. It's a lightweight abstraction that provides immense safety benefits. Your default choice for managing heap-allocated objects should always be std::unique_ptr.


std::shared_ptr: Shared Ownership

While unique_ptr covers many use cases, sometimes you genuinely need shared ownership. This occurs when an object's lifetime is tied to multiple "owners," and it should only be destroyed when the last owner is finished with it. std::shared_ptr models this behavior.

A std::shared_ptr<T> manages an object of type T through reference counting.

  • All shared_ptr instances that own the same object point to a shared control block. This control block contains metadata, including a reference count.
  • When a shared_ptr is copied, the reference count is incremented.
  • When a shared_ptr is destroyed or reset, the reference count is decremented.
  • When the reference count drops to zero, it means the last shared_ptr owning the object is gone. The object is then destroyed and its memory is deallocated.
Internal layout of a `std::shared_ptr`. It holds a pointer to the managed object and another pointer to a control block, which stores the reference count and other data.

Similar to unique_ptr, the preferred way to create a shared_ptr is with a helper function, std::make_shared. Using std::make_shared is even more important than make_unique, as it performs a single, more efficient memory allocation for both the object and its control block.

This concept of shared ownership is not just an academic exercise; it's fundamental to the design of many large C++ frameworks, including the PyTorch C++ API that you will be using.

Using the PyTorch C++ Frontend

This section of the official PyTorch tutorial explains exactly why std::shared_ptr is used for managing neural network modules. This provides excellent context for your goals.

Please read the section titled Module Ownership. It explains that to replicate the easy-to-use reference semantics of Python, the C++ frontend uses std::shared_ptr extensively to manage the lifetime of modules like torch::nn::Linear. It also introduces the TORCH_MODULE macro, a clever device used to reduce the boilerplate of using shared_ptr.

As the PyTorch documentation highlights, shared_ptr is essential when an object (like a neural network layer) is logically owned by multiple other objects (e.g., by the main network Net class and also by the underlying torch::nn::Module base class for registration).

The following video segment provides a detailed explanation of shared_ptr's mechanics and usage.

Back to Basics: C++ Smart Pointers - David Olsen - CppCon 2022

This final segment of the "Smart Pointers" talk covers std::shared_ptr in detail.

Please watch from the beginning of the shared_ptr section to its end. Focus on: How the reference counting model works. The importance of using std::make_shared. The critical rule: never create multiple shared_ptrs from the same raw pointer. Always create one, and then copy it. The brief discussion of thread safety, which is relevant for high-performance code.


Choosing the Right Pointer

You now have two powerful tools. The key is to know when to use each. The guideline is simple:

  1. Default to std::unique_ptr. It is the simplest, most efficient smart pointer. It clearly expresses exclusive ownership and has no runtime overhead. Most objects should have a single, clear owner.
  2. Use std::shared_ptr only when you genuinely need shared ownership. This is when an object's lifetime is fundamentally tied to multiple, non-hierarchical owners, and it's not clear which one will be the last to finish. The PyTorch module example is a perfect case study.
  3. Use raw pointers (T*) for non-owning access. If a function just needs to observe or use an object without affecting its lifetime, pass a raw pointer (obtained via ptr.get()) or, even better, a reference (*ptr).

Here's the final piece of advice from David Olsen's talk, which perfectly summarizes this choice.

Back to Basics: C++ Smart Pointers - David Olsen - CppCon 2022

This short segment provides a clear, actionable rule for deciding between unique_ptr and shared_ptr.

Watch from the section "When to Use". The advice to prefer unique_ptr and only move to shared_ptr when necessary is a core tenet of modern C++ best practices.

Finally, to see both smart pointers used in the PyTorch API, note that while modules use shared_ptr, the function torch::data::make_data_loader returns a std::unique_ptr to a data loader object. This shows that real-world libraries use the right tool for the job, selecting the ownership model that best fits the specific component's role.


Conclusion

In this lesson, we've taken the RAII principle and applied it to memory management using the standard library's smart pointers. You've learned how std::unique_ptr and std::shared_ptr automate memory deallocation, prevent entire classes of common bugs, and make your code's ownership intentions explicit and clear.

Key Takeaways:

  • Smart pointers are RAII wrappers for memory: They bind the lifetime of heap-allocated memory to the scope of a pointer object.
  • std::unique_ptr models exclusive ownership: It's lightweight, fast, and move-only. It should be your default choice. Use std::make_unique to create them.
  • std::shared_ptr models shared ownership: It uses reference counting to ensure an object is destroyed only when the last owner is gone. It's essential for complex ownership graphs, like those in DL frameworks. Use std::make_shared for efficiency.
  • Express intent: Use std::move to explicitly transfer unique ownership. Use shared_ptr copies to express a new shared owner. Use raw pointers or references for non-owning access.

With your understanding of RAII and smart pointers, you have now mastered the foundation of modern C++ resource management. In our next lesson, we will move from managing single objects to managing collections of data. We'll explore the core STL containers you'll use constantly in numerical code: std::vector, std::array, and std::span.