5. Smart pointers and ownership design¶
Time: ~3 hours Prerequisites: 03, 04
1. In plain words¶
A raw pointer answers "where is the object?" but not "who deletes it?". In your C code that second
question is answered by a comment, a naming convention, or the arena allocator's contract. A smart
pointer answers it in the type: std::unique_ptr<T> means "I am the single owner and I will delete
it"; std::shared_ptr<T> means "we jointly own it; the last one out deletes it"; a raw T* or T&
then means, by convention, "I am only looking, someone else owns it".
Analogy: a car. unique_ptr is the title deed: exactly one person holds it, and it can be handed
over. shared_ptr is a car-share where the car is scrapped when the last member leaves.
weak_ptr is knowing the car's number plate: you can check whether it still exists and borrow it
if so, but you do not keep it alive. A raw pointer is a passenger: enjoys the ride, never owns anything.
2. The real thing¶
2.1 std::unique_ptr<T, Deleter>¶
- Owns exactly one object (or array with
unique_ptr<T[]>). Move-only. Destructor calls the deleter. std::make_unique<T>(args...)constructs the object and the pointer in one step. Prefer it overunique_ptr<T>(new T(...)): exception-safe in expressions with several allocations, and nonewin your code.get()gives the raw pointer for non-owning use (passing to a C API).release()gives up ownership and returns the raw pointer.reset(p)deletes the current object and takesp.- Custom deleters make it the universal wrapper for C APIs:
struct FileCloser { void operator()(std::FILE* f) const noexcept { if (f) std::fclose(f); } };
using FilePtr = std::unique_ptr<std::FILE, FileCloser>; // sizeof == sizeof(FILE*)
using ZctxPtr = std::unique_ptr<zctx, void (*)(zctx*)>; // sizeof == 2 pointers
ZctxPtr z(zctx_new(6), &zctx_free);
A stateless functor deleter costs nothing (empty base optimisation). A function-pointer deleter costs one extra pointer in the object.
2.2 std::shared_ptr<T>¶
- Shared ownership through a control block holding a strong count, a weak count, the deleter,
and (with
make_shared) the object itself. - Copying increments the strong count atomically; destroying decrements; when it hits zero the object is destroyed; when the weak count also hits zero the control block is freed.
std::make_shared<T>(args...)does one allocation for control block and object together.shared_ptr<T>(new T)does two, and leaksTif the control block allocation throws.sizeof(shared_ptr) == 2 * sizeof(void*): a pointer to the object and a pointer to the control block. The object pointer can differ from what the control block manages (aliasing constructor,shared_ptr<Base>fromshared_ptr<Derived>).- Thread safety: the reference count is atomic, so copying
shared_ptrs to the same object from several threads is safe. Access to the pointed-to object is not synchronised, and concurrent modification of the sameshared_ptrvariable is a data race.
2.3 std::weak_ptr<T>¶
A non-owning observer of a shared_ptr-managed object. lock() returns a shared_ptr (null if the
object is gone). expired() is a cheaper check but racy: by the time you act, it may have died.
Two uses:
- Breaking cycles. Parent owns child (
shared_ptr), child refers back to parent (weak_ptr). With twoshared_ptrs neither count ever reaches zero and both leak. - Caches and observers. A cache holds
weak_ptrs so it never keeps an object alive on its own.
2.4 Ownership design¶
Decide, for every object in the system, exactly one of:
| Ownership | Type to use | Typical case |
|---|---|---|
| Single owner, lifetime = scope | plain member / local object (no pointer at all) | most objects |
| Single owner, polymorphic or optional or heap-sized | std::unique_ptr<T> |
a Logger behind an interface, a 1 MB buffer |
| Single owner, transferable across threads/queues | std::unique_ptr<T> moved into the queue |
request objects in a worker pool |
| Several owners with unrelated lifetimes | std::shared_ptr<T> |
a config snapshot read by many threads while being replaced |
| Observer that must not extend lifetime | std::weak_ptr<T> |
caches, back-pointers, callbacks that might outlive the target |
| Non-owning access, owner guaranteed alive | T& or T* (nullable) |
function parameters, iteration |
shared_ptr is not the default. It is for genuinely shared, non-hierarchical lifetime. Most
C++ codebases that overuse it end up with unclear ownership, cycles, and atomic traffic in hot loops.
Start with values and unique_ptr; introduce shared_ptr only when you can name the second owner.
2.5 Mapping to your arena allocator¶
Your C arena with "explicit ownership contracts" is exactly this discipline done by hand: the arena
is the single owner of every block, callers get non-owning pointers, and the lifetime ends when the
arena resets. In C++ that design still exists (module 12, std::pmr), and the vocabulary types make
the contract checkable: a function taking T* is documented as non-owning; one taking
std::unique_ptr<T> takes ownership; one returning std::unique_ptr<T> gives it.
3. Code¶
code/m05_unique_ptr_c_api.cpp wraps three kinds of C resources:
if (FilePtr f = open_file("/tmp/m05_demo.txt", "w")) { // contextual bool: non-null?
std::fputs("hello\n", f.get()); // raw pointer to the C API
} // fclose here
MallocPtr<char> buf(static_cast<char*>(std::malloc(64))); // deleter calls free
ZctxPtr z(zctx_new(6), &zctx_free);
ZctxPtr z2 = std::move(z); // z is null now
zctx* raw = z2.release(); // hand ownership back to C; z2 will not free
zctx_free(raw);
auto arr = std::make_unique<int[]>(8); // delete[] on destruction
code/m05_shared_weak_ptr.cpp shows the counts changing, a cache that does not keep objects alive,
and a parent/child cycle broken with weak_ptr:
auto s1 = std::make_shared<Session>("shakir"); // one allocation: control block + Session
{ std::shared_ptr<Session> s2 = s1; } // use_count 1 -> 2 -> 1 (two atomic ops)
class SessionCache {
std::unordered_map<std::string, std::weak_ptr<Session>> cache_;
public:
std::shared_ptr<Session> get(const std::string& id) {
auto it = cache_.find(id);
return it == cache_.end() ? nullptr : it->second.lock(); // null if the session died
}
};
struct Node {
std::shared_ptr<Node> child; // owning edge
std::weak_ptr<Node> parent; // back edge: weak, or both nodes leak
};
Expected: ~Session(shakir) prints as soon as s1.reset() runs even though the cache still holds a
weak_ptr, and both ~Node lines print at the end of the block.
4. Under the hood (senior-level)¶
unique_ptr is a zero-overhead abstraction. sizeof(std::unique_ptr<T>) == sizeof(T*) with a
stateless deleter; operator-> and operator* inline to a load; the destructor inlines to
if (p) delete p;. The one real cost is at the ABI boundary: on x86-64, a unique_ptr is passed to
a function in memory (because it has a non-trivial destructor), not in a register like a raw pointer.
Measurable only in very hot code.
The shared_ptr control block (libstdc++, _Sp_counted_base) contains a vptr (the control block
is polymorphic so it can hold different deleters and allocators), an int strong count (_M_use_count),
and an int weak count (_M_weak_count). With make_shared, the T object is embedded directly
after it in the same allocation (_Sp_counted_ptr_inplace). Typical size: 16 bytes of header plus
the object. shared_ptr<T>(new T) instead allocates T separately and a smaller control block that
stores the pointer: two allocations, two cache lines.
Atomic reference counting cost. Every copy and destroy of a shared_ptr is a lock xadd (or
lock dec) on the control block's count. On x86 that is a full barrier, roughly 20 cycles
uncontended and far more if another core recently touched the same line (the count is a shared
write hotspot). libstdc++ skips the atomic instructions when the program has not linked
libpthread (__gthread_active_p()), which is why a single-threaded benchmark can look faster than
the server. Passing shared_ptr by value through five layers of calls does five increments and five
decrements for no reason; pass const shared_ptr<T>& or, better, T& when the callee does not need
to own.
Why make_shared can delay freeing memory. Because object and control block share one allocation,
the memory cannot be returned until the weak count also reaches zero. A long-lived weak_ptr to a
large object keeps the object's bytes allocated (though destroyed) until the weak_ptr dies.
For large objects observed by long-lived weak pointers, shared_ptr<T>(new T) is the better choice.
weak_ptr::lock() is a CAS loop. It must increment the strong count only if it is non-zero, so
it reads the count, checks, and does a compare-and-swap; retries on contention. expired() is a plain
load and is only a hint.
enable_shared_from_this. A class that must produce a shared_ptr to itself from inside a
member function (typical in async callbacks: self = shared_from_this() captured in the lambda)
derives from std::enable_shared_from_this<T>, which stores a weak_ptr to the control block that
make_shared fills in. Calling shared_from_this() on an object not owned by a shared_ptr
throws std::bad_weak_ptr.
Custom deleter type erasure. shared_ptr<T> has one type regardless of deleter (the deleter
lives in the control block); unique_ptr<T, D> has the deleter in its type. That is why
shared_ptr is convenient for plugin-style APIs and unique_ptr is the tool for zero-overhead wrappers.
5. In production¶
- Hot paths and
shared_ptr. A request handler that copies ashared_ptr<Config>per request is one atomic RMW per request; per field access it would be a disaster. Pattern: take a copy once at the top of the request (or hold aconst Config&if the owner is guaranteed to outlive the call), then use references. - Config hot-reload is the textbook
shared_ptruse: readers holdshared_ptr<const Config>for the duration of a request; the reloader builds a newConfigand atomically swaps the pointer (std::atomic<std::shared_ptr<T>>in C++20, or a mutex-protected pointer in C++17). Old readers finish on the old snapshot; it is freed when the last one lets go. - Cycles are silent leaks. A
Sessionholding ashared_ptr<Connection>whose callback capturesshared_ptr<Session>never frees. Valgrind reports "still reachable", not "lost". Rule: callbacks captureweak_ptrandlock()when invoked, or capture a raw pointer with a documented lifetime. - Never
deletewhat a smart pointer owns; never wrap the same raw pointer twice.shared_ptr<T> a(p); shared_ptr<T> b(p);creates two control blocks and a double free. Always copy theshared_ptr, never the raw pointer, to add an owner. - Interop with C callbacks.
void* user_datain a C API is where ownership gets lost. Passhandle.get()and keep the owner alive in a known scope, or passhandle.release()and document which C callback frees it. - 3 AM debugging. ASan reports "heap-use-after-free" on an object held through a raw pointer: some
unique_ptrowner was destroyed while an observer kept the raw pointer. Fix the design (the observer should hold aweak_ptr, or the owner must outlive the observer), not the symptom.
6. Try it¶
- (easy) Write a
unique_ptralias forDIR*(opendir/closedir) with a stateless deleter and assertsizeofequals one pointer. Done when it lists/tmpand the static_assert holds. - (easy) In
m05_shared_weak_ptr.cpp, changeNode::parenttoshared_ptrand run undervalgrind --leak-check=full. Done when you can read the report and identify the cycle. - (medium) Implement
class Connection : std::enable_shared_from_this<Connection>with anasync_read(std::function<void()> on_done)that stores a lambda capturingshared_from_this(). Show that the connection survives until the callback runs. Done when the destructor prints after the callback, not before. - (medium) Write a tiny
SharedPtr<T>with anintstrong count (non-atomic), copy/move/destroy, anduse_count(). Then make the countstd::atomic<int>and benchmark copying in a loop from one thread vs four threads sharing one object. Done when you can report the ratio and explain it. - (hard) Design the ownership for a tcpkv-style server:
Serverowns listeningSocketFd, accepts intoConnectionobjects, each handled by a worker from a pool, with a sharedKvStore. Draw the ownership graph and assign a type (value /unique_ptr/shared_ptr/weak_ptr/ reference) to every edge. Done when no edge isshared_ptrwithout a named second owner and no cycle exists.
7. Interview questions¶
Q: When would you choose unique_ptr over shared_ptr?
By default. unique_ptr expresses single ownership, costs the same as a raw pointer, and can be
moved to transfer ownership. shared_ptr is for genuinely shared lifetime where no single owner can
be named, and costs two pointers plus atomic reference counting on every copy.
Q: What is in a shared_ptr control block, and why does make_shared matter?
A strong count, a weak count, the deleter (and allocator), and with make_shared the object itself.
make_shared does one allocation instead of two and cannot leak the object if the second allocation
fails. The downside: the object's memory is held until all weak_ptrs are gone too.
Q: Is shared_ptr thread-safe?
The reference count is atomic, so copying and destroying different shared_ptr instances pointing to
the same object from several threads is safe. The managed object itself is not protected, and two
threads writing the same shared_ptr variable is a data race.
Q: What problem does weak_ptr solve?
Observing a shared object without extending its lifetime: it breaks ownership cycles (parent/child)
and lets caches or callbacks check whether the object still exists via lock(), which returns a
shared_ptr or null.
Q: How do you wrap a C API handle in a smart pointer?
std::unique_ptr<Handle, Deleter> where Deleter is a stateless functor calling the C free function.
The result is the size of a raw pointer and inlines to the same code. A function-pointer deleter also
works but adds a pointer of storage.
Q: What does a raw pointer mean in a modern C++ codebase?
Non-owning, nullable access to an object someone else owns. References mean non-owning, non-null.
Owning raw pointers (new without a smart pointer) should not appear outside of allocator internals.
Q: Why is passing shared_ptr by value through many layers a performance problem?
Each copy and destroy is an atomic read-modify-write on the control block, a full memory barrier on
x86 and a contended cache line if several threads share the object. Pass const shared_ptr& or a
plain T& when the callee does not need to participate in ownership.
Q: What happens if you construct two shared_ptrs from the same raw pointer?
Two independent control blocks each believe they own the object; the second to die double-frees it.
Always copy an existing shared_ptr to add an owner, or use shared_from_this().
8. Recap¶
- Ownership belongs in the type:
unique_ptr= single owner,shared_ptr= shared,weak_ptr= observer, raw pointer/reference = non-owning. unique_ptrcosts nothing over a raw pointer with a stateless deleter; use custom deleters for C APIs.make_unique/make_sharedinstead ofnew;make_shareddoes one allocation.shared_ptrcopies are atomic RMWs; pass byconst&orT&in hot paths.weak_ptrbreaks cycles and backs caches;lock()is a CAS loop,expired()is only a hint.- Two
shared_ptrs from one raw pointer is a double free; cycles are silent leaks. enable_shared_from_thisfor async callbacks that must keep the object alive.- Start with values and
unique_ptr; justify everyshared_ptrwith a named second owner. - Your C arena "ownership contracts" are the same discipline; C++ makes them checkable types.