Skip to content

3. Smart Pointers

What & why

When you do need a heap-allocated object, a smart pointer owns it and deletes it automatically — RAII for single objects. More importantly, the type you choose documents ownership: who is responsible for the object's lifetime. That's the question behind most C++ memory bugs, and smart pointers make the answer explicit.

All three live in <memory>.

std::unique_ptr: exactly one owner

#include <memory>

struct Widget {
    int id;
    explicit Widget(int i) : id{i} {}
};

int main() {
    std::unique_ptr<Widget> w = std::make_unique<Widget>(7);
    std::cout << w->id << '\n';      // use like a pointer
    std::cout << (*w).id << '\n';

    if (w) { ... }                   // true if it owns something
}   // Widget deleted here automatically
  • Zero overhead compared to a raw pointer.
  • Cannot be copied — there can only be one owner.
  • Can be moved — ownership transfers:
auto a = std::make_unique<Widget>(1);
auto b = a;                // ERROR: copy is deleted
auto c = std::move(a);     // OK: c owns it now; a is empty (nullptr)

Transferring ownership through functions

// Factory: returns ownership to the caller
std::unique_ptr<Widget> make_widget(int id) {
    return std::make_unique<Widget>(id);    // no std::move needed on return
}

// Sink: takes ownership
void store(std::unique_ptr<Widget> w) {
    registry.push_back(std::move(w));
}

store(make_widget(3));
auto w = make_widget(4);
store(std::move(w));       // explicit: "I'm giving this away"

Other operations

w.get();          // raw, non-owning pointer (still owned by w)
w.reset();        // delete the object now; w becomes empty
w.reset(new ...); // replace (prefer: w = std::make_unique<...>())
w.release();      // give up ownership WITHOUT deleting — you must delete it. Rare.

std::unique_ptr<T[]> exists for arrays, but just use std::vector.

std::shared_ptr: shared ownership

Several owners; the object is deleted when the last one goes away. It keeps a reference count in a separately allocated control block.

auto a = std::make_shared<Widget>(1);
std::cout << a.use_count() << '\n';    // 1
{
    auto b = a;                         // copy: both own it
    std::cout << a.use_count() << '\n'; // 2
}
std::cout << a.use_count() << '\n';    // 1

Costs: an extra allocation (merged into one by make_shared), atomic reference-count updates on every copy, and — worst — it makes lifetimes hard to reason about. Use shared_ptr only when ownership is genuinely shared (e.g. a cache entry used by several in-flight requests, a node referenced from multiple graphs). unique_ptr is the default.

std::weak_ptr: observe without owning

Two shared_ptrs that point at each other never reach a count of zero — a cycle leak:

struct Node {
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;   // cycle! neither node is ever freed
};

Break it with weak_ptr, which doesn't contribute to the count:

struct Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;     // non-owning back-link
};

if (auto p = node->prev.lock()) {   // lock() → shared_ptr, empty if already deleted
    use(*p);
}

Also useful for caches and observers that shouldn't keep objects alive.

Raw pointers and references still have a job

Smart pointers express ownership. For non-owning access, use references or raw pointers:

void print(const Widget& w);           // needs a widget, doesn't care who owns it
void maybe_print(const Widget* w);     // may be null, doesn't own it

auto owner = std::make_unique<Widget>(1);
print(*owner);
maybe_print(owner.get());

Don't pass smart pointers to functions that just use the object. void print(const std::unique_ptr<Widget>&) needlessly restricts callers.

Guidelines for function signatures

Signature Meaning
void f(Widget&) / const Widget& uses the object; doesn't care about ownership
void f(Widget*) uses it; may be null; doesn't own
void f(std::unique_ptr<Widget>) takes ownership
std::unique_ptr<Widget> f() gives ownership to the caller
void f(std::shared_ptr<Widget>) will share ownership (keeps a copy)
void f(const std::shared_ptr<Widget>&) might keep a copy; rarely what you want

Always use make_unique / make_shared

auto p = std::make_unique<Widget>(1);          // good
std::unique_ptr<Widget> q{new Widget{1}};      // works, but writes `new`

make_shared also performs one allocation instead of two.

Custom deleters

unique_ptr can manage non-memory resources too:

#include <cstdio>

struct FileCloser {
    void operator()(std::FILE* f) const { std::fclose(f); }
};
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;

FilePtr f{std::fopen("data.txt", "r")};
if (!f) { /* failed to open */ }

Polymorphism and containers

The most common legitimate reason for heap allocation: a container of objects of different derived types (lesson 7):

std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(1.0));
shapes.push_back(std::make_unique<Square>(2.0));
for (const auto& s : shapes) {
    std::cout << s->area() << '\n';
}

Common mistakes

  • Using shared_ptr by default. Start with unique_ptr; upgrade only when ownership is truly shared.
  • Creating two smart pointers from one raw pointer — double delete:
    Widget* raw = new Widget{1};
    std::unique_ptr<Widget> a{raw};
    std::unique_ptr<Widget> b{raw};   // boom
    
  • Using a unique_ptr after moving from it — it's nullptr.
  • Keeping .get() pointers after the owner is destroyed — dangling.
  • shared_ptr cycles — use weak_ptr for back-references.
  • Passing smart pointers where a reference would do.
  • std::shared_ptr<T>(this) inside a member function — creates a second control block. Use std::enable_shared_from_this.

Exercises

  1. Build a singly linked list of ints with std::unique_ptr<Node> next. Implement push_front, print, and reverse. What happens when you destroy a list with 1,000,000 nodes? (Hint: recursive destructors — fix it with an iterative destructor.)
  2. Write a factory std::unique_ptr<Shape> make_shape(std::string_view name) that returns nullptr for unknown names.
  3. Demonstrate a shared_ptr cycle leak with a parent/child pair, confirm it with -fsanitize=address (LeakSanitizer), then fix it with weak_ptr.
  4. Wrap a C API pair like std::fopen/std::fclose in a unique_ptr with a lambda deleter: std::unique_ptr<std::FILE, decltype(&std::fclose)>.
  5. Refactor a function that takes const std::unique_ptr<Widget>& to take const Widget&. Why is the new version more reusable?

Recap

  • Smart pointers are RAII for single heap objects, and their type documents ownership.
  • std::unique_ptr — sole owner, zero cost, move-only. The default.
  • std::shared_ptr — shared ownership via reference counting. Use deliberately.
  • std::weak_ptr — non-owning observer; breaks cycles; lock() to use.
  • Non-owning access: plain references and raw pointers.
  • Create with std::make_unique / std::make_shared, never with new.

Next: Classes: fundamentals →