Go back

Smart Pointers in C++23: A Comprehensive Guide

Published:

Smart Pointers in C++23

Table of Contents

Open Table of Contents

Introduction

In modern C++, memory management is both a powerful tool and a thorny pitfall. Raw pointers and manual new/delete pairs might give the illusion of control, but they also invite leaks, double-frees, and undefined behavior. Over the years, smart pointers—objects that manage dynamic lifetime for you—have become indispensable. In this article, I’ll share my deep dive into std::unique_ptr, std::shared_ptr, and std::weak_ptr in C++23, discussing their design, performance characteristics, thread-safety nuances, and best practices. I’ll pepper in anecdotes—from my own experiences and those of colleagues in finance, game development, and systems programming—and highlight caveats around compiler support and portability.

Overview of Smart Pointers

Smart pointers in the <memory> header provide clear ownership models:

Each has its niche, and choosing the right one can make the difference between rock-solid code and subtle leaks or performance cliffs.


Callout – Why Smart Pointers Matter: If you’ve ever chased a memory leak at 2 a.m., you know that a deterministic destructor is worth its weight in gold.


std::unique_ptr: The Lean, Mean Resource Guard

Semantics and Performance

std::unique_ptr<T> expresses sole ownership of a dynamically allocated T. When the unique_ptr goes out of scope, it invokes delete on its held pointer. There’s no reference counting overhead, making it as efficient as a raw pointer in optimized builds.

#include <memory>

struct Connection { /* ... */ };

void useConnection() {
    auto conn = std::make_unique<Connection>(/*args*/);
    // use conn->...
} // conn is destroyed here, Connection freed

By using std::make_unique, we avoid potential issues with exception-safety and new expressions.

Tip: Prefer std::make_unique over direct new to prevent resource leaks if constructor arguments throw.

Use Cases

  1. Factory Functions: Return unique_ptr from creators.
  2. Pimpl Idiom: Hide implementation details without manual delete.
  3. Containers of Non-Copyable Objects: Store in std::vector<std::unique_ptr<T>>.

Caveats and Gotchas

Personal Anecdote

Early in my career, I wrote a small HTTP server with raw pointers. A single exception path missed a delete, leading to an out-of-memory crash under load. Switching to unique_ptr would have eliminated that entire class of bugs. Too bad unique_ptr did not exist yet!

std::shared_ptr: The Poor Man’s Garbage Collector

Reference Counting and Thread Safety

std::shared_ptr<T> maintains a control block that tracks the number of owners (shared_count) and weak references (weak_count). In C++23, the reference counters use atomic operations by default, so incrementing or decrementing counts is thread-safe:

control_block: {
  atomic<size_t> shared_count;
  atomic<size_t> weak_count;
}

However, the pointer itself is not protected by atomics. Concurrently assigning a new shared_ptr to the same variable without synchronization results in a data race.

Code Example

#include <memory>
#include <thread>

std::shared_ptr<int> globalPtr;

void writer() {
    globalPtr = std::make_shared<int>(42); // not thread-safe to do concurrently
}

void reader() {
    auto local = globalPtr; // atomic increment of refcount is safe
    if (local)
        process(*local);
}

To assign atomically, you’d need std::atomic<std::shared_ptr<T>> (available since C++20), but it comes with its own overhead.

Anecdote from Finance: At a fintech startup, a colleague used shared_ptr to manage market data feeds. They assumed thread-safety end-to-end, but a subtle race on pointer assignment led to intermittent crashes during high-frequency trading runs.

Pros and Cons of std::shared_ptr

Pros:

Cons:

std::weak_ptr: The Specialized Observer

Breaking Cycles and Caching

std::weak_ptr<T> holds a non-owning reference to the control block of a shared_ptr<T>. It does not contribute to shared_count, but weak_count is incremented. You must call .lock() to obtain a shared_ptr<T> if the object still exists:

#include <memory>

std::weak_ptr<GameObject> cacheEntry;

void render() {
    if (auto obj = cacheEntry.lock()) {
        obj->draw();
    } else {
        // reload or skip
    }
}

Use Case: Texture Cache in Game Engines

In large game engines, loading textures on demand and keeping them in a cache helps performance. Holding them via weak_ptr ensures that unused textures free memory when not referenced elsewhere.

Anecdote from Game Dev: A team I consulted for had a memory spike when all cached textures remained alive indefinitely. By switching to weak_ptr for the cache table, textures unloaded once no entity used them, saving hundreds of megabytes.

Pros and Cons of std::weak_ptr

Pros:

Cons:

Pitfalls of Passing Smart Pointers by Reference

A common question is whether to pass smart pointers to functions by reference instead of by value:

void process(std::shared_ptr<Foo>& ptrRef);
void process(std::shared_ptr<Foo> ptrCopy);

Why Passing by Value Is Usually Preferred

  1. Clear Ownership Semantics: Passing by value clearly indicates a new owner or shared ownership in the callee. The ref count is incremented explicitly at the call site.
  2. Thread Safety: Copying a shared_ptr increments the shared_count atomically before accessing the pointer.
  3. Avoids Side Effects: A reference parameter can be modified inside the function, leading to surprises for the caller.

Cons of Passing by Reference

Tip: If you only need read-only access, consider passing a raw pointer or reference to T (i.e., const T* or const T&) to avoid reference counting overhead.

Comparative Table of Smart Pointer Traits

Smart PointerThread-Safe Ref CountOwnership ModelTypical Use CaseOverhead
unique_ptr<T>N/AExclusiveRAII; pimpl; factory returnsMinimal
shared_ptr<T>YesSharedShared lifetime; plugin systemsMedium
weak_ptr<T>N/A (counts atomic)Observer (no own)Caches; break cyclesMedium-Low

Compiler Support and Portability Concerns

Most standard library implementations (libstdc++, libc++, and MSVC STL) fully support C++23 smart pointers. However, minor performance differences exist:

Caveat: On embedded or real-time systems without full <atomic> support, shared_ptr may not be available or may degrade to non-atomic ref counts. Check your platform’s documentation before relying on them in safety-critical code.

Practical Examples Across Domains

  1. Systems Programming: A network server uses unique_ptr for socket wrappers, ensuring deterministic cleanup.
  2. Finance: An order book uses shared_ptr for trade messages handed off between threads, with a small dead-letter queue using weak_ptr to avoid stale deliveries.
  3. Game Development: An entity-component system stores components in std::vector<std::unique_ptr<Component>>, and the rendering cache uses weak_ptr to avoid holding onto components that have been destroyed.

Best Practices and Recommendations

Callout – Performance Tip: std::make_shared typically performs a single allocation for both the control block and the object, offering better cache locality compared to separate allocations.

Conclusion

Smart pointers in C++23 are not a one-size-fits-all cure, but they provide robust tools for safe memory management. In my experience, embracing unique_ptr by default reduces complexity and boosts performance. When shared lifetimes are unavoidable, shared_ptr—despite its overhead—saves you from manual bookkeeping, and weak_ptr elegantly handles cycles and caches. Always be mindful of thread-safety guarantees: atomic ref counting does not imply atomic pointer assignments. By following the guidelines in this article, you’ll write cleaner, safer, and higher-performance C++ code.


Further Reading

Happy coding!


Suggest Changes

Previous Post
Awesome C++: The Ultimate Resource Guide
Next Post
Solving the C++ Class Hash Problem