Skip to main content

Welcome to a Grasp lesson

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

Lesson 31

Modernizing C++: From Raw Pointers to Smart Pointers

Recommended resources

Hello! Welcome to the final lesson in our module on C++ resource management.

Over the past few lessons, we've dissected the core principles of modern C++ resource handling. We started with the RAII idiom, then explored std::unique_ptr for exclusive ownership, std::shared_ptr for shared ownership, and finally std::weak_ptr to handle complex scenarios like cyclical references.

Today, we'll put all that theory into practice. Our goal is to refactor C-style code that uses raw pointers and manual memory management into modern, safe, and expressive C++ using smart pointers. This lesson is the capstone of the module, where you'll synthesize everything you've learned to transform brittle, error-prone code into robust and maintainable C++.

The Problem with Raw Pointers

Your C background gives you a solid understanding of how pointers work at a low level, but it also means you're familiar with their dangers: memory leaks, double deletions, dangling pointers, and ambiguous ownership. Before we refactor, let's watch a brief segment that vividly illustrates these common bugs.

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

In his CppCon 2022 talk "Back to Basics: C++ Smart Pointers", David Olsen starts by showing a few simple C-style code snippets and asking "Where's the bug?". These examples are perfect reminders of why manual memory management is so hazardous.

Watch from the beginning to 04:21. Pay attention to how memory leaks can occur not just through simple mistakes, but also through early returns and exceptions. Notice the commentary on how raw pointers fail to communicate ownership intent.

As the video highlights, the core issues with raw pointers are:

  • Unclear Ownership: Who is responsible for calling delete? The function that created the object? The function that received it? The type system doesn't help.
  • Resource Leaks: It's easy to forget delete, and even if you remember, an early return or an exception can skip the cleanup code entirely.
  • Undefined Behavior: Calling delete on the wrong type of pointer (e.g., single-object delete on an array) or deleting the same pointer twice leads to crashes or corruption.

The "Before" Picture: A C-Style Example

Let's consider a typical C-style C++ program using raw pointers to manage a collection of polymorphic objects. Given your systems background, you'll recognize the manual new/delete dance and the potential for trouble.

#include <iostream>
#include <vector>
#include <string>

// A simple polymorphic hierarchy
struct Shape {
    // A virtual destructor is crucial for polymorphic deletion!
    // We covered this in Module 3.
    virtual ~Shape() { std::cout << "Shape destroyed\n"; } 
    virtual void draw() const = 0;
};

struct Circle : public Shape {
    ~Circle() { std::cout << "Circle destroyed\n"; }
    void draw() const override { std::cout << "Drawing a Circle\n"; }
};

struct Square : public Shape {
    ~Square() { std::cout << "Square destroyed\n"; }
    void draw() const override { std::cout << "Drawing a Square\n"; }
};

// A factory function that returns a raw pointer.
// The caller is now responsible for this memory.
Shape* create_shape(const std::string& type) {
    if (type == "circle") return new Circle();
    if (type == "square") return new Square();
    // What if an invalid type is passed? The caller gets a nullptr.
    return nullptr;
}

void process_shapes_c_style() {
    std::vector<Shape*> shapes;
    shapes.push_back(create_shape("circle"));
    shapes.push_back(create_shape("square"));
    
    // Imagine an exception is thrown here... the memory is leaked.
    // if (some_condition) { throw std::runtime_error("Something went wrong!"); }

    std::cout << "--- Drawing shapes (C-style) ---\n";
    for (const auto& s : shapes) {
        if (s) {
            s->draw();
        }
    }
    
    // Manual, verbose, and error-prone cleanup.
    // Forgetting this loop results in a memory leak.
    std::cout << "--- Cleaning up shapes (C-style) ---\n";
    for (auto s : shapes) {
        delete s;
    }
    shapes.clear();
}

This code works, but it's fragile. The process_shapes_c_style function has to meticulously manage the memory of the objects it creates. The ownership is implicit, not enforced by the compiler.


The "After" Picture: Refactoring to Modern C++

Our goal is to eliminate manual new and delete and make ownership explicit. The key is to apply the RAII principle using the smart pointers we've studied.

Step 1: Default to std::unique_ptr for Exclusive Ownership

In our example, the std::vector is the sole owner of the Shape objects. There's no need for shared ownership. This is the perfect use case for std::unique_ptr, which represents exclusive, unique ownership with zero performance overhead compared to a raw pointer.

Let's start by refactoring the create_shape factory function. Instead of returning a raw pointer and implicitly transferring ownership, we'll return a std::unique_ptr to make the ownership transfer explicit and safe.

#include <memory> // For smart pointers

// Modern factory function returns a std::unique_ptr.
// Ownership is clearly and safely transferred to the caller.
std::unique_ptr<Shape> create_shape_modern(const std::string& type) {
    if (type == "circle") return std::make_unique<Circle>();
    if (type == "square") return std::make_unique<Square>();
    return nullptr;
}

Notice we also use std::make_unique, which is the preferred way to create unique_ptrs. It's safer (prevents some obscure leak scenarios) and more concise.

Now, we can refactor process_shapes to use our modern factory and a std::vector of std::unique_ptrs.

void process_shapes_modern() {
    // The vector now owns the objects via unique_ptr.
    std::vector<std::unique_ptr<Shape>> shapes;
    
    // We must use std::move to transfer ownership into the vector.
    shapes.push_back(create_shape_modern("circle"));
    shapes.push_back(create_shape_modern("square"));
    
    std::cout << "\n--- Drawing shapes (Modern C++) ---\n";
    for (const auto& s : shapes) {
        if (s) {
            s->draw();
        }
    }

    std::cout << "--- Cleaning up shapes (Modern C++) ---\n";
    // No cleanup loop needed!
    // When 'shapes' goes out of scope, its destructor is called.
    // This calls the destructor for each unique_ptr, which in turn
    // calls delete on the managed Shape object.
}

The result is transformative:

  1. No delete: The manual cleanup loop is gone. Resource management is now automatic.
  2. Exception Safety: If an exception is thrown, the stack unwinds. The shapes vector destructor will be called automatically, ensuring all Shape objects are correctly destroyed. No leaks!
  3. Clear Intent: The code now clearly states that the vector owns the Shapes. A unique_ptr cannot be copied, only moved, which the compiler enforces. This prevents accidental creation of multiple owners.

To reinforce this pattern, let's watch another segment from David Olsen's talk where he refactors a similar function.

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

This part of the talk directly addresses how to refactor functions that pass and return raw pointers to use std::unique_ptr.

Watch from 17:51 to 20:06. Observe how passing and returning std::unique_ptr by value leverages move semantics to efficiently and safely transfer ownership.


When to Use std::shared_ptr

std::unique_ptr should be your default choice. But what if ownership truly needs to be shared?

Let's modify our requirements. Imagine we have a ShapeCache that stores some frequently used shapes by an ID, and we don't know if the main shapes vector or the ShapeCache will outlive the other. In this case, the last one holding a reference should be responsible for cleanup. This is the definition of shared ownership, and the tool for the job is std::shared_ptr.

#include <map>

void process_shapes_shared() {
    // The vector and cache will share ownership.
    std::vector<std::shared_ptr<Shape>> shapes;
    std::map<int, std::shared_ptr<Shape>> cache;

    // Use std::make_shared.
    auto circle = std::make_shared<Circle>();
    auto square = std::make_shared<Square>();

    shapes.push_back(circle);
    shapes.push_back(square);

    // Both the vector and the cache now point to the same Circle object.
    // The reference count for the Circle is now 2.
    cache[101] = circle; 

    // ... work with shapes and cache ...

    // Again, no manual cleanup needed. When the last shared_ptr to an
    // object is destroyed (wherever it may be), the object is deleted.
}

As you appreciate the "under the hood" details, it's vital to know that this convenience comes at a cost. The atomic operations required to update the reference count of a std::shared_ptr can have a significant performance impact, especially in multi-threaded code.

C++ Smart Pointers - Usage and Secrets - Nicolai Josuttis

Nicolai Josuttis gives a powerful demonstration of this overhead in his talk "C++ Smart Pointers - Usage and Secrets". This is a must-see to understand the real-world cost of shared_ptr.

Watch from 30:00 to 43:36. Pay close attention to the benchmark he runs, comparing iteration over a vector of shared_ptrs by reference versus by value. The performance difference when copying shared_ptrs (which updates the reference count) is staggering. This illustrates why you should prefer unique_ptr unless shared ownership is a strict requirement.


Refactoring Non-Memory Resources

The RAII pattern isn't just for memory. It's for any resource that requires acquisition and release. Coming from C, you're familiar with fopen and fclose. A FILE* is a resource handle that can be leaked just like memory. We can use smart pointers with a custom deleter to manage it.

Modern C++ for C Programmers: Part 5

The article "Modern C++ for C Programmers: Part 5" by Bert Hubert provides a fantastic example of this by creating a SmartFP class. This is essentially what a unique_ptr with a custom deleter does for you.

Read the section titled "std::move". While the title focuses on std::move, the core example is the SmartFP class, which is a perfect RAII wrapper for a FILE*. Understand how its destructor guarantees fclose is called.

Here's how you'd achieve the same thing directly with std::unique_ptr and a custom deleter:

#include <cstdio>

// A lambda function that knows how to close a FILE*.
auto file_closer = [](FILE* f) {
    if (f) {
        std::cout << "Closing file.\n";
        fclose(f);
    }
};

void process_file_modern() {
    // We define a unique_ptr that manages a FILE* and uses our lambda to clean up.
    // The type of the deleter is now part of the unique_ptr's type.
    std::unique_ptr<FILE, decltype(file_closer)> file_ptr(fopen("example.txt", "w"), file_closer);

    if (!file_ptr) {
        // Handle error...
        return;
    }

    fprintf(file_ptr.get(), "Hello, RAII!");

    // No need to call fclose! The unique_ptr's destructor will invoke
    // our file_closer lambda when file_ptr goes out of scope.
}

This pattern is incredibly powerful. You can use it to manage file descriptors, socket handles, database connections, third-party C library objects, and more, bringing the full power of RAII and exception safety to non-memory resources.

Test your understanding!

You are working with a C library that has the following functions to manage a Widget resource:

// C-style API
typedef struct Widget Widget;
Widget* create_widget();
void destroy_widget(Widget* w);
void use_widget(Widget* w);

Refactor the following C++ function, which uses this library, to make it safe and modern using a smart pointer.

// Leaky C++ function
void do_work() {
    Widget* w = create_widget();
    if (!w) {
        return; // Potential leak if create_widget did partial allocation. Let's assume it doesn't.
    }
    
    // Some condition might cause an early exit, leaking the widget.
    if (some_error_condition()) {
        return;
    }
    
    use_widget(w);
    
    // We must always remember to call destroy_widget.
    destroy_widget(w);
}

What would the refactored do_work function look like?

Show answer

The most appropriate smart pointer is std::unique_ptr with a custom deleter, since there is a single clear owner. The custom deleter will call destroy_widget instead of delete.

#include <memory>

// ... C library declarations ...

void do_work_modern() {
    // Define the custom deleter. A simple function pointer works well here.
    auto widget_deleter = [](Widget* w) {
        destroy_widget(w);
    };

    // Create a unique_ptr, passing the raw pointer and the deleter.
    std::unique_ptr<Widget, decltype(widget_deleter)> w_ptr(create_widget(), widget_deleter);

    if (!w_ptr) {
        return; // unique_ptr is null, nothing to clean up.
    }

    if (some_error_condition()) {
        return; // The unique_ptr goes out of scope, automatically calling destroy_widget(w)!
    }

    // Use .get() to pass the raw pointer to C-style APIs.
    use_widget(w_ptr.get());

    // No manual cleanup call needed. RAII handles it.
}

This refactored version is robust. The Widget resource is guaranteed to be destroyed no matter how the function exits.


Conclusion

You've now seen how to systematically upgrade C-style code to modern C++. This is more than just a change in syntax; it's a profound shift in how you reason about resources. By letting the type system manage ownership and lifetimes, you eliminate entire categories of bugs and write code that is safer, clearer, and easier to maintain.

Key Takeaways:

  • Analyze Ownership First: Before writing code, decide on the ownership model. Is it exclusive (unique_ptr), shared (shared_ptr), or non-owning (raw pointer or weak_ptr)?
  • Default to std::unique_ptr: It's the most efficient smart pointer and clearly expresses exclusive ownership.
  • Use std::shared_ptr Only When Necessary: Use it for true shared ownership, but be aware of its performance overhead and the potential for circular references.
  • Embrace RAII for All Resources: Use custom deleters with smart pointers to bring automatic, exception-safe management to file handles, sockets, and other non-memory resources.
  • Eliminate new/delete: In modern C++ application code, manual calls to new and delete should be extremely rare. Prefer make_unique and make_shared.

What's Next?

This lesson concludes our deep dive into RAII and runtime resource management. You now have a complete toolkit for handling object lifetimes safely and efficiently.

In the next module, we'll shift our focus from runtime behavior to compile-time capabilities. We'll explore templates, the C++ feature that enables generic programming and compile-time polymorphism. This will open up a new dimension of creating highly reusable and performant code, and it's a cornerstone of the Standard Template Library (STL).