Back to blog

Deep Dive: Memory Management & Smart Pointers

cppc++memory-managementsmart-pointersmodern-cpp
Deep Dive: Memory Management & Smart Pointers

Memory management is the defining challenge of C++. It's the source of the language's greatest power — and its most notorious bugs. Get it right, and your code runs at hardware speed with zero overhead. Get it wrong, and you face crashes, leaks, security vulnerabilities, and hours of debugging.

Modern C++ has solved most of these problems. The techniques in this guide — RAII, smart pointers, and ownership semantics — eliminate entire classes of bugs at compile time. If you've been writing new and delete by hand, it's time to stop.

Time commitment: 1-2 weeks, 1-2 hours daily
Prerequisites: Phase 3: Modern C++ Features

Related reading: For foundational topics, see C++ Stack vs Heap Memory and C++ Pointers: The Complete Guide.

What You'll Learn

✅ Understand the C++ memory model: stack, heap, and object lifetime
✅ Apply RAII to manage every kind of resource automatically
✅ Use std::unique_ptr for exclusive ownership
✅ Use std::shared_ptr for shared ownership with reference counting
✅ Use std::weak_ptr to break circular references
✅ Write custom deleters for non-standard resources
✅ Apply ownership semantics to design clear APIs
✅ Avoid the top memory safety pitfalls in C++


1. The C++ Memory Model

Every object in C++ has a storage duration that determines when it's created and destroyed:

Storage DurationWhereLifetimeExample
AutomaticStackScope-basedLocal variables
DynamicHeapManual / smart pointernew-allocated objects
StaticData segmentProgram lifetimestatic and global variables
ThreadThread-localThread lifetimethread_local variables

Stack vs Heap — The Quick Version

#include <memory>
 
void example() {
    // Stack: fast allocation, automatic cleanup
    int x = 42;                    // destroyed at end of scope
    double arr[100];               // fixed size, on the stack
 
    // Heap: flexible size, must manage lifetime
    int* p = new int(42);          // YOU must delete this
    delete p;                      // manual cleanup — error-prone
 
    // Heap with smart pointer: automatic cleanup
    auto sp = std::make_unique<int>(42);  // deleted automatically
}   // x, arr, sp all cleaned up here. If you forgot 'delete p', it leaks.

Key insight: Stack allocation is nearly free (just a pointer bump). Heap allocation involves the memory allocator and is orders of magnitude slower. Prefer the stack when possible — use the heap when you need dynamic lifetime, polymorphism, or large/variable-size data.

Object Lifetime

Every C++ object follows a strict lifecycle:

For stack objects, the compiler handles all five steps. For heap objects, you are responsible for destruction and deallocation — unless you use RAII.


2. RAII: The Foundation of Safe C++

RAII (Resource Acquisition Is Initialization) is the single most important idiom in C++. The idea is simple: tie every resource to an object's lifetime. Acquire the resource in the constructor, release it in the destructor. When the object goes out of scope, cleanup happens automatically.

The Problem RAII Solves

// WITHOUT RAII — manual resource management
void processFile(const std::string& path) {
    FILE* f = fopen(path.c_str(), "r");
    if (!f) return;
 
    char* buffer = new char[1024];
 
    // What if this throws?
    processData(f, buffer);
 
    delete[] buffer;    // skipped if exception thrown!
    fclose(f);          // skipped if exception thrown!
}

This code has three bugs:

  1. If processData throws, buffer leaks
  2. If processData throws, f is never closed
  3. If you add an early return, you might forget cleanup

The RAII Solution

#include <fstream>
#include <vector>
#include <string>
 
// WITH RAII — automatic resource management
void processFile(const std::string& path) {
    std::ifstream file(path);       // opens file in constructor
    if (!file) return;
 
    std::vector<char> buffer(1024); // allocates in constructor
 
    processData(file, buffer);
 
}   // file closed, buffer freed — AUTOMATICALLY
    // Even if processData throws — destructors always run

No leaks. No forgotten cleanup. No bugs from early returns or exceptions. RAII makes resource management correct by construction.

Writing Your Own RAII Class

Any resource that needs cleanup can be wrapped in RAII:

#include <cstdio>
#include <stdexcept>
#include <string>
 
class FileHandle {
public:
    explicit FileHandle(const std::string& path, const char* mode)
        : file_(fopen(path.c_str(), mode))
    {
        if (!file_) {
            throw std::runtime_error("Failed to open: " + path);
        }
    }
 
    // Destructor — always runs when object goes out of scope
    ~FileHandle() {
        if (file_) {
            fclose(file_);
        }
    }
 
    // Delete copy operations — file handles aren't copyable
    FileHandle(const FileHandle&) = delete;
    FileHandle& operator=(const FileHandle&) = delete;
 
    // Enable move operations — transfer ownership
    FileHandle(FileHandle&& other) noexcept : file_(other.file_) {
        other.file_ = nullptr;
    }
 
    FileHandle& operator=(FileHandle&& other) noexcept {
        if (this != &other) {
            if (file_) fclose(file_);
            file_ = other.file_;
            other.file_ = nullptr;
        }
        return *this;
    }
 
    FILE* get() const { return file_; }
 
    void write(const std::string& data) {
        fwrite(data.c_str(), 1, data.size(), file_);
    }
 
private:
    FILE* file_;
};
 
// Usage
void writeReport() {
    FileHandle report("report.txt", "w");
    report.write("Hello, RAII!\n");
}   // file automatically closed here

RAII for Non-Memory Resources

RAII works for any resource — not just memory:

#include <mutex>
#include <iostream>
 
// Mutex locking
std::mutex mtx;
 
void safeOperation() {
    std::lock_guard<std::mutex> lock(mtx);  // locks mutex
    // ... critical section ...
}   // mutex automatically unlocked
 
// Database transactions
class Transaction {
public:
    explicit Transaction(Database& db) : db_(db) {
        db_.begin();
    }
    ~Transaction() {
        if (!committed_) {
            db_.rollback();   // auto-rollback if not committed
        }
    }
    void commit() {
        db_.commit();
        committed_ = true;
    }
private:
    Database& db_;
    bool committed_ = false;
};

The RAII rule: If you acquire it, wrap it. If it has a close(), release(), unlock(), or delete — it belongs in a destructor.


3. std::unique_ptr — Exclusive Ownership

std::unique_ptr is the workhorse of modern C++ memory management. It owns a heap-allocated object exclusively — exactly one unique_ptr points to the object at any time. When the unique_ptr is destroyed, the object is deleted.

Basic Usage

#include <memory>
#include <iostream>
#include <string>
 
struct User {
    std::string name;
    int age;
 
    User(std::string n, int a) : name(std::move(n)), age(a) {
        std::cout << "User created: " << name << "\n";
    }
    ~User() {
        std::cout << "User destroyed: " << name << "\n";
    }
};
 
int main() {
    // Create with make_unique (preferred — exception-safe)
    auto alice = std::make_unique<User>("Alice", 30);
 
    std::cout << alice->name << " is " << alice->age << "\n";
 
    // Access the raw pointer (doesn't transfer ownership)
    User* raw = alice.get();
    std::cout << raw->name << "\n";
 
    // Check if it owns something
    if (alice) {
        std::cout << "alice is not null\n";
    }
 
}   // alice destroyed here → User destroyed: Alice

Transfer Ownership with std::move

unique_ptr cannot be copied — ownership is exclusive. But you can move it:

#include <memory>
#include <iostream>
#include <vector>
 
auto createUser(std::string name, int age) {
    return std::make_unique<User>(std::move(name), age);
}
 
int main() {
    auto user = createUser("Bob", 25);
 
    // Transfer ownership
    auto user2 = std::move(user);
 
    // user is now nullptr!
    if (!user) {
        std::cout << "user is null after move\n";
    }
 
    // Store in containers
    std::vector<std::unique_ptr<User>> users;
    users.push_back(std::make_unique<User>("Charlie", 35));
    users.push_back(std::move(user2));
 
}   // all users destroyed when vector is destroyed

unique_ptr and Polymorphism

unique_ptr is perfect for polymorphic objects:

#include <memory>
#include <iostream>
#include <vector>
#include <string>
 
class Shape {
public:
    virtual ~Shape() = default;
    virtual double area() const = 0;
    virtual std::string name() const = 0;
};
 
class Circle : public Shape {
public:
    explicit Circle(double r) : radius_(r) {}
    double area() const override { return 3.14159 * radius_ * radius_; }
    std::string name() const override { return "Circle"; }
private:
    double radius_;
};
 
class Rectangle : public Shape {
public:
    Rectangle(double w, double h) : width_(w), height_(h) {}
    double area() const override { return width_ * height_; }
    std::string name() const override { return "Rectangle"; }
private:
    double width_, height_;
};
 
int main() {
    // Factory function returning unique_ptr
    auto makeShape(std::string type) -> std::unique_ptr<Shape>;
 
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Circle>(5.0));
    shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0));
 
    for (const auto& shape : shapes) {
        std::cout << shape->name() << ": area = "
                  << shape->area() << "\n";
    }
}

unique_ptr with Arrays

#include <memory>
#include <iostream>
 
int main() {
    // Array version — calls delete[] automatically
    auto arr = std::make_unique<int[]>(100);
 
    for (int i = 0; i < 100; ++i) {
        arr[i] = i * i;
    }
 
    std::cout << "arr[10] = " << arr[10] << "\n"; // 100
}
 
// But prefer std::vector in most cases:
// std::vector<int> arr(100);

Custom Deleters

Sometimes you need cleanup other than delete. Custom deleters handle this:

#include <memory>
#include <cstdio>
#include <iostream>
 
// Custom deleter for FILE*
struct FileDeleter {
    void operator()(FILE* f) const {
        if (f) {
            std::cout << "Closing file\n";
            fclose(f);
        }
    }
};
 
// Type includes the deleter
using FilePtr = std::unique_ptr<FILE, FileDeleter>;
 
FilePtr openFile(const char* path, const char* mode) {
    return FilePtr(fopen(path, mode));
}
 
int main() {
    auto file = openFile("data.txt", "w");
    if (file) {
        fprintf(file.get(), "Hello from unique_ptr!\n");
    }
}   // file closed by FileDeleter
 
// Lambda deleter (more concise)
auto closerLambda = [](FILE* f) {
    if (f) fclose(f);
};
 
// Note: lambda deleters change the unique_ptr type
std::unique_ptr<FILE, decltype(closerLambda)> file2(
    fopen("data.txt", "r"), closerLambda
);

When to Use unique_ptr

Use unique_ptr as your default for heap allocation:

  • Factory functions that create objects
  • Class members with polymorphic types
  • Containers of polymorphic objects
  • Pimpl (Pointer to Implementation) idiom
  • Any time you'd write new — wrap it in make_unique instead

Cost: Zero overhead compared to raw new/delete. Same size as a raw pointer (unless you have a stateful deleter). Same performance.


4. std::shared_ptr — Shared Ownership

Sometimes multiple parts of your code need to share ownership of an object. std::shared_ptr uses reference counting: the object is destroyed when the last shared_ptr pointing to it is destroyed.

Basic Usage

#include <memory>
#include <iostream>
#include <string>
 
struct Config {
    std::string database_url;
    int max_connections;
 
    Config(std::string url, int max)
        : database_url(std::move(url)), max_connections(max) {}
    ~Config() { std::cout << "Config destroyed\n"; }
};
 
int main() {
    // Create with make_shared (single allocation, cache-friendly)
    auto config = std::make_shared<Config>("postgres://localhost/db", 10);
 
    std::cout << "ref count: " << config.use_count() << "\n"; // 1
 
    {
        // Copy — increments reference count
        auto config2 = config;
        std::cout << "ref count: " << config.use_count() << "\n"; // 2
 
        auto config3 = config;
        std::cout << "ref count: " << config.use_count() << "\n"; // 3
 
    }   // config2, config3 destroyed → ref count back to 1
 
    std::cout << "ref count: " << config.use_count() << "\n"; // 1
 
}   // config destroyed → ref count 0 → Config destroyed

make_shared vs shared_ptr Constructor

Always prefer std::make_shared:

#include <memory>
 
struct Widget {
    int x, y;
};
 
// GOOD: single allocation (object + control block together)
auto w1 = std::make_shared<Widget>();
 
// LESS GOOD: two allocations (object + control block separate)
std::shared_ptr<Widget> w2(new Widget());
 
// DANGEROUS: potential leak in function arguments before C++17
// process(std::shared_ptr<Widget>(new Widget()), mightThrow());
// If mightThrow() throws after new but before shared_ptr constructor,
// the Widget leaks. make_shared avoids this entirely.

Why make_shared is better:

  1. One allocation instead of two (object + control block in one memory block)
  2. Exception safe — no leak window between new and smart pointer construction
  3. Better cache locality — object and reference count are adjacent in memory

Sharing Between Objects

#include <memory>
#include <iostream>
#include <string>
#include <vector>
 
class Logger {
public:
    void log(const std::string& msg) {
        std::cout << "[LOG] " << msg << "\n";
    }
};
 
class Service {
public:
    explicit Service(std::string name, std::shared_ptr<Logger> logger)
        : name_(std::move(name)), logger_(std::move(logger)) {}
 
    void doWork() {
        logger_->log(name_ + " doing work");
    }
 
private:
    std::string name_;
    std::shared_ptr<Logger> logger_;  // shared ownership
};
 
int main() {
    auto logger = std::make_shared<Logger>();
 
    // Multiple services share the same logger
    Service auth("AuthService", logger);
    Service data("DataService", logger);
    Service cache("CacheService", logger);
 
    std::cout << "Logger ref count: " << logger.use_count() << "\n"; // 4
 
    auth.doWork();
    data.doWork();
    cache.doWork();
}
// Logger destroyed only after ALL services and 'logger' are gone

The Aliasing Constructor

A lesser-known feature: create a shared_ptr that shares ownership of one object but points to a member:

#include <memory>
#include <iostream>
#include <string>
 
struct Pair {
    std::string first;
    std::string second;
};
 
int main() {
    auto pair = std::make_shared<Pair>(Pair{"hello", "world"});
 
    // Aliasing constructor: shares ownership of 'pair',
    // but points to pair->first
    std::shared_ptr<std::string> first(pair, &pair->first);
 
    pair.reset();  // release our handle
 
    // 'first' keeps the whole Pair alive!
    std::cout << *first << "\n"; // "hello" — still valid!
    std::cout << "ref count: " << first.use_count() << "\n"; // 1
}

Performance Considerations

shared_ptr is not free:

// Cost breakdown:
// 1. Extra allocation for control block (avoided with make_shared)
// 2. Atomic reference count increment/decrement (thread-safe but ~10-50ns)
// 3. Two pointers per shared_ptr (16 bytes on 64-bit vs 8 for raw/unique)
// 4. Control block stores: strong count, weak count, deleter, allocator
 
// When it matters:
// - Tight loops with millions of copies → measurable overhead
// - Single-threaded code → atomic ops are unnecessary overhead
// - Large collections → 2x pointer storage adds up

Rule of thumb: Start with unique_ptr. Only use shared_ptr when you genuinely need shared ownership. If you're unsure, you probably don't need it.


5. std::weak_ptr — Non-Owning Observer

std::weak_ptr is a non-owning reference to a shared_ptr-managed object. It can observe the object without extending its lifetime. This is critical for breaking circular references.

The Circular Reference Problem

#include <memory>
#include <iostream>
#include <string>
 
struct Node {
    std::string name;
    std::shared_ptr<Node> next;  // owns the next node
    std::shared_ptr<Node> prev;  // PROBLEM: circular ownership!
 
    Node(std::string n) : name(std::move(n)) {
        std::cout << "Node " << name << " created\n";
    }
    ~Node() {
        std::cout << "Node " << name << " destroyed\n";
    }
};
 
int main() {
    auto a = std::make_shared<Node>("A");
    auto b = std::make_shared<Node>("B");
 
    a->next = b;   // A owns B
    b->prev = a;   // B owns A → CIRCULAR REFERENCE!
 
    std::cout << "a ref count: " << a.use_count() << "\n"; // 2
    std::cout << "b ref count: " << b.use_count() << "\n"; // 2
}
// a goes out of scope → ref count drops to 1 (b->prev still holds it)
// b goes out of scope → ref count drops to 1 (a->next still holds it)
// NEITHER gets destroyed! MEMORY LEAK!

The Fix: weak_ptr for Back-References

#include <memory>
#include <iostream>
#include <string>
 
struct Node {
    std::string name;
    std::shared_ptr<Node> next;   // owns the next node
    std::weak_ptr<Node> prev;     // FIX: non-owning back-reference
 
    Node(std::string n) : name(std::move(n)) {
        std::cout << "Node " << name << " created\n";
    }
    ~Node() {
        std::cout << "Node " << name << " destroyed\n";
    }
};
 
int main() {
    auto a = std::make_shared<Node>("A");
    auto b = std::make_shared<Node>("B");
 
    a->next = b;   // A owns B
    b->prev = a;   // weak reference — doesn't affect ref count
 
    std::cout << "a ref count: " << a.use_count() << "\n"; // 1
    std::cout << "b ref count: " << b.use_count() << "\n"; // 2
 
    // Access through weak_ptr — must lock first
    if (auto prev = b->prev.lock()) {
        std::cout << "B's prev: " << prev->name << "\n";
    }
}
// a destroyed → a ref count 0 → Node A destroyed
// → a->next (shared_ptr to B) destroyed → b ref count drops to 1
// b destroyed → b ref count 0 → Node B destroyed
// No leak!

Using weak_ptr Safely

weak_ptr doesn't guarantee the object is alive. You must check before accessing:

#include <memory>
#include <iostream>
 
void useWeakPtr(std::weak_ptr<int> weak) {
    // Method 1: lock() — returns shared_ptr or nullptr
    if (auto shared = weak.lock()) {
        std::cout << "Value: " << *shared << "\n";
    } else {
        std::cout << "Object already destroyed\n";
    }
 
    // Method 2: expired() — check without locking
    if (weak.expired()) {
        std::cout << "Object is gone\n";
    }
}
 
int main() {
    std::weak_ptr<int> weak;
 
    {
        auto shared = std::make_shared<int>(42);
        weak = shared;
 
        useWeakPtr(weak); // prints "Value: 42"
    }   // shared destroyed here
 
    useWeakPtr(weak);     // prints "Object already destroyed"
}

Observer Pattern with weak_ptr

A classic use case — observers that don't keep subjects alive:

#include <memory>
#include <vector>
#include <algorithm>
#include <iostream>
#include <string>
 
class EventEmitter {
public:
    using Listener = std::shared_ptr<std::function<void(const std::string&)>>;
 
    void subscribe(Listener listener) {
        listeners_.push_back(listener);
    }
 
    void emit(const std::string& event) {
        // Clean up expired listeners and notify active ones
        listeners_.erase(
            std::remove_if(listeners_.begin(), listeners_.end(),
                [](const std::weak_ptr<std::function<void(const std::string&)>>& w) {
                    return w.expired();
                }),
            listeners_.end()
        );
 
        for (auto& weak : listeners_) {
            if (auto listener = weak.lock()) {
                (*listener)(event);
            }
        }
    }
 
private:
    std::vector<std::weak_ptr<std::function<void(const std::string&)>>> listeners_;
};

Cache with weak_ptr

#include <memory>
#include <unordered_map>
#include <string>
#include <iostream>
 
class ImageCache {
public:
    std::shared_ptr<Image> load(const std::string& path) {
        // Check if image is still in memory
        auto it = cache_.find(path);
        if (it != cache_.end()) {
            if (auto img = it->second.lock()) {
                std::cout << "Cache hit: " << path << "\n";
                return img;  // still alive — reuse it
            }
            // expired — remove stale entry
            cache_.erase(it);
        }
 
        // Load new image
        std::cout << "Cache miss: " << path << "\n";
        auto img = std::make_shared<Image>(path);
        cache_[path] = img;  // store weak reference
        return img;
    }
 
private:
    std::unordered_map<std::string, std::weak_ptr<Image>> cache_;
};

6. Smart Pointer Comparison

Here's when to use each:

PointerOwnershipOverheadUse When
unique_ptrExclusiveZero (same as raw new/delete)Default choice for heap allocation
shared_ptrSharedAtomic ref count + control blockMultiple owners needed
weak_ptrNone (observer)Lock check before accessBreaking cycles, caches, observers
Raw pointer T*None (non-owning)NoneObserving, optional references
Reference T&None (non-owning)NoneGuaranteed non-null access

Decision Flowchart


7. Ownership Semantics in APIs

How you pass smart pointers communicates ownership intent. This is one of the most important design signals in C++.

Function Parameters

#include <memory>
#include <string>
 
class Widget {
public:
    std::string name;
};
 
// 1. Take by raw pointer or reference — "I just use it, I don't own it"
void inspect(const Widget& w) {
    // read-only access, caller retains ownership
    std::cout << w.name << "\n";
}
 
void modify(Widget& w) {
    // read-write access, caller retains ownership
    w.name = "modified";
}
 
// 2. Take unique_ptr by value — "Give me ownership (sink parameter)"
void consume(std::unique_ptr<Widget> w) {
    // I now own this widget. It's destroyed when I'm done.
    std::cout << "Consumed: " << w->name << "\n";
}
 
// 3. Take unique_ptr by reference — "I might take ownership"
void maybeConsume(std::unique_ptr<Widget>& w) {
    if (w->name == "disposable") {
        w.reset();  // take and destroy
    }
}
 
// 4. Take shared_ptr by value — "I need shared ownership"
void share(std::shared_ptr<Widget> w) {
    // I now co-own this widget
    // (ref count incremented on call, decremented when function returns)
}
 
// 5. Take shared_ptr by const ref — "I might share ownership"
void maybeShare(const std::shared_ptr<Widget>& w) {
    // Cheap to call — no ref count change
    // Can copy if I decide to keep it
}

Return Values

#include <memory>
#include <string>
 
// Factory — return unique_ptr (caller gets ownership)
std::unique_ptr<Widget> createWidget(std::string name) {
    auto w = std::make_unique<Widget>();
    w->name = std::move(name);
    return w;  // moved automatically (NRVO or implicit move)
}
 
// Shared factory — when callers will share the result
std::shared_ptr<Widget> getSharedWidget() {
    static auto instance = std::make_shared<Widget>();
    return instance;
}

Class Members

#include <memory>
#include <string>
#include <vector>
 
class Engine {
public:
    void run() { /* ... */ }
};
 
class Car {
public:
    // Exclusive ownership — Car owns the Engine
    Car() : engine_(std::make_unique<Engine>()) {}
 
    // Provide non-owning access
    Engine& engine() { return *engine_; }
 
private:
    std::unique_ptr<Engine> engine_;
};
 
class Team {
public:
    void addMember(std::shared_ptr<Player> player) {
        members_.push_back(std::move(player));
    }
 
private:
    // Shared ownership — players can be on multiple teams
    std::vector<std::shared_ptr<Player>> members_;
};

The Core Guidelines

From the C++ Core Guidelines:

GuidelineRule
R.30Take smart pointers as parameters only to express ownership
R.32Take unique_ptr<Widget> to express that a function assumes ownership
R.33Take unique_ptr<Widget>& to express that a function reseats the widget
R.34Take shared_ptr<Widget> to express shared ownership
R.36Take const shared_ptr<Widget>& to express that it might retain a reference
F.7For general use, take T* or T& rather than smart pointers

The most common mistake: Passing shared_ptr everywhere "just in case." This pollutes your API with unnecessary reference counting. If a function just reads or modifies an object, take it by reference.


8. Common Memory Safety Pitfalls

Pitfall 1: Dangling Pointers

#include <memory>
#include <iostream>
 
// BUG: returning reference to local object
int& dangling() {
    int x = 42;
    return x;  // x destroyed at end of scope!
}
 
// BUG: keeping raw pointer after unique_ptr is moved
void danglingFromMove() {
    auto ptr = std::make_unique<int>(42);
    int* raw = ptr.get();
 
    auto ptr2 = std::move(ptr);  // ownership transferred
 
    // raw is NOW DANGLING — ptr no longer owns the object
    // *raw is undefined behavior!
}
 
// FIX: don't outlive the owner
void safe() {
    auto ptr = std::make_unique<int>(42);
    int* raw = ptr.get();
 
    // Use raw only while ptr is alive and not moved
    std::cout << *raw << "\n"; // OK
 
    // Don't move ptr while raw is in use
}

Pitfall 2: Double Deletion

#include <memory>
 
// BUG: two shared_ptrs from same raw pointer
void doubleFree() {
    int* raw = new int(42);
 
    std::shared_ptr<int> sp1(raw);
    std::shared_ptr<int> sp2(raw);  // DANGER: separate control blocks!
 
    // When sp1 is destroyed → deletes raw
    // When sp2 is destroyed → deletes raw AGAIN → crash!
}
 
// FIX: always use make_shared, or create from existing shared_ptr
void safe() {
    auto sp1 = std::make_shared<int>(42);
    auto sp2 = sp1;  // same control block, ref count = 2
}

Pitfall 3: Circular References with shared_ptr

#include <memory>
 
struct Parent;
struct Child;
 
struct Parent {
    std::shared_ptr<Child> child;
    ~Parent() { std::cout << "Parent destroyed\n"; }
};
 
struct Child {
    std::shared_ptr<Parent> parent;  // BUG: circular!
    ~Child() { std::cout << "Child destroyed\n"; }
};
 
// FIX: use weak_ptr for the back-reference
struct ChildFixed {
    std::weak_ptr<Parent> parent;    // non-owning
    ~ChildFixed() { std::cout << "ChildFixed destroyed\n"; }
};

Pitfall 4: Using shared_ptr from this

#include <memory>
#include <vector>
#include <iostream>
 
// BUG: creating shared_ptr from raw 'this'
class BadWidget {
public:
    void addToList(std::vector<std::shared_ptr<BadWidget>>& list) {
        // DANGER: creates new control block from raw pointer!
        list.push_back(std::shared_ptr<BadWidget>(this));
    }
};
 
// FIX: inherit from enable_shared_from_this
class GoodWidget : public std::enable_shared_from_this<GoodWidget> {
public:
    void addToList(std::vector<std::shared_ptr<GoodWidget>>& list) {
        // Safe: uses existing control block
        list.push_back(shared_from_this());
    }
};
 
// Usage — MUST be managed by shared_ptr first
auto widget = std::make_shared<GoodWidget>();
std::vector<std::shared_ptr<GoodWidget>> list;
widget->addToList(list);  // safe!

Pitfall 5: Slicing with Smart Pointers

#include <memory>
#include <iostream>
 
class Base {
public:
    virtual ~Base() = default;
    virtual void speak() { std::cout << "Base\n"; }
};
 
class Derived : public Base {
public:
    void speak() override { std::cout << "Derived\n"; }
    int extra_data = 42;
};
 
// OK: smart pointers to base class work correctly
std::unique_ptr<Base> ptr = std::make_unique<Derived>();
ptr->speak();  // prints "Derived" — polymorphism works!
 
// BUG: value-type slicing (not a smart pointer issue, but common confusion)
Base base = Derived();  // SLICED! extra_data lost, speak() calls Base::speak

9. Advanced Memory Patterns

Pimpl Idiom (Pointer to Implementation)

Hide implementation details behind a unique_ptr:

// widget.h — public header
#pragma once
#include <memory>
#include <string>
 
class Widget {
public:
    Widget(std::string name);
    ~Widget();  // must be declared in header, defined in .cpp
 
    // Move operations
    Widget(Widget&&) noexcept;
    Widget& operator=(Widget&&) noexcept;
 
    // No copy
    Widget(const Widget&) = delete;
    Widget& operator=(const Widget&) = delete;
 
    void doSomething();
    std::string name() const;
 
private:
    struct Impl;                  // forward declaration
    std::unique_ptr<Impl> pimpl_; // pointer to implementation
};
// widget.cpp — implementation (can change without recompiling users)
#include "widget.h"
#include <iostream>
#include <vector>
 
struct Widget::Impl {
    std::string name;
    std::vector<int> data;
    // ... any private members, even heavy headers
};
 
Widget::Widget(std::string name)
    : pimpl_(std::make_unique<Impl>()) {
    pimpl_->name = std::move(name);
}
 
Widget::~Widget() = default;  // must be in .cpp where Impl is complete
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
 
void Widget::doSomething() {
    pimpl_->data.push_back(42);
    std::cout << pimpl_->name << " did something\n";
}
 
std::string Widget::name() const {
    return pimpl_->name;
}

Benefits: Faster compilation (changes to Impl don't recompile users), ABI stability, clean public headers.

Object Pool

Reuse objects instead of allocating/deallocating repeatedly:

#include <memory>
#include <vector>
#include <iostream>
#include <functional>
 
template<typename T>
class ObjectPool {
public:
    using Deleter = std::function<void(T*)>;
    using Ptr = std::unique_ptr<T, Deleter>;
 
    explicit ObjectPool(size_t initial_size = 10) {
        for (size_t i = 0; i < initial_size; ++i) {
            pool_.push_back(std::make_unique<T>());
        }
    }
 
    Ptr acquire() {
        if (pool_.empty()) {
            // Pool exhausted — create new object
            return Ptr(new T(), [this](T* obj) {
                pool_.push_back(std::unique_ptr<T>(obj));
            });
        }
 
        // Take from pool — return with custom deleter that recycles
        auto obj = pool_.back().release();
        pool_.pop_back();
 
        return Ptr(obj, [this](T* obj) {
            pool_.push_back(std::unique_ptr<T>(obj));
        });
    }
 
    size_t available() const { return pool_.size(); }
 
private:
    std::vector<std::unique_ptr<T>> pool_;
};
 
// Usage
struct Connection {
    void reset() { /* reset state for reuse */ }
};
 
int main() {
    ObjectPool<Connection> pool(5);
    std::cout << "Available: " << pool.available() << "\n"; // 5
 
    {
        auto conn1 = pool.acquire();
        auto conn2 = pool.acquire();
        std::cout << "Available: " << pool.available() << "\n"; // 3
    }   // conn1, conn2 returned to pool
 
    std::cout << "Available: " << pool.available() << "\n"; // 5
}

std::pmr Polymorphic Allocators (C++17)

For performance-critical code, you can control where memory is allocated:

#include <memory_resource>
#include <vector>
#include <iostream>
 
int main() {
    // Stack-based buffer — no heap allocation at all
    char buffer[4096];
    std::pmr::monotonic_buffer_resource pool(buffer, sizeof(buffer));
 
    // Vector that allocates from our buffer
    std::pmr::vector<int> vec(&pool);
    for (int i = 0; i < 100; ++i) {
        vec.push_back(i);
    }
 
    std::cout << "Size: " << vec.size() << "\n";
    // All allocations came from 'buffer' — zero heap allocations!
}

aligned_alloc and Alignment

For SIMD or hardware-specific requirements:

#include <memory>
#include <cstdlib>
#include <iostream>
 
// Custom deleter for aligned memory
struct AlignedDeleter {
    void operator()(void* ptr) const {
        std::free(ptr);
    }
};
 
// Allocate aligned memory with smart pointer
auto makeAligned(size_t alignment, size_t size) {
    void* ptr = std::aligned_alloc(alignment, size);
    return std::unique_ptr<void, AlignedDeleter>(ptr);
}
 
int main() {
    // 64-byte aligned buffer (cache line aligned)
    auto buffer = makeAligned(64, 1024);
    std::cout << "Aligned: "
              << (reinterpret_cast<uintptr_t>(buffer.get()) % 64 == 0)
              << "\n"; // 1 (true)
}
 
// C++17: alignas in classes
struct alignas(64) CacheAligned {
    int data[16];
};
 
auto ptr = std::make_unique<CacheAligned>();
// Note: make_unique respects alignas since C++17

10. Memory Debugging Tools

No matter how careful you are, memory bugs happen. These tools catch them:

AddressSanitizer (ASan)

The most important tool for catching memory bugs at runtime:

# Compile with ASan
g++ -fsanitize=address -g -O1 main.cpp -o main
clang++ -fsanitize=address -g -O1 main.cpp -o main
 
# Run — ASan reports errors with stack traces
./main

ASan detects:

  • Use after free
  • Heap buffer overflow
  • Stack buffer overflow
  • Memory leaks
  • Double free
// Example: ASan catches use-after-free
#include <memory>
 
int main() {
    auto ptr = std::make_unique<int>(42);
    int* raw = ptr.get();
    ptr.reset();       // frees the memory
    return *raw;       // ASan: "use-after-free"
}

Valgrind

# Run under Valgrind (slower but more thorough)
valgrind --leak-check=full --show-leak-kinds=all ./main

LeakSanitizer (LSan)

# Often included with ASan, or standalone:
g++ -fsanitize=leak -g main.cpp -o main
./main

Compiler Warnings

# Turn on all the warnings
g++ -Wall -Wextra -Wpedantic -Werror -std=c++20 main.cpp
clang++ -Wall -Wextra -Wpedantic -Werror -std=c++20 main.cpp

11. Best Practices Summary

The Modern C++ Memory Rules

  1. Never write new/delete directly — use make_unique or make_shared
  2. unique_ptr is your default — use shared_ptr only when genuinely needed
  3. Functions that don't own should take T& or T* — not smart pointers
  4. Use weak_ptr to break circular references in shared ownership graphs
  5. Every resource gets RAII — files, locks, connections, transactions, everything
  6. Prefer stack allocation — heap only when you need dynamic lifetime or size
  7. Use ASan in development — compile with -fsanitize=address and run your tests

Code Review Checklist

When reviewing C++ code for memory safety:

✅ No raw new/delete outside RAII wrappers
✅ No raw pointer ownership (raw pointers are observers only)
shared_ptr cycles broken with weak_ptr
enable_shared_from_this used when objects need shared_from_this()
✅ Factory functions return unique_ptr (not raw pointers)
✅ No use of get() pointer beyond the lifetime of the smart pointer
✅ Virtual destructors on base classes
✅ Move semantics implemented for resource-holding classes

Migration Path: Legacy Code → Modern C++

If you're working with legacy code that uses raw new/delete:

// Step 1: Wrap existing raw pointers in unique_ptr at creation
// BEFORE:
Widget* w = new Widget();
// ... 200 lines later ...
delete w;
 
// AFTER:
auto w = std::make_unique<Widget>();
// ... 200 lines later ...
// (automatic cleanup)
 
// Step 2: Replace raw pointer parameters with references
// BEFORE:
void process(Widget* w) { w->doThing(); }
// AFTER:
void process(Widget& w) { w.doThing(); }
 
// Step 3: Replace ownership transfers with unique_ptr
// BEFORE:
Widget* create() { return new Widget(); }  // caller must delete
// AFTER:
std::unique_ptr<Widget> create() { return std::make_unique<Widget>(); }

Practice Exercises

Exercise 1: RAII Socket Wrapper

Write an RAII wrapper for a network socket:

class Socket {
public:
    explicit Socket(int port);  // opens socket
    ~Socket();                  // closes socket
 
    Socket(Socket&&) noexcept;
    Socket& operator=(Socket&&) noexcept;
 
    // No copies
    Socket(const Socket&) = delete;
    Socket& operator=(const Socket&) = delete;
 
    void send(const std::string& data);
    std::string receive();
 
private:
    int fd_ = -1;  // file descriptor
};

Exercise 2: Tree with Smart Pointers

Build a tree where:

  • Parent owns children (unique_ptr)
  • Children reference parent (weak_ptr or raw pointer)
struct TreeNode {
    int value;
    std::vector<std::unique_ptr<TreeNode>> children;
    TreeNode* parent = nullptr;  // non-owning back reference
 
    void addChild(int val) {
        auto child = std::make_unique<TreeNode>();
        child->value = val;
        child->parent = this;
        children.push_back(std::move(child));
    }
};

Exercise 3: Type-Erased Callback

Use shared_ptr and weak_ptr to build a callback system that auto-unregisters when the listener is destroyed:

class Button {
public:
    using Callback = std::function<void()>;
 
    void onClick(std::weak_ptr<Callback> cb) {
        callbacks_.push_back(cb);
    }
 
    void click() {
        // Remove expired, invoke active
        // ... implement this
    }
 
private:
    std::vector<std::weak_ptr<Callback>> callbacks_;
};

Summary

RAII is the foundation — every resource gets a destructor
unique_ptr for exclusive ownership — zero overhead, default choice
shared_ptr for shared ownership — use sparingly, make_shared always
weak_ptr to observe without owning — breaks cycles, enables caches
Ownership semantics communicate intent — your API's most important signal
Never write raw new/delete — the compiler and smart pointers handle it
Use ASan and Valgrind — trust but verify

What's Next?

Additional Resources


Series: Modern C++ Learning Roadmap
Previous: Phase 3: Modern C++ Features
Next: Deep Dive: STL Containers & Algorithms

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.