Hi everyone ✋
In the previous post we stared down the dark side of pointers ─ wild and dangling pointers ─ and saw that the cleanest cure for most of them is to let smart pointers own our memory. We first met smart pointers back in the smart pointers post, where unique_ptr and shared_ptr did away with manual delete altogether.
Today we go a level deeper 🤿.
If we haven’t read the earlier posts in this series, it’s worth going through them first ─ especially the original smart pointers post and the dynamic memory one, since today assumes we’re already comfortable with unique_ptr and shared_ptr.
Today’s topic is smart pointers, part 2 ─ the finer, more powerful side of them. We’ll uncover a sneaky leak that even smart pointers can fall into, meet weak_ptr to fix it, teach smart pointers to clean up files and handles (not just memory), transfer ownership around with std::move, and pick up the habits that make all of this second nature 🥳.
Let’s take a deep dive 🦴
A quick recap ─ keys and reference counts 🔑
The heart of shared_ptr is a single idea ─ reference counting. Several shared_ptrs can own the same object, and each one that exists adds to a count. When a shared_ptr is copied, the count goes up; when one is destroyed or reassigned, it goes down. The moment the count hits zero ─ nobody owns the object anymore ─ it’s automatically freed.
We can watch the count ourselves with use_count() ─
#include <iostream>
#include <memory>
using namespace std;
int main()
{
shared_ptr<int> a = make_shared<int>(42);
cout << "owners: " << a.use_count() << endl; // 1
shared_ptr<int> b = a; // copy ─ now two owners share the same int
cout << "owners: " << a.use_count() << endl; // 2
b = nullptr; // b lets go of its share
cout << "owners: " << a.use_count() << endl; // 1
return 0;
}
The output will be ─
owners: 1 owners: 2 owners: 1
Copying a into b bumped the count to 2; releasing b dropped it back to 1. When a finally goes out of scope, the count reaches 0 and the int is freed ─ no delete in sight. Let’s give this shared ownership a picture.
My little reference ─ keys to a shared house 🏠🔑
Let’s keep our trusty house. The object on the heap is a house, and a shared_ptr is a key to it. The rule is simple ─ the house stands as long as at least one key exists.
- Copying a
shared_ptris cutting another copy of the key ─ the count goes up. - A
shared_ptrgoing out of scope is handing a key back ─ the count goes down. - When the last key disappears, the house is demolished ─ the memory is freed. 🏚️
That’s use_count() ─ simply how many keys are floating around. This is a wonderfully safe system… until it isn’t. Because there’s one arrangement of keys where the house can never be torn down, no matter what. Let’s go find it 👇
The hidden leak ─ reference cycles 🔄
Here’s a surprise ─ smart pointers can still leak memory. If two objects each hold a shared_ptr to the other, they keep each other alive forever, and neither is ever freed. It’s called a reference cycle, and it’s the one leak shared_ptr can’t solve on its own.
#include <iostream>
#include <memory>
#include <string>
using namespace std;
struct Person
{
string name;
shared_ptr<Person> partner; // shared ownership of the partner
~Person() { cout << name << " destroyed" << endl; }
};
int main()
{
auto alice = make_shared<Person>();
auto bob = make_shared<Person>();
alice->name = "Alice";
bob->name = "Bob";
alice->partner = bob; // Alice holds a key to Bob
bob->partner = alice; // Bob holds a key to Alice
cout << "main ending..." << endl;
return 0;
}
The output will be ─
main ending...
Look at what’s missing ─ neither “Alice destroyed” nor “Bob destroyed” ever prints 😱. The destructors never ran, which means the memory was never freed. That’s a leak, hiding in plain sight.
Why? Picture the keys 🔑. When main ends, the local alice and bob keys are handed back ─ but Alice’s house still holds a key to Bob, and Bob’s house still holds a key to Alice. Each house is propped up by the other’s key, so neither count ever reaches zero. Two empty houses, standing forever, kept alive only by pointing at each other. This shows up in real code all the time ─ parent/child trees where children point back at parents, observers that reference their subjects, any two-way relationship.
The fix ─ weak_ptr, the keyless observer 👀
The cure is a third kind of smart pointer ─ weak_ptr. A weak_ptr refers to an object managed by shared_ptr, but it does not own it and does not bump the count. In our picture, a weak_ptr is someone who wrote down the address of a house but holds no key ─ they can look, but they can’t keep the house standing.
To break the cycle, we make just one direction weak. The relationship still works both ways, but now only real keys keep houses alive ─
#include <iostream>
#include <memory>
#include <string>
using namespace std;
struct Person
{
string name;
weak_ptr<Person> partner; // NON-owning ─ just an address note, no key
~Person() { cout << name << " destroyed" << endl; }
};
int main()
{
auto alice = make_shared<Person>();
auto bob = make_shared<Person>();
alice->name = "Alice";
bob->name = "Bob";
alice->partner = bob; // weak ─ does NOT keep Bob alive
bob->partner = alice; // weak ─ does NOT keep Alice alive
// to use a weak_ptr we must first lock() it into a temporary shared_ptr
if (auto p = alice->partner.lock())
cout << "Alice's partner is " << p->name << endl;
cout << "main ending..." << endl;
return 0;
}
The output will be ─
Alice's partner is Bob main ending... Bob destroyed Alice destroyed
Now both destructors run ─ the leak is gone 🥳. Because the partner links are weak, they no longer prop up the houses, so when the local keys are handed back the counts fall to zero and both Persons are freed.
Notice the one extra step ─ we can’t use a weak_ptr directly, because the house it points at might already be gone. We call lock() first, which hands us a temporary real key (shared_ptr) if the house is still standing, and an empty one if it isn’t. So if (auto p = ...lock()) safely says “if it still exists, give me a key and let me in.” There’s also expired() to just ask “is it gone?” This is exactly the safe, checkable observing we wished for back in the dangling-pointer post ─ a weak_ptr can tell it’s dangling before we follow it 👀.
A real problem ─ a parent/child tree 🌳
The classic real-world home for weak_ptr is a tree ─ a parent that owns its children, where each child also needs a link back to its parent. If both links were shared_ptr, we’d recreate exactly the cycle from a moment ago. The clean rule that avoids it ─ own downward with shared_ptr, refer back upward with weak_ptr.
#include <iostream>
#include <memory>
#include <string>
#include <vector>
using namespace std;
struct Node
{
string name;
vector<shared_ptr<Node>> children; // parent OWNS its children (down)
weak_ptr<Node> parent; // child refers back weakly (up)
~Node() { cout << name << " freed" << endl; }
};
int main()
{
auto root = make_shared<Node>();
auto child = make_shared<Node>();
root->name = "root";
child->name = "child";
root->children.push_back(child); // root owns child
child->parent = root; // child points back ─ but only weakly
// we can still walk UP the tree safely by locking the parent link
if (auto p = child->parent.lock())
cout << child->name << "'s parent is " << p->name << endl;
cout << "main ending..." << endl;
return 0;
}
The output will be ─
child's parent is root main ending... root freed child freed
Because the parent link is a weak_ptr, the child never props the parent up ─ so when main ends, everything unwinds cleanly and both nodes are freed. Yet we can still climb up the tree whenever we like, safely, by lock()-ing the parent link. This one rule ─ shared down, weak up ─ is how real trees, graphs, and doubly-linked structures avoid leaks 🥳.
Managing more than memory ─ custom deleters 🧹
So far smart pointers have freed memory. But their real superpower is RAII ─ tying any resource’s cleanup to an owner’s lifetime. Files, sockets, database connections, C-library handles ─ all of these need to be closed, not deleted, and a custom deleter lets a smart pointer do exactly that.
Here we let a unique_ptr manage a C FILE*, closing it with fclose automatically ─
#include <iostream>
#include <memory>
#include <cstdio>
using namespace std;
int main()
{
// a unique_ptr that calls fclose (not delete) when it's done
unique_ptr<FILE, decltype(&fclose)> file(fopen("demo.txt", "w"), &fclose);
if (file)
fprintf(file.get(), "hello from a smart file handle\n");
// no manual fclose ─ it happens automatically when 'file' goes out of scope
cout << "file written and auto-closed" << endl;
return 0;
}
The output will be ─
file written and auto-closed
We never call fclose ourselves ─ the moment file goes out of scope, its custom deleter closes the handle for us, even if an exception fires along the way. In our house picture, a custom deleter is simply a specialised demolition crew ─ instead of the default wrecking ball (delete), it knows how to properly shut down whatever the smart pointer is guarding 🧹. This is how modern C++ makes every resource as safe as memory.
Transferring ownership ─ unique_ptr and std::move 📦
A unique_ptr is the sole owner of its object ─ there’s only ever one key. That’s why we can’t copy a unique_ptr (two keys would break the promise). But we can move it ─ hand the single key from one owner to another ─ using std::move.
#include <iostream>
#include <memory>
using namespace std;
unique_ptr<int> makeResource()
{
return make_unique<int>(100); // ownership flows out to the caller
}
int main()
{
unique_ptr<int> a = makeResource(); // 'a' now holds the only key
cout << *a << endl;
unique_ptr<int> b = move(a); // hand the key from a to b
if (!a) cout << "a is now empty" << endl;
cout << *b << endl;
return 0;
}
The output will be ─
100 a is now empty 100
After move(a), a is empty (it gave away its only key) and b holds it. This is transfer of ownership, and it’s how we pass unique things around ─ returning a unique_ptr from a factory function, or handing one into a container. The move is explicit precisely so we can see ownership changing hands, never by accident 📦.
unique_ptr for arrays 🧮
A unique_ptr can own a dynamic array too, with a special form ─ unique_ptr<T[]>. It remembers to free the whole block with delete[] (not plain delete), which is exactly the new[]/delete[] matching rule from the dangling pointers post ─ now handled for us automatically.
#include <iostream>
#include <memory>
using namespace std;
int main()
{
// owns a dynamic array ─ frees it with delete[] automatically
unique_ptr<int[]> arr = make_unique<int[]>(5);
for (int i = 0; i < 5; i++)
arr[i] = i * 10;
for (int i = 0; i < 5; i++)
cout << arr[i] << " ";
cout << endl;
return 0; // no delete[] to forget
}
The output will be ─
0 10 20 30 40
We index it just like a raw array with arr[i], but there’s no manual delete[] to forget. For most everyday code a std::vector is still the friendlier choice, but when we need a bare owned array, unique_ptr<T[]> gives us one that cleans up after itself 🧮.
Passing smart pointers to functions 🤝
Earlier we said own with smart pointers, observe with raw ─ let’s see exactly what that looks like at a function boundary, because the parameter type quietly announces our intent.
- To transfer ownership, take a
unique_ptrby value andstd::moveinto it ─ the caller’s pointer is emptied. - To just observe, take a plain reference (or raw pointer) ─ no ownership changes hands at all.
#include <iostream>
#include <memory>
#include <string>
using namespace std;
struct Widget { string name; };
// takes ownership ─ the caller's unique_ptr is emptied
void consume(unique_ptr<Widget> w)
{
cout << "consuming " << w->name << endl;
} // the Widget is destroyed here, when w goes out of scope
// only observes ─ no ownership, works on any Widget
void inspect(const Widget& w)
{
cout << "inspecting " << w.name << endl;
}
int main()
{
auto w = make_unique<Widget>();
w->name = "Gadget";
inspect(*w); // borrow it ─ we still own it afterward
consume(move(w)); // hand ownership INTO the function
if (!w) cout << "w is empty now" << endl;
return 0;
}
The output will be ─
inspecting Gadget consuming Gadget w is empty now
inspect(*w) borrowed the Widget for a moment ─ we still owned it afterwards. consume(move(w)) handed ownership into the function, which then destroyed the Widget on its way out, leaving our w empty. The parameter types tell the whole story ─ a unique_ptr by value means “I’m taking this,” while a const& means “I’m only looking.” Choosing the right one makes ownership self-documenting 🥳.
An object that shares itself ─ enable_shared_from_this 🪞
Here’s an elegant one that ties straight back to the this pointer post. Sometimes an object, from inside a member function, needs to hand out a shared_ptr to itself ─ to register with something, or return itself for chaining. You might reach for shared_ptr<Widget>(this)… but that’s a trap ☠️: it creates a second, independent key count for a house that already has one, and both will try to demolish it ─ a double-free.
The correct tool is enable_shared_from_this, which lets an object safely produce a key that belongs to the existing count ─
#include <iostream>
#include <memory>
using namespace std;
struct Widget : enable_shared_from_this<Widget>
{
shared_ptr<Widget> getSelf()
{
return shared_from_this(); // a proper shared key to 'this'
}
};
int main()
{
auto w = make_shared<Widget>();
auto w2 = w->getSelf();
cout << "keys to the widget: " << w.use_count() << endl; // 2
return 0;
}
The output will be ─
keys to the widget: 2
shared_from_this() handed back a key tied to the same count, so use_count() correctly reads 2. The one rule ─ this only works when the object is already owned by a shared_ptr (as ours is, via make_shared). It’s this, but grown up into a properly counted key 🥳.
The habits that keep it clean 🏭
A few small practices tie the whole toolkit together.
Prefer make_shared and make_unique. We’ve used them all along, and for good reason ─ they avoid a bare new, they’re exception-safe, and make_shared even bundles the object and its reference-count block into a single allocation, which is faster. Reach for make_* first; use the raw constructor only when a custom deleter forces it (like our FILE* above).
Own with smart pointers, observe with raw. A raw pointer isn’t evil ─ it’s the right tool for looking without owning. The modern division of labour is clear: a unique_ptr or shared_ptr says “I own this,” while a plain T* or reference says “I’m just looking.” So whenever you write a function that only needs to read an object, hand it a raw pointer or a reference ─ not a shared_ptr, which would needlessly cut and destroy a key on every call.
Move unique_ptr, share shared_ptr. To hand over sole ownership, std::move a unique_ptr. To share ownership, copy a shared_ptr. And to break any cycle that shared ownership creates, reach for weak_ptr.
Gotchas to keep us safe ⚠️
Let’s gather every tripwire in one place ─
shared_ptrcycles leak. Two objects owning each other never reach a count of zero ─ break the cycle by making one link aweak_ptr.- A
weak_ptrmust belock()ed before use. It might already be expired;lock()gives a real key only if the object still lives, andexpired()just checks. - Never build two
shared_ptrs from the same raw pointer.shared_ptr<T> a(raw); shared_ptr<T> b(raw);creates two separate counts and a double-free ─ copy theshared_ptr, don’t re-wrap the raw pointer. - Don’t wrap
thisin a freshshared_ptr. Useenable_shared_from_thisandshared_from_this()instead, and only when ashared_ptralready owns the object. unique_ptrcan’t be copied, only moved. Usestd::moveto transfer its single key.- Custom deleters need the constructor form, e.g.
unique_ptr<FILE, decltype(&fclose)>─make_uniquecan’t attach one. - Use
unique_ptr<T[]>for owned dynamic arrays ─ it frees withdelete[]automatically, matchingnew[]correctly. - Let parameter types show ownership ─ pass a
unique_ptrby value to transfer, a reference or raw pointer to merely observe. - Own with smart pointers, observe with raw. Passing a
shared_ptrwhen we only need to read wastes count churn and hides intent.
Follow these and smart pointers handle nearly every lifetime worry for us 🥳.
So what should we remember? 🤔
Let’s wrap up the key takeaways ─
shared_ptrworks by reference counting ─ copies raise the count, releases lower it, and the object is freed when it hits zero.use_count()shows the total.- Picture keys to a shared house 🏠🔑 ─ the house stands while any key exists; the last key gone means demolition (free).
- Reference cycles leak ─ two objects each owning the other keep the count above zero forever, so destructors never run.
weak_ptrfixes cycles ─ it’s a keyless observer that refers without owning.lock()turns it into a real key if the object still lives, so it’s also a safe way to check for a dangling target.- The go-to real-world pattern is shared down, weak up ─ a parent owns its children with
shared_ptr, and each child refers back to the parent withweak_ptr, so trees and graphs never leak. - Custom deleters let smart pointers manage any resource ─ files, handles, sockets ─ closing them automatically via RAII, not just freeing memory.
unique_ptris move-only ─std::movetransfers its single key; it’s how we return owned resources from functions and pass sole ownership around. Useunique_ptr<T[]>for owned arrays.- Parameter types announce ownership ─ pass a
unique_ptrby value to transfer, a reference or raw pointer to merely observe. enable_shared_from_thislets an object safely hand out ashared_ptrto itself ─ never wrap rawthisin a newshared_ptr.- The habits ─ prefer
make_shared/make_unique, own with smart pointers and observe with raw ones, move uniques, share shareds, and break cycles with weaks.
Once these click, memory management in C++ stops being a chore and becomes something we barely think about ─ ownership is written right into the types, and the compiler and runtime do the rest 👀✨.
Congratulations 🥳🥳🥳
We’ve now mastered the deeper side of smart pointers ─ cycles, weak_ptr, custom deleters, ownership transfer, and the habits that keep memory effortlessly safe.
In the next post we’ll point a base-class pointer at a derived object and watch polymorphism come to life ─ virtual functions dispatching through a pointer, why a base class needs a virtual destructor (a direct sequel to today’s cleanup story), and the object-slicing trap that pointers help us dodge 😉
Happy Coding 💻 🎵