CodeRigor

FREE LESSON

Smart pointers and ownership

Decide who owns what — unique, shared or borrowed — and express it with unique_ptr, shared_ptr, weak_ptr and plain references, without leaks, cycles or double frees.

A smart pointer is not "a safer pointer". It is a statement about ownership: who is responsible for destroying an object, and when. Get the ownership design right and the pointer types follow; get it wrong and no pointer type will save you. This lesson is about making that decision deliberately.

1. Three relationships

Every pointer-ish thing in your program is one of these:

Relationship Meaning Spell it
Unique owner exactly one object is responsible; ownership can move std::unique_ptr<T> (or just a T member)
Shared owner several owners; the last one out destroys it std::shared_ptr<T>
Borrower / observer uses the object, never destroys it, must not outlive it T&, const T&, T*, std::span, std::string_view
Weak observer wants to know whether it still exists std::weak_ptr<T>

The default is unique. Shared ownership is a real design choice with real costs (an atomic count, a control block, lifetimes nobody can predict), not a way to avoid thinking about lifetimes.

Raw pointers aren't evil — owning raw pointers are. A T* that means "I'm looking at it, someone else owns it" is perfectly modern C++.

2. std::unique_ptr: one owner, zero overhead

C++
#include <memory>
#include <print>
#include <string>
#include <utility>

struct Texture {
    std::string name;
    explicit Texture(std::string n) : name(std::move(n)) { std::println("load {}", name); }
    ~Texture() { std::println("free {}", name); }
};

int main() {
    auto a = std::make_unique<Texture>("grass");   // owner: a
    std::unique_ptr<Texture> b = std::move(a);      // ownership moves; a is now null
    std::println("a is {}", a ? "set" : "null");
    b.reset();                                      // destroys grass now
    std::println("end of main");
}
Moving a unique_ptr moves the ownership, not the object
start / 2
Step through the example. The Texture never moves in memory; only which pointer owns it changes. reset() destroys it on the spot.
Moving a unique_ptr moves the ownership, not the object
Step through the example. The Texture never moves in memory; only which pointer owns it changes. reset() destroys it on the spot.
  • sizeof(std::unique_ptr<T>) equals sizeof(T*) with the default deleter: it costs nothing over a raw pointer, and the compiler writes the delete for you.
  • It's move-only. Copying would mean two owners — exactly what it forbids.
  • std::make_unique<T>(args...) is the way to create one. There's no naked new to forget to match.
  • unique_ptr<T[]> owns an array (make_unique<T[]>(n)), uses delete[], and provides operator[]. Usually a std::vector is better.

Custom deleters: owning things that aren't newed

unique_ptr can own any resource with a "release" function: C handles, file descriptors, library objects.

C++
#include <memory>
#include <print>

struct Handle { int id; };
Handle* open_handle(int id) { std::println("open {}", id); return new Handle{id}; }
void close_handle(Handle* h) { std::println("close {}", h->id); delete h; }

int main() {
    // The deleter type is part of the unique_ptr type.
    std::unique_ptr<Handle, decltype(&close_handle)> h(open_handle(7), &close_handle);
    std::println("using {}", h->id);
}   // close 7

A stateless lambda or function object deleter (struct Closer { void operator()(Handle*) const; };) keeps the unique_ptr the size of a pointer; a function pointer deleter doubles it.

3. Passing smart pointers around (GotW #91)

The parameter type says what the function does with ownership:

Parameter Means
Widget& / const Widget& uses the widget; doesn't care how it's owned — the default
Widget* same, but "no widget" is allowed
std::unique_ptr<Widget> (by value) takes ownership (caller must std::move)
std::unique_ptr<Widget>& may reseat the caller's pointer (rare)
std::shared_ptr<Widget> (by value) will keep a share (stores it somewhere)
const std::shared_ptr<Widget>& might take a share, maybe not (rare)

The commonest mistake is void draw(std::shared_ptr<Widget> w) for a function that only draws: it forces callers to have a shared_ptr, and costs two atomic operations per call for nothing. Take const Widget&.

C++
#include <memory>
#include <print>
#include <vector>

struct Widget { int id; };

void draw(const Widget& w) { std::println("draw {}", w.id); }           // borrows
void keep(std::vector<std::unique_ptr<Widget>>& owners,
          std::unique_ptr<Widget> w) { owners.push_back(std::move(w)); } // takes ownership

int main() {
    std::vector<std::unique_ptr<Widget>> owners;
    auto w = std::make_unique<Widget>(1);
    draw(*w);                      // borrow: dereference, no ownership involved
    keep(owners, std::move(w));    // transfer: the call site says so
    draw(*owners.front());
}

4. std::shared_ptr: shared ownership and its costs

A shared_ptr points at the object and at a control block holding the strong count, the weak count and the deleter. Copying increments the strong count (atomically); destroying a copy decrements it; at zero, the object is destroyed. The control block lives until the weak count is zero too.

Copies share one control block
start / 2
Copying a shared_ptr bumps the strong count atomically; destroying a copy drops it. At zero, the object is destroyed.
Copies share one control block
Copying a shared_ptr bumps the strong count atomically; destroying a copy drops it. At zero, the object is destroyed.
C++
#include <memory>
#include <print>

int main() {
    auto a = std::make_shared<int>(42);
    std::println("count {}", a.use_count());   // 1
    {
        auto b = a;                             // copy: +1
        std::println("count {}", a.use_count()); // 2
    }                                           // b destroyed: -1
    std::println("count {}", a.use_count());   // 1
}
make_shared allocates once; shared_ptr(new T) twice
Every shared_ptr is two pointers: one to the object, one to the control block. With make_shared, both live in one allocation — so the object's memory is only freed when the last weak reference goes too.
make_shared allocates once; shared_ptr(new T) twice
Every shared_ptr is two pointers: one to the object, one to the control block. With make_shared, both live in one allocation — so the object's memory is only freed when the last weak reference goes too.

make_shared allocates once: object and control block in a single block. std::shared_ptr<T>(new T) allocates twice. The flip side: with make_shared, the object's memory can't be returned until the last weak_ptr is gone too.

Thread safety: the reference count is thread-safe — copies of the same shared_ptr may be created and destroyed on different threads. The object is not protected at all, and neither is a single shared_ptr variable being assigned from two threads (that needs std::atomic<std::shared_ptr<T>>).

The aliasing constructor shares ownership of one object while pointing at another — typically a member:

C++
#include <memory>
#include <print>
#include <string>

struct Config { std::string name = "prod"; int port = 443; };

int main() {
    auto cfg = std::make_shared<Config>();
    std::shared_ptr<int> port(cfg, &cfg->port);  // keeps *cfg alive, points at port
    cfg.reset();
    std::println("port {} still valid; owners {}", *port, port.use_count());
}

The cycle trap

Shared ownership forms a graph. If the graph has a cycle, the counts never reach zero and everything in the cycle leaks:

C++
struct Person {
    std::shared_ptr<Person> partner;   // owning in both directions: a cycle
};
auto a = std::make_shared<Person>();
auto b = std::make_shared<Person>();
a->partner = b;
b->partner = a;   // a and b now keep each other alive forever
A shared_ptr cycle leaks; a weak_ptr back edge doesn't
After a and b go out of scope, each Person in the left cycle still has strong count 1 — from the other one — so neither is ever destroyed. On the right, the weak edge doesn't count: bo's count reaches 0 and the pair is freed.
A shared_ptr cycle leaks; a weak_ptr back edge doesn't
After a and b go out of scope, each Person in the left cycle still has strong count 1 — from the other one — so neither is ever destroyed. On the right, the weak edge doesn't count: bo's count reaches 0 and the pair is freed.

LeakSanitizer usually reports this at exit — but its scan is conservative (a stale pointer left on the stack can make the cycle look reachable), so don't rely on it: the reliable test is a weak_ptr observer that should have expired. The fix is to decide that one direction doesn't own.

5. std::weak_ptr: observing without owning

A weak_ptr is created from a shared_ptr and doesn't keep the object alive. To use the object you must lock it, which gives you a shared_ptr that's either null (the object is gone) or keeps it alive while you use it:

C++
#include <memory>
#include <print>
#include <string>

struct Person {
    std::string name;
    std::weak_ptr<Person> partner;   // observes, doesn't own: no cycle
};

int main() {
    auto a = std::make_shared<Person>("ada");
    {
        auto b = std::make_shared<Person>("bo");
        a->partner = b;
        b->partner = a;
        if (auto p = a->partner.lock()) std::println("{}'s partner is {}", a->name, p->name);
    }   // b's last owner is gone: bo is destroyed
    if (a->partner.expired()) std::println("{}'s partner is gone", a->name);
}

Always lock() and test the result. Checking expired() and then locking is a race in multithreaded code (the object can die in between); lock() is the one atomic step.

Caches are the other classic use: hold weak_ptrs to resources so the cache never keeps something alive that nobody uses.

6. Worked example: a tree with back pointers

A tree has a natural ownership direction: parents own children. Children need to know their parent, but must not own it — that would be a cycle. So: children in unique_ptr, parent as a plain observing pointer.

C++
#include <memory>
#include <print>
#include <string>
#include <utility>
#include <vector>

class Node {
public:
    explicit Node(std::string name, Node* parent = nullptr)
        : name_(std::move(name)), parent_(parent) {}

    Node& add_child(std::string name) {
        children_.push_back(std::make_unique<Node>(std::move(name), this));
        return *children_.back();   // a borrow: *this keeps owning it
    }

    std::string path() const {
        return parent_ ? parent_->path() + "/" + name_ : name_;
    }

private:
    std::string name_;
    Node* parent_;                                  // observer: never deletes
    std::vector<std::unique_ptr<Node>> children_;   // owners
};

int main() {
    Node root("root");
    Node& src = root.add_child("src");
    Node& main_cpp = src.add_child("main.cpp");
    std::println("{}", main_cpp.path());
}   // root destroys src, which destroys main.cpp — no manual cleanup
Owning edges down, observing pointers up
Solid: unique_ptr children — the direction that owns and destroys. Dashed: the raw Node* parent_ back pointers, which never delete. No cycle of ownership, so destroying root frees everything.
Owning edges down, observing pointers up
Solid: unique_ptr children — the direction that owns and destroys. Dashed: the raw Node* parent_ back pointers, which never delete. No cycle of ownership, so destroying root frees everything.

Why unique_ptr<Node> in the vector rather than vector<Node>? Children hand out Node& and store this as their children's parent pointer. A vector<Node> would move nodes when it grows, invalidating all of those. unique_ptr keeps every node at a stable address.

Faded example — your turn

Write detach_child(i): remove child i from this node and return ownership of it to the caller. What must happen to the detached node's parent pointer, and why?

C++
std::unique_ptr<Node> detach_child(std::size_t i) {
    std::unique_ptr<Node> child = /* ??? */;
    children_.erase(children_.begin() + i);
    /* ??? */
    return child;
}
Answer

std::move(children_[i]) takes ownership out of the vector (leaving a null unique_ptr that erase then removes), and child->parent_ = nullptr — otherwise the detached node points at a parent that may be destroyed while the caller still holds the child, and path() would read freed memory.

7. enable_shared_from_this — and its trap

Sometimes an object managed by shared_ptr needs to hand out a shared_ptr to itself (say, to register a callback that keeps it alive). Writing std::shared_ptr<T>(this) is a bug: it creates a second, independent control block, so the object is deleted twice. Instead, derive from std::enable_shared_from_this<T> and call shared_from_this():

C++
#include <memory>
#include <print>

struct Session : std::enable_shared_from_this<Session> {
    std::shared_ptr<Session> self() { return shared_from_this(); }
};

int main() {
    auto s = std::make_shared<Session>();
    auto t = s->self();                        // shares s's control block
    std::println("owners {}", s.use_count());  // 2
}
shared_ptr<T>(this) makes a second control block
Two control blocks each believe they own the object, each with count 1: the second to reach zero deletes it again. shared_from_this() finds and shares the existing block instead.
shared_ptr<T>(this) makes a second control block
Two control blocks each believe they own the object, each with count 1: the second to reach zero deletes it again. shared_from_this() finds and shares the existing block instead.

The trap: shared_from_this() only works if the object is already owned by a shared_ptr. Called on a stack object, or from the constructor, it throws std::bad_weak_ptr. Make such types creatable only through a factory that returns a shared_ptr.

8. Preview: C++26 value-semantic owners

unique_ptr gives you an owning pointer whose copy is forbidden. Often you actually want a member that's heap-allocated (for an incomplete type, or a large or polymorphic object) but copies like a value. C++26 adds std::indirect<T> (deep-copying owner of a T) and std::polymorphic<T> (deep-copying owner of something derived from T):

C++
#include <memory>
#include <print>

int main() {
    std::indirect<int> a(5);
    std::indirect<int> b = a;    // deep copy: a new int on the heap
    *b = 6;
    std::println("{} {}", *a, *b);
}

They're covered properly in Level 6 alongside std::variant.

Summary

  • Decide the ownership first: unique (default), shared (only if truly shared), borrowed (references, raw pointers, views).
  • unique_ptr costs nothing and moves; make_unique to create; custom deleters for non-new resources.
  • Parameters: const T& to use, unique_ptr<T> by value to take ownership, shared_ptr<T> by value only to keep a share.
  • shared_ptr costs a control block and atomic counts; make_shared allocates once; the count is thread-safe, the object isn't.
  • Cycles of shared_ptr leak: make back-references weak_ptr (or raw observers when the lifetime is structurally guaranteed, as in a tree).
  • weak_ptr::lock() and test, in one step. Never shared_ptr<T>(this).

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

unique_ptr, shared_ptr and weak_ptr built up from scratch — including the control block.

Ownership semantics of each smart pointer and the C++ Core Guidelines rules for them.

Practical guidance: prefer unique_ptr, when shared ownership is real, and passing smart pointers to functions.

Smart pointers as RAII, and writing your own RAII handle.

.reset() on the smart pointer vs ->reset() on the pointee: a one-character bug worth recognising.

C++23 out_ptr / inout_ptr: handing smart pointers to C APIs that return ownership through T**.