[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, andweak_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
- What are smart pointers?
- unique_ptr: exclusive ownership
- shared_ptr: shared ownership and reference counting
- weak_ptr: preventing cycles
- Smart pointer selection guide
- Practical patterns
- Common errors and fixes
- Performance comparison
- 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
deleteyourself 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 internaldelete, heap memory is gone when scope ends. When unsure: if the answer to “do many places need to own this?” is no, default tounique_ptr.shared_ptrhas 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_ptrplus (when needed) raw pointers or references is simpler and safer. In the block below,std::make_unique<int>(42)allocates anintwith value 42 on the heap and wraps it in aunique_ptr.ptrexclusively owns that memory, so when the block ends theunique_ptrdestructor callsdelete. Onreturnor exceptions, leaving scope still runs cleanup—no manualdelete, 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
| Type | Ownership | Copy | Move | Overhead | Typical use |
|---|---|---|---|---|---|
unique_ptr | Exclusive | ❌ | ✅ | None (~8 bytes) | Default choice |
shared_ptr | Shared | ✅ | ✅ | Yes (~16 bytes + control block) | Multiple owners |
weak_ptr | Non-owning | ✅ | ✅ | Yes (~16 bytes) | Break cycles |
2. unique_ptr: exclusive ownership
Properties
- Exclusive ownership: only one
unique_ptrowns 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 ptr2—only 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_ptris 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 node1–node3 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
| Aspect | unique_ptr | shared_ptr | raw pointer |
|---|---|---|---|
| Size | ~8 bytes | ~16 bytes | ~8 bytes |
| Allocation overhead | pointer only | control block | pointer only |
| Copy | not allowed (move only) | atomic inc | pointer copy |
| Destruction cost | single delete | dec ref + maybe delete | manual 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
| Operation | unique_ptr | shared_ptr | Notes |
|---|---|---|---|
| Create (make_*) | O(1) | O(1) + control block | shared slightly slower |
| Copy | N/A | atomic inc | shared only |
| Move | O(1) | O(1) | both cheap |
| Destroy | O(1) | O(1) + atomic dec | shared slightly heavier |
| Dereference | pointer | pointer | similar |
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_ptronly when sharing is real - Prefer
make_unique/make_sharedover rawnew - On bidirectional edges, one side
weak_ptr - Never
deletepointers fromget() - For
thisasshared_ptr, useenable_shared_from_this - On hot paths, prefer
unique_ptrfirst
Related reading (internal links)
- 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
Keywords (search)
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
- Default to
unique_ptr(when in doubt, start there) shared_ptronly when sharing is truly required- Bidirectional references →
weak_ptron one side - Raw pointers for non-owning views only
Lessons learned
- Cycles are silent (Valgrind may not flag them as classic leaks)
weak_ptris 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++ Core Guidelines - Smart Pointers
- “Effective Modern C++” by Scott Meyers (Items 18–22)
Related posts
- 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