featured image

SDL Hands You a Raw Pointer — Here's How to Make C++ Clean It Up For You

Every C library follows the same contract: you call a `Create` function, you get a raw pointer, and you are now *personally responsible* for calling the matching `Destroy` function — in the right order, at the right time, even when errors happen.

Published

Mon Jun 01 2026

Technologies Used

C++ SDL3
Beginner 17 minutes

Every C library follows the same contract: you call a Create function, you get a raw pointer, and you are now personally responsible for calling the matching Destroy function — in the right order, at the right time, even when errors happen. SDL3 is no exception. SDL_CreateWindow must be paired with SDL_DestroyWindow. SDL_CreateRenderer must be paired with SDL_DestroyRenderer. Miss one, and you leak GPU memory. Call one too early, and your program crashes.

In this tutorial, we build the exact mechanism the Multiversal Consciousness engine uses to solve this problem: a single custom deleter struct that turns every SDL resource into a std::unique_ptr that cleans itself up automatically, even during exceptions, early returns, or initialization failures. No external libraries, no garbage collector — just the C++ type system enforcing correctness at compile time.

What You Need: C++17, SDL3, and Overloaded operator()

Knowledge prerequisites:

  • What std::unique_ptr does and why it exists
  • How function objects (functors) work — specifically, that a struct with operator() can be called like a function
  • Basic understanding of templates and type aliases (using)

Environment:

  • C++17 compiler or later (GCC 10+, Clang 12+, MSVC 2019+)
  • SDL3 development libraries
  • CMake 3.20+

One Struct, Four Cleanup Strategies

Think of the custom deleter as a janitor with a ring of labeled keys. You hand the janitor an SDL resource, and the janitor looks at the type to decide which cleanup function to call. A window gets SDL_DestroyWindow. A texture gets SDL_DestroyTexture. The janitor never mixes them up because the C++ compiler selects the correct operator() overload at compile time — not at runtime.

graph TD
    A["std::unique_ptr<SDL_Window, SDLDeleter>"] -->|"goes out of scope"| B["SDLDeleter::operator()(SDL_Window*)"]
    B --> C["SDL_DestroyWindow(window)"]

    D["std::unique_ptr<SDL_Renderer, SDLDeleter>"] -->|"goes out of scope"| E["SDLDeleter::operator()(SDL_Renderer*)"]
    E --> F["SDL_DestroyRenderer(renderer)"]

    G["std::unique_ptr<SDL_Texture, SDLDeleter>"] -->|"goes out of scope"| H["SDLDeleter::operator()(SDL_Texture*)"]
    H --> I["SDL_DestroyTexture(texture)"]

    J["std::unique_ptr<SDL_Surface, SDLDeleter>"] -->|"goes out of scope"| K["SDLDeleter::operator()(SDL_Surface*)"]
    K --> L["SDL_DestroySurface(surface)"]

The key insight: one struct handles all four resource types because operator() can be overloaded on parameter type, and the compiler resolves the correct overload when the unique_ptr destructor runs.

Building the Deleter, One Overload at a Time

Step 1: The struct with its first overload

Every SDL resource type needs its own cleanup function. The null check prevents a crash if the unique_ptr was moved-from or explicitly reset to null before going out of scope:

#pragma once

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

struct SDLDeleter {
    void operator()(SDL_Window* window) const {
        if (window) {
            SDL_DestroyWindow(window);
        }
    }
};

Step 2: Overloads for Renderer, Texture, and Surface

Each overload follows the same pattern — null-check, call the matching SDL destroy function. The const qualifier signals that the deleter itself holds no state, which matters because unique_ptr stores the deleter internally:

struct SDLDeleter {
    void operator()(SDL_Window* window) const {
        if (window) { SDL_DestroyWindow(window); }
    }

    void operator()(SDL_Renderer* renderer) const {
        if (renderer) { SDL_DestroyRenderer(renderer); }
    }

    void operator()(SDL_Texture* texture) const {
        if (texture) { SDL_DestroyTexture(texture); }
    }

    void operator()(SDL_Surface* surface) const {
        if (surface) { SDL_DestroySurface(surface); }
    }
};

Step 3: Type aliases for readability

Writing std::unique_ptr<SDL_Window, SDLDeleter> everywhere is noisy. A template alias wraps the pattern, and named aliases make header files read like English:

template<typename T>
using SDLPtr = std::unique_ptr<T, SDLDeleter>;

using WindowPtr   = SDLPtr<SDL_Window>;
using RendererPtr = SDLPtr<SDL_Renderer>;
using TexturePtr  = SDLPtr<SDL_Texture>;
using SurfacePtr  = SDLPtr<SDL_Surface>;

Step 4: Using the aliases in the engine

The GameEngine class declares its resources as smart pointers. No manual cleanup code appears anywhere in the class — destruction happens automatically in reverse declaration order (C++ destroys members in reverse order of their declarations):

class GameEngine {
private:
    WindowPtr window_;       // Destroyed second (after renderer)
    RendererPtr renderer_;   // Destroyed first
    // ...
};

Step 5: Wrapping SDL creation calls

When creating resources, wrap the raw pointer returned by SDL immediately:

bool GameEngine::create_window(const EngineConfig& config) {
    window_ = WindowPtr(SDL_CreateWindow(
        config.window_title.c_str(),
        config.window_width,
        config.window_height,
        0
    ));

    if (!window_) {
        std::cerr << "Window creation failed: " << SDL_GetError() << std::endl;
        return false;
    }
    return true;
}

If creation fails, the smart pointer is null, and the engine can bail out safely. Any previously created resources — the window from an earlier step, for instance — will be cleaned up automatically by their destructors when the GameEngine goes out of scope or is destroyed.

Why This Costs Nothing at Runtime

SDLDeleter is an empty class — it has no data members. When used as the second template parameter of std::unique_ptr, the compiler applies the Empty Base Optimization. This means sizeof(WindowPtr) == sizeof(SDL_Window*). The smart pointer is the same size as a raw pointer. Zero memory overhead.

At the call site, the compiler inlines the operator() body directly into the destructor of unique_ptr. The resulting machine code is identical to manually writing SDL_DestroyWindow(window) — no virtual dispatch, no function pointer indirection, no heap allocation for the deleter.

This is what the C++ community means by zero-overhead abstraction: you get automatic cleanup without paying anything for it at runtime. The compiler guarantees it never forgets to call the destroy function, in the right order, even when an exception unwinds the stack.

Where Things Can Still Go Wrong

SDL resources have implicit dependencies. A renderer depends on its window. Destroy the window before the renderer and the behavior is undefined. In this engine, window_ is declared before renderer_, so C++ destroys them in reverse order — renderer first, then window. If you rearrange the member declarations, you silently break this guarantee.

After a std::move, the source unique_ptr is null. The deleter’s null check handles this safely. But if you accidentally use a moved-from GameEngine object, the .get() calls return nullptr. The engine prevents this by deleting the copy constructor and assignment operator, and explicitly defaulting the move constructor.

Because unique_ptr enforces single ownership, two smart pointers can’t point to the same SDL resource. Double-free bugs are impossible at compile time.

The engine’s shutdown() method explicitly resets resources in a specific order even though destructors would handle it — this is defense-in-depth that makes the dependency chain readable and debuggable:

void GameEngine::shutdown() {
    system_manager_.reset();
    component_registry_.reset();
    entity_manager_.reset();

    renderer_.reset();   // SDLDeleter calls SDL_DestroyRenderer
    window_.reset();     // SDLDeleter calls SDL_DestroyWindow

    SDL_Quit();
}

The core pattern — a stateless functor struct with overloaded operator(), used as the deleter for std::unique_ptr — works with any C library that follows the create/destroy convention. OpenGL contexts, file handles, database connections, CUDA streams — any opaque pointer that must be released can be wrapped this way.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!