[2026] C++ Smart Pointers | Fixing a Circular Reference Bug That Took Three Days

[2026] C++ Smart Pointers | Fixing a Circular Reference Bug That Took Three Days

이 글의 핵심

C++ smart pointers: circular references, unique_ptr vs shared_ptr vs weak_ptr, reference counting, and why memory can grow even when Valgrind reports no leak.

Introduction: A circular reference bug that took three days to find

“There is no leak—so why does memory keep growing?”

In the previous post we thought we had fixed leaks with unique_ptr. But when the program ran for a long time, memory still grew. Valgrind still reported no memory leak. (See Valgrind and leaks & ASan in practice.) After three days of digging, we found a circular reference (A holds B and B holds A, so neither side can be destroyed). Memory context: unique_ptr and shared_ptr work most safely when tied to scope and lifetime with RAII; ownership transfer pairs well with move semantics. Rust ownership and borrowing enforces similar rules at compile time; shared_ptr reference counting has runtime cost and a different philosophy. Modeling ownership inside structs also connects to Rust structs. For basic leak patterns, see the memory leak guide. shared_ptr destroys its object only when the reference count (the number of shared_ptrs to that object; when it hits 0 the object is deleted) reaches zero. If A points to B and B points back to A, both counts stay ≥1 and neither object is freed. Fix by making one side a weak_ptr, or by designing ownership in one direction only. The buggy code: If node1 and node2 point to each other with shared_ptr, node1’s count stays 1 because of node2->prev, and node2’s count stays 1 because of node1->next. When main ends, the local node1 and node2 variables go away, but they still reference each other, so the count never reaches 0 and destructors do not run. Bidirectional links with shared_ptr alone tend to create cycles; one side should be a weak_ptr so it observes without owning.

// After paste: g++ -std=c++17 -o cycle_bad cycle_bad.cpp && ./cycle_bad
#include <memory>
#include <iostream>
class Node {
public:
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;  // ❌ circular reference!
    
    ~Node() { std::cout << "Node destroyed\n"; }
};
int main() {
    auto node1 = std::make_shared<Node>();
    auto node2 = std::make_shared<Node>();
    
    node1->next = node2;  // node1 → node2
    node2->prev = node1;  // node2 → node1 (cycle!)
    
    // node1 and node2 reference each other
    // reference count never reaches 0
    // memory is not freed!
    
    return 0;
}
// "Node destroyed" never prints!

Explanation: node1->next = node2 and node2->prev = node1 make each node’s reference count stay at 1. After main’s locals disappear, the mutual shared_ptrs keep counts above 0, so destructors never run and memory is not released. Output: Node destroyed does not print (no destructor calls due to the cycle). Fixed version (paste and run with g++ -std=c++17 -o cycle_ok cycle_ok.cpp && ./cycle_ok):

// After paste: g++ -std=c++17 -o cycle_ok cycle_ok.cpp && ./cycle_ok
#include <memory>
#include <iostream>
class Node {
public:
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // ✅ use weak_ptr!
    
    ~Node() { std::cout << "Node destroyed\n"; }
};
int main() {
    auto node1 = std::make_shared<Node>();
    auto node2 = std::make_shared<Node>();
    
    node1->next = node2;
    node2->prev = node1;  // weak_ptr does not bump ref count
    
    return 0;
}
// "Node destroyed" prints twice!

Explanation: Making prev a weak_ptr means node2->prev = node1 does not increase node1’s strong reference count. When main ends, node1’s count can reach 0 and it is destroyed first; then node2’s count reaches 0 and it is destroyed—hence two "Node destroyed" lines. Output: Node destroyed prints twice.

More scenarios

Scenario 2: Entity–component references in a game engine

If an entity holds components with shared_ptr and a component holds its parent entity with shared_ptr, you get a cycle. Make one side weak_ptr or keep ownership one-way. Scenario 3: Event listener registration

If the subject holds observers with shared_ptr and observers hold the subject with shared_ptr, you get a cycle. Observers should usually hold the subject with weak_ptr. Scenario 4: Interop with C APIs

When C++ manages memory allocated by a C library with malloc/free, a unique_ptr with a custom deleter (free) gives RAII-safe release. Scenario 5: Sharing object lifetime across threads

When thread A creates an object and thread B uses it, knowing when to free is hard. shared_ptr gives “last user frees” and is a common pattern for cross-thread lifetime. The roles of the three smart pointer types:

flowchart LR
  subgraph U[unique_ptr]
    U1[Single owner]
    U2[Move-only]
  end
  subgraph S[shared_ptr]
    S1[Reference count]
    S2[Shared ownership]
  end
  subgraph W[weak_ptr]
    W1[Non-owning]
    W2[Break cycles]
  end
  U --> S
  S --> W

This article walks through smart pointers with practical examples. After reading you will:

  • Know the differences among unique_ptr, shared_ptr, and weak_ptr.
  • Know when to use each smart pointer.
  • Know how to fix circular reference issues.
  • Use smart pointers effectively in real code. Practical note: This post is based on problems and fixes from large C++ codebases—pitfalls and debugging tips you rarely see in textbooks.

Table of contents

  1. What are smart pointers?
  2. unique_ptr: exclusive ownership
  3. shared_ptr: shared ownership and reference counting
  4. weak_ptr: preventing cycles
  5. Smart pointer selection guide
  6. Practical patterns
  7. Common errors and fixes
  8. Performance comparison
  9. Production patterns

1. What are smart pointers?

RAII (Resource Acquisition Is Initialization)

Smart pointer types are classes that follow RAII:

  • Constructor: acquire resource (allocate memory)
  • Destructor: release resource (free memory) Analogy: a raw pointer where you call delete yourself is like a house where you must turn off lights and lock the door; a smart pointer is like a robot that cleans when you leave—you do not need to see the internal delete, heap memory is gone when scope ends. When unsure: if the answer to “do many places need to own this?” is no, default to unique_ptr. shared_ptr has counting overhead and cycle risk—use it only when shared ownership is truly required. If one object owns a resource and others only use it, unique_ptr plus (when needed) raw pointers or references is simpler and safer. In the block below, std::make_unique<int>(42) allocates an int with value 42 on the heap and wraps it in a unique_ptr. ptr exclusively owns that memory, so when the block ends the unique_ptr destructor calls delete. On return or exceptions, leaving scope still runs cleanup—no manual delete, fewer leak and double-delete risks.
{
    std::unique_ptr<int> ptr = std::make_unique<int>(42);
    // constructor: allocate memory
    
    std::cout << *ptr << std::endl;
    
    // end of scope → destructor → automatic free
}

Explanation: std::make_unique<int>(42) allocates an int on the heap and returns it wrapped in unique_ptr. When ptr goes out of scope (including return and exceptions), the destructor deletes automatically—no manual delete, avoiding leaks and double delete.

Comparing the three smart pointers

TypeOwnershipCopyMoveOverheadTypical use
unique_ptrExclusiveNone (~8 bytes)Default choice
shared_ptrSharedYes (~16 bytes + control block)Multiple owners
weak_ptrNon-owningYes (~16 bytes)Break cycles

2. unique_ptr: exclusive ownership

Properties

  • Exclusive ownership: only one unique_ptr owns the object at a time
  • Non-copyable: copy ctor and copy assignment are deleted
  • Movable: ownership transfers with std::move
  • Minimal overhead: same size as a raw pointer (~8 bytes) on typical platforms

Basic usage

make_unique<T>(args...) constructs T on the heap and returns a unique_ptr. Prefer it over raw new—on exceptions you avoid leaks, and use get() only when a raw pointer is required (e.g. passing int* to a C API). Never delete a pointer from get() or transfer ownership to another smart pointer while the unique_ptr still owns it—destruction must happen exactly once. For arrays, use make_unique<int[]>(n) and index with [].

#include <memory>
// creation (preferred)
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
// creation (legacy, avoid)
std::unique_ptr<int> ptr2(new int(42));
// access
std::cout << *ptr1 << std::endl;  // 42 (dereference)
std::cout << ptr1.get() << std::endl;  // print address
// array
std::unique_ptr<int[]> arr = std::make_unique<int[]>(100);
arr[0] = 10;
arr[99] = 20;

Explanation: make_unique<int>(42) is a single object; make_unique<int[]>(100) is a dynamic array wrapped in unique_ptr. get() exposes a raw pointer for C APIs—do not delete it or move ownership out from under the unique_ptr. Index arrays with arr[i].

Transferring ownership

unique_ptr is not copyable, only movable. std::move(ptr1) leaves ptr1 null and moves ownership to ptr2only one owner at a time.

std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
// ❌ cannot copy
// std::unique_ptr<int> ptr2 = ptr1;  // compile error!
// ✅ can move
std::unique_ptr<int> ptr2 = std::move(ptr1);
// now:
// - ptr1 is nullptr
// - ptr2 owns the memory

Explanation: std::move(ptr1) transfers ownership to ptr2; after the move, ptr1 is null and only ptr2 will delete. Copy is disabled so two unique_ptrs cannot own the same memory.

Using them in functions

Transfer ownership (callee takes ownership)

void takeOwnership(std::unique_ptr<int> ptr) {
    std::cout << *ptr << std::endl;
    // freed when function returns
}
int main() {
    auto ptr = std::make_unique<int>(42);
    takeOwnership(std::move(ptr));  // transfer ownership
    
    // ptr is now nullptr
    // if (ptr) { ....}  // false
    
    return 0;
}

Explanation: After takeOwnership(std::move(ptr)), the caller’s ptr is null; the parameter owns the allocation and its destructor runs at the end of takeOwnership.

Keep ownership (read-only)

void useValue(const std::unique_ptr<int>& ptr) {
    std::cout << *ptr << std::endl;
    // keep ownership, read only
}
int main() {
    auto ptr = std::make_unique<int>(42);
    useValue(ptr);  // ownership stays
    
    // ptr still valid
    std::cout << *ptr << std::endl;
    
    return 0;
}

Explanation: const std::unique_ptr<int>& avoids copy/move; the caller’s ptr keeps ownership. Use when you only read the value.

Return ownership

std::unique_ptr<int> createObject() {
    return std::make_unique<int>(42);
}
int main() {
    auto ptr = createObject();  // receive ownership
    std::cout << *ptr << std::endl;
    
    return 0;
}

Explanation: Returning make_unique uses RVO or move; the caller’s ptr owns the object and it is freed when ptr goes out of scope.

Custom deleter

Managing a file handle:

struct FileCloser {
    void operator()(FILE* fp) const {
        if (fp) {
            std::cout << "Closing file\n";
            fclose(fp);
        }
    }
};
void readFile() {
    std::unique_ptr<FILE, FileCloser> file(fopen("data.txt", "r"));
    
    if (!file) {
        throw std::runtime_error("File not found");
        // ✅ safe on exception
    }
    
    // read file...
    
    // ✅ fclose on scope exit
}

Explanation: std::unique_ptr<FILE, FileCloser> uses the second template parameter as the deleter. On destruction, FileCloser::operator()(fp) runs instead of delete, calling fclose. Exceptions and early returns still close the handle.

Practical unique_ptr example

class ResourceManager {
    std::vector<std::unique_ptr<Resource>> resources;
    
public:
    void addResource(std::unique_ptr<Resource> res) {
        resources.push_back(std::move(res));
    }
    
    std::unique_ptr<Resource> removeResource(size_t index) {
        auto res = std::move(resources[index]);
        resources.erase(resources.begin() + index);
        return res;  // return ownership
    }
};

Explanation: addResource moves ownership into the vector; removeResource moves one element out and returns it. When the vector is destroyed, each element’s destructor runs and resources are freed.

3. shared_ptr: shared ownership and reference counting

Properties

  • Shared ownership: many shared_ptrs can own the same object
  • Reference counting: an internal count tracks strong references
  • Automatic deletion: when the last shared_ptr is destroyed, the object is deleted
  • Thread-safe counting: reference count updates use atomics

Basic usage

#include <memory>
// creation (preferred)
std::shared_ptr<int> ptr1 = std::make_shared<int>(42);
// copy (increments count)
std::shared_ptr<int> ptr2 = ptr1;
std::cout << ptr1.use_count() << std::endl;  // 2
// destroy ptr1 (decrement)
ptr1.reset();
std::cout << ptr2.use_count() << std::endl;  // 1
// destroy ptr2 (count 0 → free)
ptr2.reset();

Explanation: ptr2 = ptr1 shares the control block; count becomes 2. ptr1.reset() drops one owner; ptr2.reset() drops the last and frees the heap object.

Visualizing reference counts

void demonstrateRefCount() {
    auto ptr1 = std::make_shared<int>(42);
    std::cout << "Count: " << ptr1.use_count() << std::endl;  // 1
    
    {
        auto ptr2 = ptr1;
        std::cout << "Count: " << ptr1.use_count() << std::endl;  // 2
        
        {
            auto ptr3 = ptr1;
            std::cout << "Count: " << ptr1.use_count() << std::endl;  // 3
            
            // ptr3 destroyed
        }
        
        std::cout << "Count: " << ptr1.use_count() << std::endl;  // 2
        
        // ptr2 destroyed
    }
    
    std::cout << "Count: " << ptr1.use_count() << std::endl;  // 1
    
    // ptr1 destroyed → count 0 → free
}

Explanation: Nested scopes copy ptr1; each inner scope end destroys a shared_ptr and decrements the count. When only ptr1 remains, count is 1; when ptr1 is destroyed, count hits 0 and memory is freed.

shared_ptr internals

graph LR
    subgraph Stack[Stack]
        PTR1["ptr1
━━━━━━━━
data ptr
ctrl ptr"] PTR2["ptr2
━━━━━━━━
data ptr
ctrl ptr"] end subgraph Heap[Heap] CTRL["Control block
━━━━━━━━━━━━
ref count: 2
weak count: 0
deleter"] OBJ["Object int
━━━━━━━━━━━━
value: 42"] end PTR1 -->|data ptr| OBJ PTR1 -->|ctrl ptr| CTRL PTR2 -->|data ptr| OBJ PTR2 -->|ctrl ptr| CTRL style Stack fill:#e3f2fd,stroke:#1976d2,stroke-width:2px style Heap fill:#fff3e0,stroke:#f57c00,stroke-width:2px style PTR1 fill:#bbdefb,stroke:#1976d2,stroke-width:2px style PTR2 fill:#bbdefb,stroke:#1976d2,stroke-width:2px style CTRL fill:#ffe0b2,stroke:#f57c00,stroke-width:2px style OBJ fill:#ffe0b2,stroke:#f57c00,stroke-width:2px

shared_ptr use cases

Case 1: graph structure

class Node {
public:
    int value;
    std::vector<std::shared_ptr<Node>> neighbors;
    
    Node(int v) : value(v) {}
};
int main() {
    auto node1 = std::make_shared<Node>(1);
    auto node2 = std::make_shared<Node>(2);
    auto node3 = std::make_shared<Node>(3);
    
    // bidirectional edges
    node1->neighbors.push_back(node2);
    node2->neighbors.push_back(node1);
    node2->neighbors.push_back(node3);
    node3->neighbors.push_back(node2);
    
    // ✅ all nodes freed automatically
    return 0;
}

Explanation: Even with mutual neighbors edges, if the graph has no cycle that traps counts (or only one direction owns strongly), when node1node3 locals go away the counts can reach zero and nodes free. If two nodes only point at each other with shared_ptr, use weak_ptr on one side.

Case 2: cache

Resource cache:

class ResourceCache {
    std::map<std::string, std::shared_ptr<Resource>> cache;
    
public:
    std::shared_ptr<Resource> get(const std::string& key) {
        auto it = cache.find(key);
        if (it != cache.end()) {
            return it->second;  // bumps ref count
        }
        
        // cache miss
        auto resource = std::make_shared<Resource>(key);
        cache[key] = resource;
        return resource;
    }
    
    void clear() {
        cache.clear();  // drop all shared_ptrs → may free resources
    }
};

Explanation: On hit, returning it->second shares ownership. On miss, make_shared creates the entry. clear() destroys map entries and decrements counts so unreferenced resources are freed.

Cost of shared_ptr

sizeof(std::unique_ptr<int>);  // 8 bytes (pointer only)
sizeof(std::shared_ptr<int>);  // 16 bytes (object ptr + control block ptr)

Explanation: unique_ptr is typically one pointer wide; shared_ptr holds object pointer plus control block pointer (ref count, weak count, deleter, etc.). The control block is usually heap-allocated. Control block (typical):

  • Strong ref count (~4 bytes)
  • Weak count (~4 bytes)
  • Deleter pointer (~8 bytes)
  • Allocator bookkeeping (variable) Performance:
  • Copy uses atomic increment
  • Small overhead in multithreaded code
  • Often negligible compared to work in the program

4. weak_ptr: breaking cycles

Circular references in depth

The three-day bug:

class Node {
public:
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;  // ❌ root cause
    
    ~Node() { std::cout << "Node destroyed\n"; }
};
int main() {
    auto node1 = std::make_shared<Node>();  // ref_count = 1
    auto node2 = std::make_shared<Node>();  // ref_count = 1
    
    node1->next = node2;  // node2 ref_count = 2
    node2->prev = node1;  // node1 ref_count = 2
    
    // end of main:
    // - node1 goes out of scope → node1 ref_count = 1 (node2->prev still holds)
    // - node2 goes out of scope → node2 ref_count = 1 (node1->next still holds)
    // - both ref_count > 0 → no free!
    
    return 0;
}
// "Node destroyed" never prints!

Memory picture:

graph LR
    NODE1["node1 on heap
━━━━━━━━
ref count: 1
next → node2"] NODE2["node2 on heap
━━━━━━━━
ref count: 1
prev → node1"] NODE1 -->|next| NODE2 NODE2 -->|prev| NODE1 NOTE["⚠️ cycle
mutual strong refs
cannot free"] style NODE1 fill:#ffcdd2,stroke:#c62828,stroke-width:3px style NODE2 fill:#ffcdd2,stroke:#c62828,stroke-width:3px style NOTE fill:#fff9c4,stroke:#f57f17,stroke-width:2px

Fix with weak_ptr

class Node {
public:
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // ✅ weak_ptr
    
    ~Node() { std::cout << "Node destroyed\n"; }
};
int main() {
    auto node1 = std::make_shared<Node>();  // ref_count = 1
    auto node2 = std::make_shared<Node>();  // ref_count = 1
    
    node1->next = node2;  // node2 ref_count = 2
    node2->prev = node1;  // node1 ref_count = 1 (weak_ptr does not increment!)
    
    // end of main:
    // - node1 goes out of scope → node1 ref_count = 0 → destroy node1
    // - destroying node1 → node1->next gone → node2 ref_count = 1
    // - node2 goes out of scope → node2 ref_count = 0 → destroy node2
    
    return 0;
}
// "Node destroyed" prints twice!

Using weak_ptr

std::shared_ptr<int> shared = std::make_shared<int>(42);
std::weak_ptr<int> weak = shared;
// weak_ptr cannot be dereferenced directly
// std::cout << *weak << std::endl;  // ❌ compile error!
// obtain shared_ptr with lock()
if (auto locked = weak.lock()) {
    std::cout << *locked << std::endl;  // 42
    std::cout << "Object alive\n";
} else {
    std::cout << "Object destroyed\n";
}
// destroy shared_ptr
shared.reset();
// weak_ptr object exists but target is gone
if (auto locked = weak.lock()) {
    // not taken
} else {
    std::cout << "Object destroyed\n";  // prints
}

Explanation: weak_ptr has no operator*; use lock() to get a shared_ptr. If the object lives, lock() returns a non-empty shared_ptr; if it was destroyed, you get empty. After shared.reset(), weak.lock() fails.

weak_ptr cache example

Weak reference cache:

class WeakResourceCache {
    std::map<std::string, std::weak_ptr<Resource>> cache;
    
public:
    std::shared_ptr<Resource> get(const std::string& key) {
        auto it = cache.find(key);
        
        if (it != cache.end()) {
            if (auto resource = it->second.lock()) {
                // hit and still alive
                return resource;
            } else {
                // stale entry
                cache.erase(it);
            }
        }
        
        // miss or expired
        auto resource = std::make_shared<Resource>(key);
        cache[key] = resource;  // store weak_ptr
        return resource;
    }
};

Explanation: Storing weak_ptr lets resources die when no external shared_ptr remains. lock() success returns a live object; failure removes the stale entry. Saves memory without pinning unused objects. Benefits:

  • Keep cache entries while resources are in use
  • Free automatically when unused
  • Avoid strong-reference cycles

5. Smart pointer selection guide

Decision flow

Need ownership?
  ├─ No → raw pointer or reference
  └─ Yes → next question
Multiple owners?
  ├─ No → unique_ptr (default)
  └─ Yes → next question
Possible cycle?
  ├─ No → shared_ptr
  └─ Yes → shared_ptr + weak_ptr

Practical rules

When to use unique_ptr (~90%)

// ✅ factory
std::unique_ptr<Widget> createWidget() {
    return std::make_unique<Widget>();
}
// ✅ class members
class Window {
    std::unique_ptr<Button> closeButton;
    std::unique_ptr<TextBox> titleBar;
};
// ✅ containers
std::vector<std::unique_ptr<Shape>> shapes;

Explanation: Single owner → unique_ptr. Factories, exclusive members, vector<unique_ptr<T>>—others observe via get() or references.

When to use shared_ptr (~9%)

// ✅ graphs
class Node {
    std::vector<std::shared_ptr<Node>> neighbors;
};
// ✅ cache
std::map<std::string, std::shared_ptr<Data>> cache;
// ✅ async work
void asyncTask(std::shared_ptr<Data> data) {
    std::thread([data]() {
        // data valid until this thread finishes
        processData(data);
    }).detach();
}

Explanation: Multiple owners—graphs, caches, data captured by threads. Copying shared_ptr into the lambda keeps the object alive for the thread’s lifetime.

When to use weak_ptr (~1%)

// ✅ break cycles
class Parent {
    std::vector<std::shared_ptr<Child>> children;
};
class Child {
    std::weak_ptr<Parent> parent;  // break cycle
};
// ✅ observer pattern
class Subject {
    std::vector<std::weak_ptr<Observer>> observers;
};

Explanation: Parent/child mutual references—make one side weak_ptr. Observers stored weakly avoid dangling strong refs when observers disappear first.

6. Practical patterns

Pattern 1: factory functions

class ShapeFactory {
public:
    static std::unique_ptr<Shape> createCircle(double radius) {
        return std::make_unique<Circle>(radius);
    }
    
    static std::unique_ptr<Shape> createRectangle(double w, double h) {
        return std::make_unique<Rectangle>(w, h);
    }
};
int main() {
    auto circle = ShapeFactory::createCircle(5.0);
    auto rect = ShapeFactory::createRectangle(10.0, 20.0);
    
    circle->draw();
    rect->draw();
    
    return 0;
}

Explanation: Returning make_unique gives clear ownership to the caller—good polymorphism with a single owner.

Pattern 2: Pimpl (pointer to implementation)

// Widget.h
class Widget {
public:
    Widget();
    ~Widget();
    void doSomething();
    
private:
    class Impl;  // forward declaration
    std::unique_ptr<Impl> pImpl;
};
// Widget.cpp
class Widget::Impl {
public:
    void doSomethingInternal() {
        // implementation...
    }
};
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;  // unique_ptr destroys Impl
void Widget::doSomething() {
    pImpl->doSomethingInternal();
}

Explanation: Widget holds unique_ptr<Impl>—header needs only a forward declaration. Implementation stays in .cpp, cutting compile dependencies. User-defined destructor in .cpp may be required for incomplete Impl. Benefits:

  • Hide implementation from headers
  • Fewer recompiles
  • More stable ABI surface

Pattern 3: polymorphic containers

class Animal {
public:
    virtual void speak() = 0;
    virtual ~Animal() = default;
};
class Dog : public Animal {
public:
    void speak() override { std::cout << "Woof!\n"; }
};
class Cat : public Animal {
public:
    void speak() override { std::cout << "Meow!\n"; }
};
int main() {
    std::vector<std::unique_ptr<Animal>> animals;
    
    animals.push_back(std::make_unique<Dog>());
    animals.push_back(std::make_unique<Cat>());
    animals.push_back(std::make_unique<Dog>());
    
    for (const auto& animal : animals) {
        animal->speak();  // polymorphism
    }
    
    // ✅ all animals freed
    return 0;
}

Explanation: vector<unique_ptr<Animal>> gives polymorphism with one owning container; destruction calls the right derived destructors.

Full sketch: entity–component (unique_ptr + shared_ptr + weak_ptr)

// Entity: unique_ptr owns components
// World: shared_ptr manages entities
// ParentRefComponent: weak_ptr to parent (break cycle)
class Entity {
    std::vector<std::unique_ptr<Component>> components_;
public:
    template<typename T, typename....Args>
    T* addComponent(Args&&....args) {
        auto c = std::make_unique<T>(std::forward<Args>(args)...);
        T* p = c.get();
        components_.push_back(std::move(c));
        return p;
    }
};
// World::createEntity() → make_shared<Entity>()
// ParentRefComponent::parent → weak_ptr<Entity>

Explanation: Components owned by Entity with unique_ptr; World may hold shared_ptr<Entity>; back-references use weak_ptr to avoid cycles.

Pattern 4: observer pattern with weak_ptr

Event system sketch:

class Subject {
    std::vector<std::weak_ptr<Observer>> observers;
    
public:
    void attach(std::shared_ptr<Observer> observer) {
        observers.push_back(observer);
    }
    
    void notify() {
        // remove expired observers
        observers.erase(
            std::remove_if(observers.begin(), observers.end(),
                [](const std::weak_ptr<Observer>& wp) {
                    return wp.expired();
                }),
            observers.end()
        );
        
        // notify live observers
        for (auto& wp : observers) {
            if (auto observer = wp.lock()) {
                observer->update();
            }
        }
    }
};
int main() {
    Subject subject;
    
    {
        auto observer1 = std::make_shared<Observer>();
        subject.attach(observer1);
        
        subject.notify();  // observer1 runs
        
        // observer1 destroyed
    }
    
    subject.notify();  // expired observers removed
    
    return 0;
}

Explanation: Storing weak_ptr avoids dangling when an observer is destroyed first. notify() removes expired entries with remove_if, then lock()s the rest and calls update().

7. Common errors and fixes

Error 1: copying a unique_ptr

Symptom: error: use of deleted function 'std::unique_ptr<T>::unique_ptr(const std::unique_ptr<T>&)' Cause: copy constructor is deleted.

// ❌ wrong
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
std::unique_ptr<int> ptr2 = ptr1;  // compile error!

Fix:

// ✅ move
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
std::unique_ptr<int> ptr2 = std::move(ptr1);

Error 2: deleting a pointer from get()

Symptom: double-free crash. Fix: get() is a non-owning view—never delete. Use only for C APIs or observing.

Error 3: two shared_ptrs from the same raw pointer

Symptom: double delete. Fix: use make_shared, or one shared_ptr then copies.

Error 4: dereferencing weak_ptr without lock()

Symptom: no operator* on weak_ptr. Fix: if (auto locked = weak.lock()) { /* use locked */ }

Error 5: “Leak-free” Valgrind but growing memory

Symptom: RSS grows though Memcheck is clean. Fix: break cycles—e.g. class B { std::weak_ptr<A> a_; };

Error 6: wrapping this in shared_ptr by hand

Symptom: double delete from std::shared_ptr<MyClass>(this). Fix: inherit enable_shared_from_this and use shared_from_this() only for objects already managed by shared_ptr.

Error 7: new + shared_ptr instead of make_shared

Symptom: two allocations (object + control block). Fix: auto p = std::make_shared<Widget>(1, 2, 3);

8. Performance comparison

unique_ptr vs shared_ptr vs raw pointer

Aspectunique_ptrshared_ptrraw pointer
Size~8 bytes~16 bytes~8 bytes
Allocation overheadpointer onlycontrol blockpointer only
Copynot allowed (move only)atomic incpointer copy
Destruction costsingle deletedec ref + maybe deletemanual delete

Micro-benchmark: create/destroy in a loop

// Typical 1M create/destroy loop:
// unique_ptr ~50ms, shared_ptr ~100ms (~1.5–3× slower)
for (int i = 0; i < 1000000; ++i) {
    auto p = std::make_unique<int>(42);   // or make_shared
}

Expectation: shared_ptr is often ~1.5–3× slower than unique_ptr due to atomics and the control block.

make_shared vs new + shared_ptr

// make_shared: one allocation (better locality)
auto p1 = std::make_shared<LargeObject>(args...);
// new + shared_ptr: two allocations
std::shared_ptr<LargeObject> p2(new LargeObject(args...));

Recommendation: prefer make_shared.

Summary table

Operationunique_ptrshared_ptrNotes
Create (make_*)O(1)O(1) + control blockshared slightly slower
CopyN/Aatomic incshared only
MoveO(1)O(1)both cheap
DestroyO(1)O(1) + atomic decshared slightly heavier
Dereferencepointerpointersimilar

Conclusion: if there is a single owner, unique_ptr is fastest and smallest. Use shared_ptr only when sharing is required.


9. Production patterns

Pattern 1: factories return unique_ptr

DocumentFactory::create(path) returning make_unique<Document> makes ownership obvious at the call site.

Pattern 2: members own resources with unique_ptr

DatabaseConnection with unique_ptr<ConnectionHandle> and unique_ptr<StatementCache> auto-cleans on destruction—often no Rule of Five boilerplate.

Pattern 3: cross-thread sharing with shared_ptr

std::thread([config]() { runTask(config); }) copying a shared_ptr into the capture extends lifetime until the thread finishes.

Pattern 4: cache + weak_ptr

Store weak_ptr in the cache; lock() to validate. Return live objects; recreate after expiry.

Pattern 5: Pimpl + unique_ptr (ABI)

Forward-declared Impl and std::unique_ptr<Impl> pImpl_ in the header keep binaries more stable when implementation changes.

Pattern 6: enable_shared_from_this

For async callbacks, shared_from_this() keeps shared_ptr lifetime through completion—never raw this when asynchronous work outlives the call.

Production checklist

  • Default to unique_ptr; shared_ptr only when sharing is real
  • Prefer make_unique / make_shared over raw new
  • On bidirectional edges, one side weak_ptr
  • Never delete pointers from get()
  • For this as shared_ptr, use enable_shared_from_this
  • On hot paths, prefer unique_ptr first

  • C++ RAII | “Cannot open file” incidents and automatic resource management
  • C++ auto and decltype | type deduction for cleaner code
  • C++ exception safety | Basic, strong, and nothrow guarantees

C++ smart pointers, unique_ptr shared_ptr weak_ptr, make_unique make_shared, circular reference, reference counting, RAII, automatic memory management, modern C++, etc.

Closing

Takeaways

unique_ptr: default choice (~90% of cases)

  • Exclusive ownership
  • Minimal overhead
  • Move-only ✅ shared_ptr: when sharing is needed (~9%)
  • Multiple owners
  • Reference counting
  • Some overhead ✅ weak_ptr: break cycles (~1%)
  • Non-owning observation
  • Access via lock()
  • Observers, caches

Rules of thumb

  1. Default to unique_ptr (when in doubt, start there)
  2. shared_ptr only when sharing is truly required
  3. Bidirectional references → weak_ptr on one side
  4. Raw pointers for non-owning views only

Lessons learned

  • Cycles are silent (Valgrind may not flag them as classic leaks)
  • weak_ptr is essential for bidirectional structures
  • Prefer safety over micro-optimization (most of the time)

Next

Learn RAII—the foundation of smart pointers—to manage every kind of resource safely, not just memory.

FAQ

Q. When do I use this in practice?

A. Whenever you model shared ownership, graphs, parent/child links, async lifetime, or see RSS grow without Memcheck leaks. Apply the examples and selection guide above.

Q. What should I read first?

A. Follow previous post links at the bottom of each article, or the C++ series index for order.

Q. Where can I go deeper?

A. cppreference and official library docs. Use the reference section below. One-liner: use unique_ptr, shared_ptr, and weak_ptr to express ownership and break cycles—then move on to RAII (#6-4). Next post: C++ in practice #6-4: RAII and resource management—files, sockets, mutexes, and more.

References

Official docs

Further reading

  • C++ stack vs heap | recursion crashes and stack overflow
  • C++ stack vs heap guide | recursion, layout, RAII and smart pointers
  • C++ memory leaks | real outages and five patterns Valgrind catches
  • C++ Valgrind guide | Memcheck and leak detection
  • C++ RAII | automatic resource management