← Back to blog

Smart Pointers: Moving Beyond Raw Pointers in C++

The Problem with Raw Pointers

"Never use raw pointers in C++."

This is a statement that many C++ programmers will tell you today if you need to use pointers. For me, this was confusing because I had always used C++ as "C with more features." I also learned how to program in C before C++, so I was used to the "C-style" way of doing things. Pointers were no exception.

I had experience with pointers from past school projects. For example, this is an old code of mine that I used when I was attempting to create a console snake game:

#include "cell.h"
#define EMPTY_QUEUE 0

// The node struct containing data
typedef struct node {
    cell cell;
    struct node *next;
} node;

// The queue struct (the snake will be a queue)
typedef struct queue {
    node* front;
    node* rear;
    int size;
} queue;

// Function definitions
queue* create_queue();
bool is_empty(queue* a_queue);
void enqueue(queue* a_queue, cell* a_cell);
node* dequeue(queue* a_queue);
void free_queue(queue* a_queue);

So when I initially moved to C++, I continued to use raw pointers. I was using a C library Allegro 4.4.2 to create my projects, so they were using raw pointers. My Sprite class looked something like this:

#ifndef sprite_h
#define sprite_h
#include <allegro.h>

class Sprite {
private:
    BITMAP* image;
    int width;
    int height;
    int x_pos;
    int y_pos;
    
public:
    Sprite() {
        image = NULL;
        width = 0;
        height = 0;
        x_pos = 0;
        y_pos = 0;
    }
    
    ~Sprite() {
        if (image != NULL) {
            destroy_bitmap(image);
        }
    } // destructor
    
    bool load(const char* filename) {
        image = load_bitmap(filename, NULL);
        if (image != NULL) {
            width = image->w;
            height = image->h;
            return true;
        }
        return false; // image is NULL
    }
    
    void draw(BITMAP* dest) {
        draw_sprite(dest, image, x_pos, y_pos);
    }
    
    int get_width() { return width; }
    int get_height() { return height; }
    int get_x_pos() { return x_pos; }
    int get_y_pos() { return y_pos; }
    
    void move_sprite(int x, int y) {
        x_pos += x;
        y_pos += y;
    }
    
    bool collided(Sprite* other) {
        int other_width_offset = other->get_x_pos() + other->get_width();
        int other_height_offset = other->get_y_pos() + other->get_height();
        
        if ((x_pos + width) >= other->get_x_pos() && 
            x_pos < other_width_offset && 
            (y_pos + height) >= other->get_y_pos() && 
            y_pos <= other_height_offset) {
            return true;
        }
        // no collisions occurred
        return false;
    }
};

In this example class, the destructor was correctly deleting the bitmap by calling the corresponding function to destroy the bitmap. However, in the next example, you can see that the memory is not correctly deleted, leading to memory leaks:

class Player {
private:
    Sprite* images[NUM_IMAGES];
    Direction dir;
    int curr_frame;
    int score;
    
public:
    Player() {
        for (int i = 0; i < NUM_IMAGES; i++) {
            images[i] = new Sprite();
            images[i]->set_speed(SPEED);
        }
        score = 0;
        curr_frame = 0;
        dir = LEFT;
        images[curr_frame]->set_x_pos(375);
        images[curr_frame]->set_y_pos(400);
        images[curr_frame]->set_alive(true);
    } // constructor
    
    ~Player() {
        delete[] *images; // Incorrect, need to delete each individual Sprite*
    } // destructor
};

Common Memory Management Issues

First, the destructor is trying to delete[] the first element (*images) as if it were an array—which is incorrect and dangerous.

Second, the array itself is not created with new, or in other words, dynamically allocated. So, I shouldn't even have used delete[]. This is what I should've done to ensure proper memory management:

~Player() {
    for (int i = 0; i < NUM_IMAGES; i++) {
        delete images[i]; // delete each Sprite
        images[i] = nullptr;
    }
}

Now see how much work that is? You have to remember to delete the memory that you allocate to avoid dangling pointers.

Let's look at some other examples.

Let's say I was creating an SDL_Window with a raw pointer, and created an alias (i.e., assigning the window to a different variable). So I have two variables that point to the same SDL_Window. Then, I delete the first window manually and try to delete the second one afterwards. This leads to undefined behavior caused by double deletion:

#include <SDL3/SDL.h>
#include <iostream>

int main(int argc, char* argv[]) {
    if (SDL_Init(SDL_INIT_VIDEO) != 0) {
        std::cerr << "SDL Init Error: " << SDL_GetError() << std::endl;
        return 1;
    }
    
    SDL_Window* window = SDL_CreateWindow("A Bad Window", 640, 480, SDL_WINDOW_RESIZABLE);
        
    if (!window) {
        std::cerr << "SDL_CreateWindow Error: " << SDL_GetError() << std::endl;
        SDL_Quit();
        return 1;
    }
    
    SDL_Window* window_alias = window; // Simulate transferring ownership (bad idea with raw pointers)
    
    // First delete
    SDL_DestroyWindow(window);
    std::cout << "Window destroyed once." << std::endl;
    
    // window_alias still points to the now-invalid memory
    SDL_DestroyWindow(window_alias); // This is undefined behavior: double delete / use-after-free
    std::cout << "Window destroyed again (bad!)." << std::endl;
    
    SDL_Quit();
    return 0;
}

We attempted to transfer ownership of the window to the variable window_alias, but because we are using raw pointers, this isn't the case. The variables window and window_alias both point to the same SDL_Window in memory. When we destroy the first window, the memory allocated by SDL_CreateWindow is destroyed. So the SDL_Window does not exist in memory anymore.

However, there still exists a pointer to that now-invalid memory with window_alias.

If we decided to call a function instead of trying to delete it:

SDL_HideWindow(window_alias) // This is undefined behavior: dangling pointer to invalid memory

Then we would have undefined behavior. Not only is this a dangling pointer, but it is a memory leak, because we have access to memory that we shouldn't have access to anymore.

Let's look at one more example to fully understand this.

Let's say we were making a very simple game in SDL that renders a rect onto the screen. When the user requests to close the window, the window is destroyed:

#include <SDL3/SDL.h>
#include <iostream>

SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
bool is_running = true;

void init() {
    if (SDL_Init(SDL_INIT_VIDEO) != 0) {
        std::cerr << "SDL_Init Error: " << SDL_GetError() << std::endl;
        exit(1);
    }
    
    window = SDL_CreateWindow("A Bad Window", 640, 480, SDL_WINDOW_RESIZABLE);
        
    if (!window) {
        std::cerr << "SDL_CreateWindow Error: " << SDL_GetError() << std::endl;
        exit(1);
    }
    
    renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED);
    if (!renderer) {
        std::cerr << "SDL_CreateRenderer Error: " << SDL_GetError() << std::endl;
        exit(1);
    }
}

void handle_events() {
    SDL_Event event;
    while (SDL_PollEvent(&event)) {
        if (event.type == SDL_QUIT) {
            is_running = false;
            // Simulate premature cleanup (bad idea)
            SDL_DestroyWindow(window);
            window = nullptr;
            std::cout << "Window destroyed inside event handler!" << std::endl;
        }
    }
}

void render() {
    // Ownership problem - window is destroyed, renderer might now be invalid
    SDL_SetRenderDrawColor(renderer, 0, 0, 255, 255);
    SDL_RenderClear(renderer);
    SDL_RenderPresent(renderer);
}

void clean_up() {
    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(window); // already destroyed
    SDL_Quit();
}

int main(int argc, char* argv[]) {
    init();
    
    while (is_running) {
        handle_events();
        render(); // Crashes or behaves weirdly after window is destroyed
    }
    
    clean_up();
    return 0;
}

We can see that we have a similar issue to the first one. This time, the handle_events function seems to "own" the SDL_window on a first glance. But then we see in the clean_up function that the window is destroyed there too.

Another issue is that the renderer relies on the window, so if the window is destroyed before the renderer, then it may be invalid and either crash or behave weirdly.

We can see here that it is really confusing to know when the window should be released and who owns the window.

Enter Smart Pointers

We can see that there are many problems with raw pointers. For a language like C, we just have to be aware of these issues and keep track of them ourselves. We can create structs that allow us to better manage them, but at the end of the day, it's up to us programmers to implement this.

In C++ however, we don't need to worry about ownership issues, or handle the details of memory management, as long as we use smart pointers.

Smart pointers are a class that manages memory by automatically deallocating the memory it points to when it goes out of scope. By doing so, it prevents memory leaks and dangling pointers.

They are essentially a wrapper around a raw pointer, with automatic memory management and ensuring that resources are always released even when exceptions occur.

To use them, you just include the following header:

#include <memory>

There are three kinds of smart pointers: a unique_ptr (one person), a shared_ptr (multiple people) and a weak_ptr (an observer).

std::unique_ptr

A std::unique_ptr provides exclusive ownership of an object. This means that only one unique_ptr can point to a given object. An std::unique_ptr is a wrapper around a raw pointer with no copy constructor. This means that the unique ptr cannot be copied, and can only be moved (std::move). If it is moved, you are effectively "transferring" ownership.

You can fetch as many raw pointers to that memory as you like, but the move semantics of the unique_ptr absolve those raw pointers of any ownership and thus the responsibility of freeing the memory (you would call these "non-owning raw pointers" since they don't own the heap block they point to).

The unique_ptr also has a convenient destructor that automatically frees the memory when it's destroyed. It basically means you no longer have to do manual memory management, yet the lifetime of your memory is as well-defined as always, unlike e.g. in garbage collection.

This is what makes std::unique_ptr the recommended smart pointer when needed. It makes it easier to understand the code because you know that this object owns the pointer and controls its lifetime. You know when it gets destroyed. Readability is super important as a programmer, and you want to make your code more readable when possible. So use an std::unique_ptr and make your C++ bearable to read!

std::shared_ptr

An std::shared_ptr allows multiple std::shared_ptrs to point to the same object using reference counting to determine when the object can be deallocated. A shared_ptr internally has two references, one for the pointer and an additional reference block.

Looking at the SDL_Window example above, if for some reason we wanted to create two references to the window, we would use shared_ptrs (but really only one instance should be holding a window). A shared_ptr may be easier to use in terms of coding, but in terms of readability and knowing the lifetimes of the object it points to, it is similar to raw pointers. It gives up control, meaning multiple objects share ownership, rather than one object owning it. Unless you need a reference counted object, use an std::unique_ptr.

std::weak_ptr

An std::weak_ptr is a non-owning pointer that can look or observe an object that is managed by a shared_ptr. It does not participate in its reference counting and can be very useful to solve the dangling pointer problem.

By letting a std::shared_ptr manage the object, and using std::weak_ptr for users of the object, the users can check if the object is valid by calling expired() or lock().

Conclusion

Now you can see the issues with using raw pointers in C++ and the solution: smart pointers. They eliminate many common memory management problems while making your code more readable and maintainable.

So remember: when working with modern C++, embrace smart pointers and say goodbye to the headaches of manual memory management!