C++ Smart Pointers unique shared weak ptr guide
|

C++ Smart Pointers: unique_ptr, shared_ptr & weak_ptr Explained

If you followed the previous lesson on dynamic memory, you now know the raw new/delete dance. You allocate on the heap, you track the pointer, you free it manually — and somewhere between an early return and an unexpected exception, you leak memory. Every single time. It’s not a question of if but when.

Smart pointers, introduced in C++11, eliminate this entire class of bugs. They wrap a raw pointer inside an object that automatically calls delete when it’s no longer needed. No manual cleanup. No forgotten delete. No double-free nightmares. This lesson covers the three smart pointers you’ll use in every serious C++ project: std::unique_ptr, std::shared_ptr, and std::weak_ptr.

Why Smart Pointers Exist

Consider this innocent-looking function:

#include <iostream>
#include <stdexcept>

void processData() {
    int* data = new int[1000];

    // ... some work ...
    if (/* error condition */ true) {
        throw std::runtime_error("Something broke");
        // data is LEAKED — delete[] never runs
    }

    delete[] data;
}

The exception unwinds the stack, destroys local variables, and jumps to the nearest catch block. But data is a raw pointer — a plain integer holding a memory address. Destroying it does nothing to the heap memory it points to. That memory is gone forever (until the program exits). Multiply this across a long-running server and you’ve got a slow, silent catastrophe.

Smart pointers solve this by tying the lifetime of heap memory to the lifetime of a stack object. When the stack object dies — whether by normal scope exit, early return, or exception — it runs its destructor and frees the memory. Always.

The RAII Principle

This pattern has a name: RAII — Resource Acquisition Is Initialization. The idea is simple: acquire a resource (memory, file handle, network socket) inside a constructor, and release it inside the destructor. Since C++ guarantees that destructors run when objects leave scope, the resource is always cleaned up. If you’ve worked with classes and objects, you’ve already used RAII without knowing it — every std::string and std::vector you’ve created manages its own memory this way.

Smart pointers are RAII wrappers specifically for heap-allocated memory. They live in the <memory> header. Let’s start with the one you should reach for first.

std::unique_ptr — Exclusive Ownership

A std::unique_ptr owns its pointed-to resource exclusively. There is exactly one unique_ptr pointing to any given object at any time. When that unique_ptr is destroyed, the object is deleted. No reference counting, no overhead — it’s essentially free compared to a raw pointer. The cppreference documentation confirms that unique_ptr has zero size overhead when using the default deleter.

Basic Usage

#include <iostream>
#include <memory>

struct Sensor {
    std::string name;
    double reading;

    Sensor(std::string n, double r) : name(std::move(n)), reading(r) {
        std::cout << "Sensor " << name << " created\n";
    }
    ~Sensor() {
        std::cout << "Sensor " << name << " destroyed\n";
    }
};

int main() {
    // Create a unique_ptr using make_unique (preferred)
    auto sensor = std::make_unique<Sensor>("Temperature", 23.5);

    // Access members with -> just like a raw pointer
    std::cout << sensor->name << ": " << sensor->reading << "\n";

    // Dereference with * also works
    Sensor& ref = *sensor;
    std::cout << "Via ref: " << ref.reading << "\n";

    // Check if it holds an object
    if (sensor) {
        std::cout << "sensor is valid\n";
    }

    // Release ownership (returns raw pointer, unique_ptr becomes null)
    Sensor* raw = sensor.release();
    std::cout << "After release, sensor is "
              << (sensor ? "valid" : "null") << "\n";

    delete raw;  // We own it now, we must delete it

    // Reset destroys the held object and optionally takes a new one
    auto s2 = std::make_unique<Sensor>("Pressure", 101.3);
    s2.reset();  // Sensor destroyed here
    std::cout << "s2 is " << (s2 ? "valid" : "null") << "\n";

    return 0;
}
// Output:
// Sensor Temperature created
// Temperature: 23.5
// Via ref: 23.5
// sensor is valid
// After release, sensor is null
// Sensor Pressure created
// Sensor Pressure destroyed
// s2 is null

Notice how s2 automatically destroys the Pressure sensor when reset() is called — and if we hadn’t called reset(), it would have been destroyed at the closing brace of main().

Move Semantics with unique_ptr

Since unique_ptr enforces exclusive ownership, it cannot be copied. Trying to copy one is a compile error. But it can be moved — transferring ownership from one unique_ptr to another. This connects directly to the copy constructors and move semantics you’ve studied.

#include <iostream>
#include <memory>

int main() {
    auto original = std::make_unique<std::string>("Hello, Smart World!");

    // auto copy = original;  // COMPILE ERROR: unique_ptr cannot be copied

    // Transfer ownership via std::move
    auto moved = std::move(original);

    std::cout << "original is " << (original ? "valid" : "null") << "\n";
    std::cout << "moved holds: " << *moved << "\n";

    // Pass to a function by value (transfers ownership)
    auto consume = [](std::unique_ptr<std::string> ptr) {
        std::cout << "Consumed: " << *ptr << "\n";
        // ptr destroyed at end of lambda — string freed
    };

    consume(std::move(moved));
    std::cout << "moved is " << (moved ? "valid" : "null") << "\n";

    return 0;
}
// Output:
// original is null
// moved holds: Hello, Smart World!
// Consumed: Hello, Smart World!
// moved is null

After a move, the source unique_ptr is null. Accessing it would be undefined behavior. Always check or simply don’t use the source after moving.

Factory Functions Returning unique_ptr

One of the most powerful patterns in modern C++ is a factory function that returns unique_ptr. The caller gets ownership, and the compiler applies copy elision (RVO) so there’s no performance cost.

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

class Logger {
    std::string filename;
public:
    Logger(std::string fname) : filename(std::move(fname)) {
        std::cout << "Logger opened: " << filename << "\n";
    }
    ~Logger() { std::cout << "Logger closed: " << filename << "\n"; }
    void log(const std::string& msg) {
        std::cout << "[" << filename << "] " << msg << "\n";
    }
};

// Factory function — caller receives exclusive ownership
std::unique_ptr<Logger> createLogger(const std::string& name) {
    return std::make_unique<Logger>(name);
}

int main() {
    auto logger = createLogger("app.log");
    logger->log("Application started");
    logger->log("Processing data...");
    // Logger automatically closed when main() exits
    return 0;
}

This is the recommended way to write factory functions in modern C++. The C++ Core Guidelines (F.26) explicitly recommend returning unique_ptr from factories.

std::shared_ptr — Shared Ownership

Sometimes multiple parts of your program genuinely need to share ownership of a resource. A cache that hands out objects to multiple consumers. An observer pattern where multiple observers reference the same subject. This is where std::shared_ptr steps in.

A shared_ptr uses reference counting: an internal counter tracks how many shared_ptr instances point to the same object. Every copy increments the count; every destruction decrements it. When the count hits zero, the object is deleted.

Reference Counting in Action

#include <iostream>
#include <memory>

struct Connection {
    int id;
    Connection(int i) : id(i) { std::cout << "Connection " << id << " opened\n"; }
    ~Connection() { std::cout << "Connection " << id << " closed\n"; }
};

int main() {
    auto conn = std::make_shared<Connection>(42);
    std::cout << "use_count: " << conn.use_count() << "\n";  // 1

    {
        auto conn2 = conn;  // Copy — both share ownership
        std::cout << "use_count: " << conn.use_count() << "\n";  // 2

        auto conn3 = conn;
        std::cout << "use_count: " << conn.use_count() << "\n";  // 3
    }
    // conn2 and conn3 destroyed — count drops to 1
    std::cout << "use_count: " << conn.use_count() << "\n";  // 1

    return 0;
}
// Connection 42 closed — printed when conn is destroyed at the end of main

The reference counting is thread-safe (the count itself uses atomic operations), but accessing the pointed-to object from multiple threads still requires your own synchronization. See the cppreference shared_ptr page for the precise thread-safety guarantees.

Cost: shared_ptr is heavier than unique_ptr. It allocates a separate control block for the reference count (unless you use make_shared, which combines them). Every copy and destruction touches the atomic counter. Don’t use it when unique_ptr would suffice.

std::weak_ptr — Breaking Circular References

shared_ptr has an Achilles heel: circular references. If object A holds a shared_ptr to B, and B holds a shared_ptr to A, neither reference count ever reaches zero. Both objects leak permanently.

std::weak_ptr solves this. It observes a shared_ptr-managed object without incrementing the reference count. To actually use the object, you must call lock(), which returns a shared_ptr if the object still exists, or an empty one if it’s been destroyed.

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

struct Employee;

struct Team {
    std::string name;
    std::vector<std::shared_ptr<Employee>> members;

    Team(std::string n) : name(std::move(n)) {}
    ~Team() { std::cout << "Team " << name << " destroyed\n"; }
};

struct Employee {
    std::string name;
    std::weak_ptr<Team> team;  // weak_ptr breaks the cycle!

    Employee(std::string n) : name(std::move(n)) {}
    ~Employee() { std::cout << "Employee " << name << " destroyed\n"; }

    void showTeam() {
        // lock() converts weak_ptr to shared_ptr (may be null)
        if (auto t = team.lock()) {
            std::cout << name << " is on team " << t->name << "\n";
        } else {
            std::cout << name << "'s team no longer exists\n";
        }
    }
};

int main() {
    auto devTeam = std::make_shared<Team>("Backend");
    auto alice = std::make_shared<Employee>("Alice");
    auto bob = std::make_shared<Employee>("Bob");

    // Team owns employees (shared_ptr)
    devTeam->members.push_back(alice);
    devTeam->members.push_back(bob);

    // Employees observe team (weak_ptr — no ownership)
    alice->team = devTeam;
    bob->team = devTeam;

    alice->showTeam();  // Alice is on team Backend

    // Check if weak_ptr's target is still alive
    std::cout << "team expired? " << alice->team.expired() << "\n";  // 0 (false)

    return 0;
}
// All objects properly destroyed — no leaks

If Employee::team were a shared_ptr instead of weak_ptr, the Team and Employee objects would hold each other alive forever. The weak_ptr documentation explains the full API, including expired() and lock().

make_unique and make_shared

You should almost always create smart pointers with std::make_unique (C++14) and std::make_shared (C++11) instead of calling new directly. Two reasons:

  1. Exception safety: std::shared_ptr<Widget>(new Widget) involves two separate steps — the new allocation and the shared_ptr construction. If something throws between them, you leak. make_shared does both atomically.
  2. Performance: make_shared allocates the object and the control block in a single memory allocation instead of two, improving cache locality and reducing allocator overhead. Herb Sutter’s GotW #89 covers this in detail.
#include <memory>

struct Config {
    int timeout;
    std::string host;
    Config(int t, std::string h) : timeout(t), host(std::move(h)) {}
};

int main() {
    // Preferred — safe, efficient, no "new" keyword
    auto cfg1 = std::make_unique<Config>(30, "localhost");
    auto cfg2 = std::make_shared<Config>(60, "api.sudoflare.com");

    // Avoid — less safe, potentially less efficient
    std::unique_ptr<Config> cfg3(new Config(30, "localhost"));
    std::shared_ptr<Config> cfg4(new Config(60, "api.sudoflare.com"));

    // Arrays with make_unique (C++14)
    auto arr = std::make_unique<int[]>(100);  // array of 100 ints
    arr[0] = 42;

    return 0;
}

Custom Deleters

Sometimes you’re not managing memory allocated with new. Maybe you have a C library that uses malloc/free, or a handle-based API like file descriptors or database connections. Smart pointers support custom deleters — functions that run instead of delete when the resource is released.

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

int main() {
    // Custom deleter for FILE* — calls fclose instead of delete
    auto fileDeleter = [](FILE* f) {
        if (f) {
            std::cout << "Closing file\n";
            std::fclose(f);
        }
    };

    {
        std::unique_ptr<FILE, decltype(fileDeleter)> file(
            std::fopen("test.txt", "w"), fileDeleter
        );
        if (file) {
            std::fputs("Hello from smart pointer!\n", file.get());
        }
    }  // fclose called automatically here

    // Custom deleter for malloc'd memory
    auto mallocDeleter = [](int* p) {
        std::cout << "Freeing malloc'd memory\n";
        std::free(p);
    };

    std::unique_ptr<int, decltype(mallocDeleter)> data(
        static_cast<int*>(std::malloc(sizeof(int) * 10)),
        mallocDeleter
    );
    data.get()[0] = 99;

    // shared_ptr custom deleter is simpler syntactically
    std::shared_ptr<FILE> sharedFile(
        std::fopen("log.txt", "w"),
        [](FILE* f) { if (f) std::fclose(f); }
    );

    return 0;
}

Custom deleters make smart pointers universal resource managers — not just memory managers. Anything that has an “acquire” and “release” step can be wrapped.

Ownership Guidelines: Which Pointer When?

The C++ Core Guidelines are clear on this hierarchy:

Pointer Type When to Use Overhead
std::unique_ptr Default choice. Single owner. Zero (same as raw pointer)
std::shared_ptr Genuinely shared ownership. Multiple owners. Control block + atomic ref counting
std::weak_ptr Observing a shared_ptr without owning. Breaking cycles. Same control block as shared_ptr
Raw pointer / reference Non-owning observation. Function parameters. Zero

If you’re passing a pointer to a function that doesn’t need to own the resource, pass a raw pointer or reference — not a smart pointer. Smart pointers express ownership, not just “I have an address.” As explained in guideline F.7: use T* or T& for non-owning “use” parameters.

Common Mistakes and Pitfalls

Even with smart pointers, there are ways to shoot yourself in the foot. Here are the most common traps:

1. Creating two smart pointers from the same raw pointer:

int* raw = new int(42);
std::unique_ptr<int> a(raw);
std::unique_ptr<int> b(raw);  // DISASTER: double delete

Both think they own the memory. Both will delete it. Undefined behavior. Always use make_unique/make_shared instead of passing raw pointers around.

2. Using a unique_ptr after moving it:

auto ptr = std::make_unique<int>(10);
auto other = std::move(ptr);
std::cout << *ptr;  // UNDEFINED BEHAVIOR: ptr is null

3. Circular references with shared_ptr (no weak_ptr):

struct Node {
    std::shared_ptr<Node> next;  // If next points back to this... leak
};

Use weak_ptr for any back-pointer, parent pointer, or observer relationship.

4. Calling .get() and storing the raw pointer long-term:

int* dangerous = myUniquePtr.get();
myUniquePtr.reset();  // Object deleted
*dangerous = 5;       // UNDEFINED BEHAVIOR: dangling pointer

The .get() method is for temporary, non-owning access — like passing to a C API. Never store the result beyond the smart pointer’s lifetime.

5. Using shared_ptr when unique_ptr would do:

It’s tempting to use shared_ptr everywhere because it’s more “flexible.” Don’t. The atomic reference counting has real overhead, and it obscures the actual ownership model. Start with unique_ptr. Switch to shared_ptr only when the compiler or your design forces you to.

Understanding these pitfalls goes hand-in-hand with understanding pointer fundamentals — if you’re fuzzy on raw pointers, revisit that lesson first.

Key Takeaways

  • Smart pointers automate memory management using RAII. When they go out of scope, they free the resource — even during exceptions.
  • unique_ptr is your default. Zero overhead, exclusive ownership, move-only. Use it unless you have a specific reason not to.
  • shared_ptr is for shared ownership. Multiple pointers can share a resource via reference counting. The last one standing calls delete.
  • weak_ptr breaks circular references. It observes without owning. Call lock() to get a temporary shared_ptr.
  • Use make_unique and make_shared instead of raw new. They’re safer (exception-safe) and make_shared is faster (single allocation).
  • Custom deleters let smart pointers manage any resource — files, sockets, C library handles — not just new‘d memory.
  • Non-owning access uses raw pointers or references. Don’t pass smart pointers to functions that don’t need ownership.
  • Never create two smart pointers from the same raw pointer. Never use a unique_ptr after moving it. Never store a .get() result long-term.

Smart pointers are the single most impactful feature modern C++ gives you for writing safe, leak-free code. Master them and you’ll eliminate an entire category of bugs that has plagued C++ programmers for decades. In the next lesson, we’ll build on this foundation with move semantics in depth — understanding exactly how std::move works and when the compiler does it for you.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *