Skip to main content
C++ intermediate Lesson 13 of 23

Smart Pointers in C++

Use unique_ptr, shared_ptr, and weak_ptr to manage memory safely with clear ownership semantics.

Raw pointers in C++ give you full control but place the entire burden of memory management on you. Forgetting to call delete, deleting twice, or returning a pointer from a function that the caller doesn’t know it must free — these are the bugs that smart pointers eliminate. Since C++11, the standard library ships three smart pointer types that model different ownership patterns. The rule of thumb for modern C++: never write new or delete directly; always express ownership through a smart pointer.

Why Raw Pointers Cause Problems

Understanding what goes wrong with raw pointers makes it clear why smart pointers exist. The issue is not incompetence — even careful code has multiple paths that need cleanup, and exceptions make it worse.

// Every one of these paths is a potential memory leak or double-free
void risky(bool flag) {
    int* p = new int(42);
    if (flag) return;          // leak: p is never deleted on this path
    if (*p > 40) throw std::runtime_error("too big"); // leak: exception bypasses delete
    delete p;                  // only reached on the happy path
}

Smart pointers solve this through RAII: the destructor runs no matter how you leave the scope — normal return, early return, or exception.

std::unique_ptr — Exclusive Ownership

unique_ptr is a zero-overhead wrapper that owns exactly one object. Its destructor calls delete automatically. It cannot be copied, only moved, which makes ownership transfer explicit — there is never any ambiguity about who owns the object.

#include <memory>
#include <iostream>

struct Connection {
    Connection(std::string host) : host_(std::move(host)) {
        std::cout << "Connected to " << host_ << "\n";
    }
    ~Connection() { std::cout << "Disconnected from " << host_ << "\n"; }
    void query(std::string sql) { /* ... */ }
private:
    std::string host_;
};

void process() {
    // Prefer make_unique — never write 'new' directly in application code
    auto conn = std::make_unique<Connection>("db.example.com");
    conn->query("SELECT 1");
    // conn destroyed here automatically — disconnects even if an exception was thrown
}

// Transfer ownership with std::move — the caller loses the pointer
std::unique_ptr<Connection> open_connection(std::string host) {
    return std::make_unique<Connection>(std::move(host));
}

int main() {
    auto conn = open_connection("db.example.com");
    // conn2 = conn;          // compile error — copying unique_ptr is forbidden
    auto conn2 = std::move(conn); // OK — ownership transferred, conn is now null
}

std::shared_ptr — Shared Ownership

shared_ptr uses reference counting: the object lives until the last shared owner is destroyed. Each copy of the shared_ptr increments the count; each destructor decrements it. Use shared_ptr when multiple parts of your program genuinely share ownership of an object and its lifetime is not naturally tied to any single one.

#include <memory>
#include <vector>

struct Image {
    std::string path;
    std::vector<uint8_t> pixels;
    // expensive to copy — share ownership instead
};

// Multiple components can share the same image in memory without copying it
std::shared_ptr<Image> load_image(std::string path) {
    return std::make_shared<Image>(Image{path, {/* pixels */}});
}

int main() {
    auto img = load_image("background.png");
    std::cout << img.use_count() << "\n"; // 1

    {
        auto thumb = img;  // copy shared_ptr — both own the object, count becomes 2
        std::cout << img.use_count() << "\n"; // 2
        // thumb goes out of scope here — count drops to 1
    }

    std::cout << img.use_count() << "\n"; // 1 — object still alive
}

Use make_shared over shared_ptr<T>(new T): the former does one heap allocation for the object and its control block together. The latter does two, which wastes memory and creates an exception safety gap.

std::weak_ptr — Non-Owning Observer

weak_ptr observes a shared_ptr-managed object without contributing to the reference count. Its primary purpose is breaking reference cycles: if two objects each hold a shared_ptr to the other, neither count ever reaches zero and both leak. Making one direction weak_ptr breaks the cycle.

#include <memory>
#include <iostream>

struct Node {
    int value;
    std::shared_ptr<Node> next;     // owns the next node
    std::weak_ptr<Node> prev;       // observes the previous node — no ownership

    Node(int v) : value(v) {}
};

int main() {
    auto n1 = std::make_shared<Node>(1);
    auto n2 = std::make_shared<Node>(2);

    n1->next = n2;
    n2->prev = n1;  // weak_ptr — does not extend n1's lifetime

    // To use a weak_ptr, call lock() — returns a shared_ptr or nullptr
    if (auto owner = n2->prev.lock()) {
        std::cout << "prev value: " << owner->value << "\n";  // 1
    }
} // n1 and n2 are correctly destroyed — no cycle, no leak

Custom Deleters

Both unique_ptr and shared_ptr accept a custom deleter for resources that aren’t plain heap objects — file handles, database connections, GPU resources, anything that needs a specific release function.

#include <memory>
#include <cstdio>

// FILE* managed by unique_ptr — fclose is called automatically
auto open_file(const char* path, const char* mode) {
    return std::unique_ptr<FILE, decltype(&fclose)>(
        fopen(path, mode),
        &fclose  // called instead of delete when unique_ptr is destroyed
    );
}

// For shared_ptr, the deleter is type-erased (stored alongside the control block)
void manage_shared() {
    std::shared_ptr<FILE> f(fopen("log.txt", "w"), &fclose);
    fputs("hello\n", f.get());
}

Passing Smart Pointers to Functions

Getting ownership semantics right at function boundaries is where many C++ designs go wrong. The parameter type communicates intent clearly — use it to signal whether a function takes ownership, shares it, or just borrows.

// Takes ownership — caller loses the pointer, function is responsible for lifetime
void consume(std::unique_ptr<Widget> w);

// Borrows — function just uses the object, does not affect its lifetime
void use(Widget& w);           // preferred: reference, can't be null
void use(Widget* w);           // use when null is a valid input

// Shares ownership — function will keep the object alive beyond this call
void share(std::shared_ptr<Widget> w);

// Observes without extending lifetime — use when you just need to call methods
void observe(const std::shared_ptr<Widget>& w);

void caller() {
    auto w = std::make_unique<Widget>();
    use(*w);                      // pass underlying object — no ownership change
    use(w.get());                 // raw pointer — fine for non-owning use
    consume(std::move(w));        // transfer ownership — w is null after this
}

Common Pitfalls

Shared_ptr from the same raw pointer twice creates two independent reference counts, both of which try to delete the object:

Widget* raw = new Widget();
std::shared_ptr<Widget> p1(raw);
std::shared_ptr<Widget> p2(raw); // UNDEFINED BEHAVIOR — double delete
// Always use make_shared or copy an existing shared_ptr

Circular shared_ptr references cause leaks because the count never reaches zero. Break cycles with weak_ptr.

Storing this in a shared_ptr inside a member function requires inheriting from std::enable_shared_from_this:

struct Session : std::enable_shared_from_this<Session> {
    void async_read() {
        auto self = shared_from_this(); // correct: shares ownership with callers
        // pass self into lambda to keep Session alive during the async operation
    }
};

Choosing the Right Pointer

SituationUse
Single owner, no sharingunique_ptr
Shared ownership neededshared_ptr
Observer that must not extend lifetimeweak_ptr
Non-owning parameter to a functionraw pointer or reference
Transfer ownership into a functionunique_ptr by value

Frequently Asked Questions

Should I ever use raw pointers in modern C++?
For non-owning observation (passing a pointer you don't own to a function), raw pointers or references are fine. For ownership, always use smart pointers.
Why prefer make_shared over shared_ptr<T>(new T)?
make_shared performs a single allocation for both the object and the control block, improving cache locality and exception safety.