Road to C++ Programmer #25 - Smart Pointers

Last Edited: 3/18/2025

The blog post introduces smart pointers in C++.

CPP Smart Pointers

So far, we have been using raw pointers for dynamic memory management, where we need to manually allocate and free memory either with malloc and free or new and delete. However, using raw pointers are problematic, especially as the codebase gets bigger and complex. In this article, we will discuss those problems and how smart pointers address them.

Problems With Raw Pointers

The most obvious problem is the risk of memory leak by simply forgetting to free up memory. It happens more than we expect when the code gets complicated. Another problem is dangling pointers that hold a reference to a freed or deleted object, which can be prevented by setting the pointers to NULL after freeing an object, though we tend to forget doing it.

Both issues are critical since they can lead to unpredictable behaviors, crashes, and potential security vulnerabilities, though it's difficult to guarantee that we as humans do not forget to eliminate these issues, especially when the codebase is enormous. Hence, just like C++ provides us with STL library for safely handling strings, C++ provides us with smart pointers (from C++11), which are objects wrapping pointers that automatically handle memory deallocation.

Unique Pointers

When a pointer controls when to destroy the pointing object and deallocate the memory (usually when it goes out of scope), the pointer is smart and has an ownership of the object. Since raw pointers do not possess such control, and users need to manually destroy the object, raw pointers are said to have no ownership. A unique pointer is one such smart pointer with the ownership which does not allow two instances of unique pointers to manage the same object.

#include <memory> // Where smart pointers live
 
class Example {
public:
    Example() {
        std::cout << "Created!" << std::endl;
    };
    ~Example() {
        std::cout << "Destroyed!" << std::endl;
    };
    print() {
        std::cout << "Printed!" << std::endl;
    };
};
 
int main() {
    // Create an unique pointer std::unique_ptr<T>
    std::unique_ptr<Example> example = std::make_unique<Example>(); // => Created!
    // If parameterized constrcutor, std::make_unique<Example>(Example(params));
    // We can also do std::unique_ptr<Example, optional deleter> example(constructor);
 
    // std::unique_ptr<Example> copy = example => Err!
    std::unique_ptr<Example> copy = std::move(example); // => Move the ownership of example object to copy
 
    return 0;
    // copy destroys Example object as it gets out of main scope => Destroyed!
};

The above code shows how a unique pointer can be initialized and its behavior. As shown above, we do not need to manually perform delete as a unique pointer can destroy the object and deallocate the memory when it goes out of scope. We also observe that we cannot use the = operator for copying the unique pointer, as a unique pointer does not allow multiple pointers pointing to the same object. When we absolutely need to create a new instance of an unique pointer (when we pass the unique pointer to a function), we can use std::move to move the ownership of the object to the new unique pointer.

void func_err(std::unique_ptr<Example> moved) {
    moved -> print(); // => Printed!
    // moved goes out of scope so it gets deleted
};
 
std::unique_ptr<Example> func(std::unique_ptr<Example> moved) {
    moved -> print(); // => Printed!
    return moved;
};
 
int main() {
    std::unique_ptr<Example> example = std::make_unique<Example>(); // => Created!
    
    std::unique_ptr<Example> moved = func(std::move(example));
    moved -> print(); // Printed!
 
    func_err(std::move(moved));
    moved -> print(); // Err! object is already destroyed when it got out of scope
    return 0;
};

The above showcases the unique behavior of unique pointers. Although we could successfully use std::move to move the ownership to the unique pointer in the function func_err, it did not return the unique pointer unlike func, which caused the object to be destroyed and led to an error when accessing the member function after func_err. Here, we can see the complexity of passing unique pointers around. Using the get and release methods, we can obtain the raw pointer from the unique pointer while preserving or not preserving the ownership of the object.

However, using them requires extra considerations to make sure that the raw pointer from get is not modified (which can be enforced using const) or the raw pointer from release is manually freed. Either way, it defeats the purpose of abstracting away the details of memory management, which prompts us to use a different kind of smart pointer that allows us to share the ownership of the object between multiple pointers in cases where we know we will use the pointers in many scopes.

Shared Pointers

Shared pointer is one of the smart pointers that allows multiple of them to share ownership of the same object, which can simplify the code when the smart pointers are passed to multiple scopes. The pointer keeps track of the number of shared pointers pointing to the same object and destroys the object and deallocates the memory only when all of them have gone out of scope. The following demonstrates the behavior of shared pointers.

void func(std::shared_ptr<Example> input) {
    // Reference count goes up to 3
    input -> print(); // => Printed!
    // input goes out of scope so reference count goes down to 2 (object still lives)
};
 
int main() {
    std::shared_ptr<Example> example = std::make_shared<Example>(); // => Created!
    std::shared_ptr<Example> copied = example; // Allowed! Reference count goes up to 2
    func(copied);
    copied -> print(); // No error! Since object is still alive
    return 0;
    // Both example and copied go out of scope so destroy the object
};

Unlike unique pointers, shared pointers allow both copy and move operations, meaning we can pass a shared pointer to a function and still expect that the object is still alive. Although it is great for its simplicity, it has an overhead compared to raw pointers unlike unique pointer due to the introduction of reference count. It also does not come with the release method that discards the ownership of the object since there might be multiple shared pointers still owning the object.

(Shared pointer can point to an array but only with explicitly defined deleter like std::shared_ptr<int> example(new int[5], std::default_delete<int[]>). It cannot use make_shared here, and it does not have access to operator[] unlike unique pointers.)

Weak Pointers

Although shared pointers are useful, shared pointers can cause memory leaks when there is a cyclical dependency. The cyclical dependencies, for example, can occur when we create two shared pointers pointing to two objects that get copied to their shared pointer attributes in a way that they point to each other. In such cases, the shared pointers cannot destroy the objects even when they go out of scope, since the shared pointer attributes are still pointing to each other, maintaining the reference counts at 1. To avoid this problem, we can make use of weak pointers, which work like shared pointers but with no ownership of the object.

class Example {
public:
  // std::shared_ptr<Example> ptrAttribute; substitute this to 
  std::weak_ptr<Example> ptrAttribute;
  ~A(){ std::cout << "Destroyed!" << std::endl; };
};
 
int main() {
    std::shared_ptr<Example> ptrExample1 = std::make_shared<Example>();
    std::shared_ptr<Example> ptrExample2 = std::make_shared<Example>();
 
    ptrExample1 -> ptrAttribute = ptrExample2;
    ptrExample2 -> ptrAttribute = ptrExample1;
 
    return 0;
    // memory leak with shared_ptr due to ptrAttributes holding ownership
    // can be avoided with weeak_ptr since they don't have ownership
}

The above code demonstrates how weak pointers can avoid memory leaks in a cyclical dependency. Weak pointers can copy the shared pointers using the operator= or with their own constructor weak_ptr<T> wkptr(sharedPtr). One might suggest using raw pointers to solve this problem, but weak pointers are preferred here since they have access to methods like use_count and lock that can determine the number of shared pointers pointing to the same object and obtain the shared pointer pointing to the same object, unlike raw pointers which have no idea whether their objects are already destroyed. (It is especially difficult to know in multi-threaded programs.)

class A {
public:
  ~A(){ std::cout << "A Destroyed!" << std::endl; };
  void print() { std::cout << "Printed!" << std::endl; };
};
 
class B {
public:
  std::weak_ptr<A> ptrA;
  ~B(){ std::cout << "B Destroyed!" << std::endl; };
  void performPrint() {
    if (std::shared_ptr<A> ptr = ptrA.lock()) {
        ptr -> print();
    }
    else {
        std::cout << "A is already destroyed..." << std::endl;
    }
  }
};
 
int main() {
    B b;
    {
        std::shared_ptr<A> ptr = std::make_shared<A>();
        b.ptrA = ptr;
        b.performPrint(); // Printed!
        // A Destroyed! (shared_ptr ptr goes out of scope)
    }
    b.performPrint(); // A is already destroyed...
    return 0;
};

The above example makes use of weak pointers and its method lock to safely perform the print operation when A is still alive. Raw pointers would not be able to do this and will try to access print even when A is already destroyed. (We can technically get around this by using null pointers, though manually managing it defeats the whole purpose of using shared pointers.) Weak pointers also have a reset method, which we use to clear the reference to the object.

When To Use Which Pointers

By defaulting to using smart pointers for dynamic memory allocation of a custom class object and passing by references instead of pointers, we can eliminate the need for using raw pointers and malloc, free, new, and delete in our programs. It is generally safer to use them and allows us to focus more on the development, though shared pointers still require some level of consideration and have a little overhead.

Hence, the use case of raw pointers would only be when we need to use external modules that require raw pointers as arguments or when the performance overhead of using smart pointers (though negligible) cannot be tolerated. There are no clear rules defined about this (which is partly why people sometimes criticize C++), though I personally would almost always use smart pointers and avoid manual management as much as possible for C++11 and later unless I absolutely need to use raw pointers for the above reason.

Conclusion

In this article, we introduced three smart pointers, unique pointers, shared pointers, and weak pointers in C++ and their use cases. As manual memory management gets exponentially complex as the codebase grows, mastering smart pointers can become crucial in working with large projects, so I recommend you to practice using them.

Resources