Skip to main content
C++ · lesson 13 of 24
C++ intermediate Lesson 13 of 24

Smart Pointers in C++

unique_ptr, shared_ptr and weak_ptr run for real: a leak LeakSanitizer catches, reference counts, a shared_ptr cycle that never frees, and custom deleters.

By SkillByExample · Updated

Raw owning pointers put every cleanup path on you: each early return and each exception needs its own delete. Smart pointers tie the delete to a destructor, so it runs however the scope is left. The modern rule: don’t write new or delete in application code.

Why Raw Pointers Leak

An exception skips the delete that the happy path would have reached. Both functions below throw; watch what each one leaves behind.

#include <iostream>
#include <memory>
#include <stdexcept>

void risky_raw(bool fail) {
    int* p = new int(42);
    if (fail) throw std::runtime_error("raw failed");   // skips the delete below
    delete p;
}

void safe_smart(bool fail) {
    auto p = std::make_unique<int>(42);                  // freed on every exit path
    if (fail) throw std::runtime_error("smart failed");
}

int main() {
    for (auto f : {risky_raw, safe_smart}) {
        try { f(true); } catch (const std::exception& e) { std::cout << "caught: " << e.what() << "\n"; }
    }
}
$ g++ -std=c++20 -Wall -Wextra -o leak leak.cpp
$ ./leak
caught: raw failed
caught: smart failed

The output looks identical — nothing on screen says anything went wrong. LeakSanitizer does:

$ g++ -std=c++20 -g -fsanitize=address -o leak leak.cpp
$ ./leak 2>&1 | grep -E 'ERROR|Direct|#1|SUMMARY'
==6059==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 4 byte(s) in 1 object(s) allocated from:
    #1 0x55f649a68426 in risky_raw(bool) /tmp/demo/leak.cpp:6
SUMMARY: AddressSanitizer: 4 byte(s) leaked in 1 allocation(s).

The four-byte int from risky_raw (line 6) was never freed, because throw left the function before delete p. safe_smart doesn’t appear: the unique_ptr’s destructor ran during stack unwinding.

std::unique_ptr — Exclusive Ownership

unique_ptr owns one object and deletes it in its destructor. It can’t be copied, only moved, so there is always exactly one owner.

#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>

struct Connection {
    std::string host;
    explicit Connection(std::string h) : host(std::move(h)) { std::cout << "connect " << host << "\n"; }
    ~Connection() { std::cout << "disconnect " << host << "\n"; }
};

std::unique_ptr<Connection> open(std::string host) {       // factory: caller owns result
    return std::make_unique<Connection>(std::move(host));
}

int main() {
    try {
        auto conn = open("db-a");
        throw std::runtime_error("query failed");          // conn still cleaned up
    } catch (const std::exception& e) {
        std::cout << "caught: " << e.what() << "\n";
    }
    auto conn = open("db-b");
    auto conn2 = std::move(conn);                          // ownership moves, no copy
    std::cout << std::boolalpha << "conn null: " << (conn == nullptr)
              << ", conn2 -> " << conn2->host << "\n";
    std::cout << "sizeof(unique_ptr) = " << sizeof(conn2)
              << ", sizeof(raw pointer) = " << sizeof(Connection*) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o unique unique.cpp
$ ./unique
connect db-a
disconnect db-a
caught: query failed
connect db-b
conn null: true, conn2 -> db-b
sizeof(unique_ptr) = 8, sizeof(raw pointer) = 8
disconnect db-b

db-a was disconnected before the catch block ran: unwinding destroyed conn on the way out of the try. Returning a unique_ptr from open hands ownership to the caller with no copy. After std::move, conn is null and conn2 owns the connection, which is released at the end of main. And a unique_ptr is the same 8 bytes as a raw pointer — the safety is free.

std::shared_ptr — Shared Ownership

shared_ptr counts its owners; the object is destroyed when the last one lets go.

#include <iostream>
#include <memory>
#include <string>
#include <vector>

struct Image {
    std::string path;
    explicit Image(std::string p) : path(std::move(p)) {}
    ~Image() { std::cout << "free " << path << "\n"; }
};

int main() {
    std::vector<std::shared_ptr<Image>> gallery;
    {
        auto img = std::make_shared<Image>("background.png");
        std::cout << "count " << img.use_count() << "\n";
        {
            auto thumb = img;                          // copy: count 2
            gallery.push_back(img);                    // copy: count 3
            std::cout << "count " << img.use_count() << "\n";
        }
        std::cout << "count " << img.use_count() << "\n";
    }                                                  // img gone, gallery still owns it
    std::cout << "scope left, gallery holds it: count " << gallery[0].use_count() << "\n";
    gallery.clear();                                   // last owner: object freed now
    std::cout << "sizeof(shared_ptr) = " << sizeof(std::shared_ptr<Image>) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o shared shared.cpp
$ ./shared
count 1
count 3
count 2
scope left, gallery holds it: count 1
free background.png
sizeof(shared_ptr) = 16

Copying a shared_ptr bumps the count (1 → 3 with thumb and the vector entry), and destroying a copy lowers it. When img itself went out of scope the image survived, because the vector still owned it; gallery.clear() dropped the last owner and free background.png printed right then. A shared_ptr is two pointers (16 bytes) — one to the object, one to the shared control block holding the counts — and each copy updates the count atomically, so pass it around only when you mean to share ownership.

std::weak_ptr — Breaking Cycles

If two objects own each other through shared_ptr, neither count can reach zero:

#include <iostream>
#include <memory>

struct StrongNode {
    int id;
    std::shared_ptr<StrongNode> other;                 // owns its neighbour
    ~StrongNode() { std::cout << "  ~StrongNode " << id << "\n"; }
};

struct WeakNode {
    int id;
    std::shared_ptr<WeakNode> next;                    // owns forward
    std::weak_ptr<WeakNode> prev;                      // observes backward
    ~WeakNode() { std::cout << "  ~WeakNode " << id << "\n"; }
};

int main() {
    std::cout << "strong cycle:\n";
    {
        auto a = std::make_shared<StrongNode>(1);
        auto b = std::make_shared<StrongNode>(2);
        a->other = b;
        b->other = a;
        std::cout << "  a count " << a.use_count() << "\n";
    }
    std::cout << "weak back-pointer:\n";
    {
        auto a = std::make_shared<WeakNode>(1);
        auto b = std::make_shared<WeakNode>(2);
        a->next = b;
        b->prev = a;
        std::cout << "  a count " << a.use_count() << "\n";
    }
    std::cout << "end of main\n";
}
$ g++ -std=c++20 -Wall -Wextra -o cycle cycle.cpp
$ ./cycle
strong cycle:
  a count 2
weak back-pointer:
  a count 1
  ~WeakNode 1
  ~WeakNode 2
end of main

The strong pair never printed a destructor, even at the end of main — both nodes leaked, each kept alive by the other (a count 2: the local plus b->other). In the second pair prev is a weak_ptr, which doesn’t count, so a has one owner, and both nodes are destroyed when the locals go. Rule of thumb: parent-to-child links own, child-to-parent links observe.

A weak_ptr can’t be dereferenced directly; lock() gives you a temporary shared_ptr if the object still exists:

#include <iostream>
#include <memory>

int main() {
    auto owner = std::make_shared<int>(42);
    std::weak_ptr<int> observer = owner;

    if (auto p = observer.lock())                      // temporary shared ownership
        std::cout << "alive: " << *p << ", count while locked " << owner.use_count() << "\n";
    std::cout << "count after lock scope " << owner.use_count() << "\n";

    owner.reset();                                     // last owner lets go
    std::cout << std::boolalpha << "expired: " << observer.expired() << "\n";
    if (auto p = observer.lock()) std::cout << "still " << *p << "\n";
    else std::cout << "lock() returned null\n";
}
$ g++ -std=c++20 -Wall -Wextra -o weak weak.cpp
$ ./weak
alive: 42, count while locked 2
count after lock scope 1
expired: true
lock() returned null

While locked, the count rose to 2, so the object couldn’t disappear mid-use. After the last owner’s reset(), expired() is true and lock() returns null — the check is built into the type.

Custom Deleters

Smart pointers can release things other than newed memory — here a C FILE* that must be fclosed.

#include <cstdio>
#include <iostream>
#include <memory>

int main() {
    auto closer = [](std::FILE* f) { std::cout << "fclose called\n"; std::fclose(f); };
    {
        std::unique_ptr<std::FILE, decltype(closer)> log(std::fopen("app.log", "w"), closer);
        std::fputs("hello\n", log.get());              // .get() for C APIs
        std::cout << "writing...\n";
    }                                                  // closer runs here
    using FnPtrDeleter = std::unique_ptr<std::FILE, int (*)(std::FILE*)>;
    std::cout << "sizeof with lambda deleter: " << sizeof(std::unique_ptr<std::FILE, decltype(closer)>)
              << ", with function-pointer deleter: " << sizeof(FnPtrDeleter) << "\n";

    std::shared_ptr<std::FILE> shared(std::fopen("app.log", "r"),
                                      [](std::FILE* f) { std::cout << "shared fclose\n"; std::fclose(f); });
    char line[16];
    std::fgets(line, sizeof line, shared.get());
    std::cout << "read back: " << line;
}
$ g++ -std=c++20 -Wall -Wextra -o deleters deleters.cpp
$ ./deleters
writing...
fclose called
sizeof with lambda deleter: 8, with function-pointer deleter: 16
read back: hello
shared fclose

The unique_ptr’s deleter ran at the closing brace, so the file was flushed and closed before being reopened for reading. The deleter is part of a unique_ptr’s type: a captureless lambda adds nothing (8 bytes), while a function-pointer deleter must be stored (16 bytes). shared_ptr keeps its deleter in the control block, so the type stays plain std::shared_ptr<std::FILE>. .get() hands the raw pointer to C APIs without giving up ownership.

Passing Smart Pointers to Functions

The parameter type says what a function does with ownership.

#include <iostream>
#include <memory>

struct Widget { int id = 7; ~Widget() { std::cout << "  ~Widget\n"; } };

void use(const Widget& w)               { std::cout << "  use: borrows widget " << w.id << "\n"; }
void maybe_use(const Widget* w)         { std::cout << "  maybe_use: " << (w ? "got one" : "null") << "\n"; }
void consume(std::unique_ptr<Widget> w) { std::cout << "  consume: now owns " << w->id << "\n"; }
void share(std::shared_ptr<Widget> w)   { std::cout << "  share: count " << w.use_count() << "\n"; }
void peek(const std::shared_ptr<Widget>& w) { std::cout << "  peek: count " << w.use_count() << "\n"; }

int main() {
    auto u = std::make_unique<Widget>();
    use(*u);
    maybe_use(u.get());
    maybe_use(nullptr);
    consume(std::move(u));                 // ~Widget runs when consume returns
    std::cout << "after consume, u is " << (u ? "set" : "null") << "\n";

    auto s = std::make_shared<Widget>();
    share(s);                              // by value: +1 while the call runs
    peek(s);                               // by const&: no count change
    std::cout << "end of main\n";
}
$ g++ -std=c++20 -Wall -Wextra -o passing passing.cpp
$ ./passing
  use: borrows widget 7
  maybe_use: got one
  maybe_use: null
  consume: now owns 7
  ~Widget
after consume, u is null
  share: count 2
  peek: count 1
end of main
  ~Widget

use and maybe_use only borrow, so they take a reference or raw pointer and don’t care how the widget is owned. consume takes a unique_ptr by value, which forces the caller’s std::move and makes the transfer visible — the widget died when consume returned. share takes a shared_ptr by value and holds a second reference during the call (count 2); peek takes it by const& and doesn’t touch the count. If a function only uses the object, don’t make it take a smart pointer at all.

Common Pitfalls

Two shared_ptrs built from the same raw pointer each start their own count, so both delete the object:

#include <iostream>
#include <memory>

struct Widget { ~Widget() { std::cout << "~Widget" << std::endl; } };

int main() {
    Widget* raw = new Widget();
    std::shared_ptr<Widget> p1(raw);
    std::shared_ptr<Widget> p2(raw);       // a second, independent control block
    std::cout << "p1 count " << p1.use_count() << ", p2 count " << p2.use_count() << std::endl;
}
$ g++ -std=c++20 -Wall -Wextra -o double_owner double_owner.cpp
$ ./double_owner; echo "exit code: $?"
p1 count 1, p2 count 1
~Widget
~Widget
free(): double free detected in tcache 2
Aborted
exit code: 134

Each count was 1, each owner ran ~Widget, and the second delete aborted the program. Create the object with make_shared and copy the shared_ptr, never the raw pointer.

Getting a shared_ptr to this requires std::enable_shared_from_this, and the object must already be owned by one:

#include <iostream>
#include <memory>

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

int main() {
    auto s = std::make_shared<Session>();
    auto self = s->keep_alive();               // shares s's control block
    std::cout << "count " << s.use_count() << "\n";

    Session on_stack;                          // not owned by any shared_ptr
    try {
        on_stack.keep_alive();
    } catch (const std::bad_weak_ptr& e) {
        std::cout << "bad_weak_ptr: " << e.what() << "\n";
    }
}
$ g++ -std=c++20 -Wall -Wextra -o from_this from_this.cpp
$ ./from_this
count 2
bad_weak_ptr: bad_weak_ptr

For s, shared_from_this() joined the existing control block (count 2) — the pattern async code uses to keep an object alive until its callback runs. On a stack object there is no control block to join, and C++17 guarantees std::bad_weak_ptr.

Choosing the Right Pointer

SituationUse
Single ownerunique_ptr (the default)
Genuinely shared ownershipshared_ptr via make_shared
Observer that must not extend lifetime, or a back-pointerweak_ptr
Function that only uses the objectreference, or raw pointer if null is allowed
Function that takes ownershipunique_ptr by value

Practice

  1. Rewrite risky_raw in leak.cpp without a smart pointer or a leak, and without try/catch inside it.

    Solution
    void risky_raw(bool fail) {
        int value = 42;                                // no heap allocation needed
        if (fail) throw std::runtime_error("raw failed");
    }

    The simplest fix for a small object is not to heap-allocate it. When the heap is required, use std::make_unique; a raw new with manual try/catch cleanup is what smart pointers exist to replace.

  2. Fix the strong cycle in cycle.cpp without changing StrongNode’s type, by breaking the link before the scope ends.

    Solution

    Add a->other.reset(); before the closing brace. b’s count drops to 1 (just the local), and when the locals are destroyed both counts reach zero: ~StrongNode 2 and then ~StrongNode 1 print. It works, but it relies on remembering to break the cycle by hand — weak_ptr makes it automatic.

  3. Make a std::unique_ptr<int[]> holding five ints, fill it with squares, and print them.

    Solution
    auto arr = std::make_unique<int[]>(5);   // value-initialized: all 0
    for (int i = 0; i < 5; ++i) arr[i] = i * i;
    for (int i = 0; i < 5; ++i) std::cout << arr[i] << " ";   // 0 1 4 9 16

    The array form calls delete[] and provides operator[]. In most code a std::vector<int> is still the better choice.

  4. In passing.cpp, change share to take const std::shared_ptr<Widget>& and store it into a global std::vector<std::shared_ptr<Widget>>. What count does peek then print?

    Solution
    1. Storing the reference into the vector makes a copy, so the vector is a second owner that outlives the call. Taking shared_ptr by value and then std::move-ing it into the vector would do the same with one fewer atomic increment.
  5. Fix double_owner.cpp so it prints ~Widget once and exits with code 0.

    Solution
    auto p1 = std::make_shared<Widget>();
    std::shared_ptr<Widget> p2 = p1;         // copy the shared_ptr, not the raw pointer

    Both now share one control block and print count 2; the single ~Widget runs when the last of them is destroyed.

Frequently Asked Questions

Should I ever use raw pointers in modern C++?
Yes, for non-owning access: a raw pointer or reference passed to a function that just uses an object is fine. What modern C++ avoids is raw owning pointers — anything you would have to delete yourself belongs in a unique_ptr, shared_ptr or container.
Why prefer make_shared over shared_ptr<T>(new T)?
make_shared allocates the object and its reference-count control block in one allocation instead of two, and it never exposes a raw pointer that could accidentally be handed to a second shared_ptr, which would give the object two independent counts and a double delete.
Does unique_ptr have any overhead compared to a raw pointer?
With the default deleter or a stateless lambda deleter, a unique_ptr is exactly the size of a raw pointer (8 bytes on 64-bit) and its operations compile to the same code. A function-pointer deleter adds the pointer's size, making it 16 bytes.
How does weak_ptr break reference cycles?
A weak_ptr observes an object without increasing its reference count. If two objects point at each other through shared_ptr, neither count reaches zero and both leak; making one direction a weak_ptr lets the counts fall to zero. Call lock() to get a temporary shared_ptr, which is null if the object is gone.
What does shared_from_this do?
A class deriving from std::enable_shared_from_this can call shared_from_this() to get a shared_ptr sharing the existing control block. The object must already be owned by a shared_ptr; otherwise, since C++17, it throws std::bad_weak_ptr.