C++ unique_ptr and shared_ptr

A smart pointer is a class that wraps a raw pointer and automatically manages the lifetime of the object it points to, freeing memory the moment it is no longer needed. C++’s two essential smart pointers — std::unique_ptr and std::shared_ptr — live in the <memory> header and let you write code that almost never calls delete directly, eliminating the most common sources of memory leaks, double frees, and dangling pointers. unique_ptr models exclusive ownership: exactly one owner is responsible for the resource. shared_ptr models shared ownership: any number of owners can hold the resource, and it is destroyed only when the last one lets go. Choosing correctly between them, and understanding what happens under the hood, is one of the most important skills in modern C++.

Overview: How Smart Pointers Work

Both smart pointers are built on RAII (Resource Acquisition Is Initialization): a resource is acquired in a constructor and released in a destructor, so its lifetime is tied to an object’s scope rather than to manual delete calls scattered through your code. When a smart pointer goes out of scope — because a function returns, an exception is thrown, or a container holding it is destroyed — its destructor runs automatically and frees the managed object. This is what makes smart pointers exception-safe: even if an exception unwinds the stack, the destructor still runs.

std::unique_ptr is essentially a thin, zero-overhead wrapper around a raw pointer. With the default deleter it stores nothing more than that one pointer, and its destructor simply calls delete on it. Because ownership must stay exclusive, unique_ptr‘s copy constructor and copy assignment operator are deleted — you cannot copy one. You can, however, move one: moving transfers the raw pointer from the source to the destination and sets the source to nullptr, so only one unique_ptr ever points at the object at a time. This makes unique_ptr a practically free replacement for a raw owning pointer.

std::shared_ptr is more involved. In addition to a pointer to the managed object, every shared_ptr stores a pointer to a shared control block. The control block holds a strong reference count (how many shared_ptrs own the object), a weak reference count (how many weak_ptrs are observing it), and the deleter. Copying a shared_ptr increments the strong count; destroying one decrements it — both using atomic operations, so this bookkeeping is safe even when different threads hold copies of the same shared_ptr. When the strong count reaches zero, the managed object is destroyed immediately. When both counts reach zero, the control block itself is freed. This bookkeeping means shared_ptr is heavier than unique_ptr — copying one is not free — so it should be reserved for cases where ownership is genuinely shared.

Syntax

// unique_ptr: exclusive ownership
std::unique_ptr<T> ptr = std::make_unique<T>(args...);

// shared_ptr: shared ownership
std::shared_ptr<T> ptr = std::make_shared<T>(args...);

// weak_ptr: non-owning observer of a shared_ptr
std::weak_ptr<T> weak = sharedPtr;
  • std::unique_ptr<T> / std::shared_ptr<T> — the smart pointer type, templated on the pointee type T.
  • std::make_unique<T>(args...) / std::make_shared<T>(args...) — factory functions that allocate and construct T in one exception-safe step. Always prefer these over a bare new.
  • *ptr, ptr->member — dereference the smart pointer to access the pointee, exactly like a raw pointer.
  • ptr.get() — returns the underlying raw pointer without transferring or affecting ownership.
  • ptr.reset() — destroys the currently owned object (if any) and optionally takes ownership of a new one.
  • ptr.use_count() (shared_ptr only) — the number of shared_ptr instances currently sharing ownership.
  • weak.lock() (weak_ptr only) — returns a shared_ptr to the object if it still exists, or an empty one if it has already been destroyed.

Examples

Example 1: Basic unique_ptr ownership

#include <iostream>
#include <memory>

class Resource {
public:
    Resource(int id) : id_(id) {
        std::cout << "Resource " << id_ << " created\n";
    }
    ~Resource() {
        std::cout << "Resource " << id_ << " destroyed\n";
    }
    void greet() const {
        std::cout << "Hello from resource " << id_ << "\n";
    }
private:
    int id_;
};

int main() {
    std::unique_ptr<Resource> res = std::make_unique<Resource>(1);
    res->greet();
    return 0;
}

Output:

Resource 1 created
Hello from resource 1
Resource 1 destroyed

make_unique allocates the Resource and hands ownership straight to res. Calling res->greet() works exactly like a raw pointer, and when main returns, res‘s destructor runs automatically, deleting the Resource — no explicit delete was ever written.

Example 2: Moving a unique_ptr

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

std::unique_ptr<std::string> makeGreeting(const std::string& name) {
    return std::make_unique<std::string>("Hello, " + name + "!");
}

int main() {
    std::unique_ptr<std::string> a = makeGreeting("Ada");
    std::cout << *a << "\n";

    std::unique_ptr<std::string> b = std::move(a);
    std::cout << "b: " << *b << "\n";
    std::cout << "a is now " << (a ? "not null" : "null") << "\n";

    return 0;
}

Output:

Hello, Ada!
b: Hello, Ada!
a is now null

makeGreeting returns a unique_ptr by value; the compiler moves it out rather than copying, which is why returning a unique_ptr from a function is perfectly fine and cheap. std::move(a) then explicitly transfers ownership from a to b: after the move, b owns the string and a holds nullptr, which is why a converts to false in a boolean context.

Example 3: shared_ptr and reference counting

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

class Logger {
public:
    Logger() { std::cout << "Logger created\n"; }
    ~Logger() { std::cout << "Logger destroyed\n"; }
    void log(const std::string& msg) const {
        std::cout << "[LOG] " << msg << "\n";
    }
};

void useLogger(std::shared_ptr<Logger> logger) {
    logger->log("used inside function");
    std::cout << "use_count inside function: " << logger.use_count() << "\n";
}

int main() {
    std::shared_ptr<Logger> logger1 = std::make_shared<Logger>();
    std::cout << "use_count after creation: " << logger1.use_count() << "\n";

    {
        std::shared_ptr<Logger> logger2 = logger1;
        std::cout << "use_count after copy: " << logger1.use_count() << "\n";
        useLogger(logger2);
    }

    std::cout << "use_count after inner scope ends: " << logger1.use_count() << "\n";

    return 0;
}

Output:

Logger created
use_count after creation: 1
use_count after copy: 2
[LOG] used inside function
use_count inside function: 3
use_count after inner scope ends: 1
Logger destroyed

Every copy of logger1 bumps the strong reference count: copying into logger2 takes it to 2, and passing logger2 by value into useLogger takes it to 3. As each copy goes out of scope — first the function parameter, then logger2 at the end of the inner block — the count drops back down. The Logger object itself is only destroyed when the very last owner, logger1, disappears at the end of main.

Under the Hood

When you write std::make_shared<T>(args...), the library performs a single heap allocation that holds both the control block and the T object side by side. This is more efficient and more cache-friendly than std::shared_ptr<T>(new T(args...)), which allocates the object and the control block separately in two allocations. The one downside of make_shared is that the memory for T is not reclaimed until every weak_ptr referencing it is also gone, since object and control block share one allocation — a rare edge case worth knowing but usually not a practical problem.

A weak_ptr points at the same control block as a shared_ptr but only increments the weak count, never the strong one, so it never keeps the object alive by itself. To actually use the object, you call .lock(), which atomically checks whether the strong count is still greater than zero and, if so, returns a new shared_ptr (temporarily bumping the strong count while you hold it); otherwise it returns an empty shared_ptr. This is what makes weak_ptr safe for breaking reference cycles and for caches that should not prevent objects from being freed.

Common Mistakes

Mistake 1: Two unique_ptrs owning the same raw pointer

int* raw = new int(42);
std::unique_ptr<int> p1(raw);
std::unique_ptr<int> p2(raw); // BUG: p1 and p2 both think they own "raw"
// when p1 and p2 are destroyed, delete is called on the same address twice

Constructing two independent smart pointers from the same raw pointer means both will eventually call delete on it, which is undefined behavior (a double free, often a crash). The fix is to never let a raw pointer be adopted by more than one owning smart pointer, and to prefer make_unique so a raw pointer never exists in your code at all:

#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int> p1 = std::make_unique<int>(42);
    std::unique_ptr<int> p2 = std::move(p1); // ownership transferred, not duplicated
    std::cout << *p2 << "\n";
    return 0;
}

Output:

42

Mistake 2: Reference cycles with shared_ptr

class A {
public:
    std::shared_ptr<B> bPtr;
};
class B {
public:
    std::shared_ptr<A> aPtr; // BUG: A and B keep each other's count above zero forever
};

If A holds a shared_ptr to B and B holds a shared_ptr back to A, neither object’s strong count can ever reach zero, even after every external shared_ptr to them is gone — a genuine memory leak. The fix is to make one direction of the relationship a weak_ptr, typically the “back-pointer”:

#include <iostream>
#include <memory>

class B;

class A {
public:
    std::shared_ptr<B> bPtr;
    ~A() { std::cout << "A destroyed\n"; }
};

class B {
public:
    std::weak_ptr<A> aPtr;
    ~B() { std::cout << "B destroyed\n"; }
};

int main() {
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();
    a->bPtr = b;
    b->aPtr = a;

    std::cout << "a use_count: " << a.use_count() << "\n";
    std::cout << "b use_count: " << b.use_count() << "\n";

    return 0;
}

Output:

a use_count: 1
b use_count: 2
A destroyed
B destroyed

Because b->aPtr is a weak_ptr, it does not add to A‘s strong count, so a‘s count stays at 1 and both objects are correctly destroyed once main ends.

Mistake 3: Storing the result of .get() past the smart pointer’s lifetime

std::string* dangling;
{
    std::unique_ptr<std::string> temp = std::make_unique<std::string>("temporary");
    dangling = temp.get(); // raw pointer copied out
} // temp is destroyed here, the string is freed
std::cout << *dangling; // BUG: dangling now points at freed memory

get() does not extend the object’s lifetime; it just hands you a peek at the address. Once the owning unique_ptr is destroyed, any raw pointer obtained from get() is dangling. Only use get() for short-lived interop (for example, passing a pointer into a C API call) and never store or return it for later use.

Best Practices

  • Always create smart pointers with std::make_unique or std::make_shared instead of a bare new — it is exception-safe and, for shared_ptr, faster.
  • Default to unique_ptr. Reach for shared_ptr only when you have a genuine, provable need for multiple simultaneous owners.
  • Pass smart pointers by reference (const std::unique_ptr<T>& or just a raw T&/T*) when a function merely needs to use the object, and by value only when it should actually take or share ownership.
  • Use weak_ptr to break ownership cycles and for observer-style references that should never keep an object alive.
  • Never manage the same raw pointer with two independently-constructed smart pointers.
  • Treat .get() as a short-lived escape hatch for legacy APIs only — never store its result beyond the smart pointer’s own scope.
  • Prefer passing raw pointers or references to functions that only observe an object; reserve smart-pointer parameters for functions that actually affect ownership.

Practice Exercises

  • Write a function createEmployee that returns a std::unique_ptr<Employee> (a struct with a std::string name and an int salary) built with make_unique, then call it from main, print the employee’s name, and let the unique_ptr clean up automatically.
  • Define a simple singly linked list where each Node owns the next node through a std::unique_ptr<Node> next member. Build a list of 3 nodes and write a loop that prints each value, relying on the destructor chain to free the whole list when the head goes out of scope.
  • Create two classes, Parent and Child, where Parent holds a std::shared_ptr<Child> and Child holds a std::weak_ptr<Parent>. Print a message in each destructor and confirm both messages are printed when your main function ends, proving there is no leak.

Summary

  • std::unique_ptr gives exclusive, move-only ownership with essentially zero overhead over a raw pointer — use it as your default choice.
  • std::shared_ptr gives shared ownership through an atomically reference-counted control block; the object is destroyed only when the last owner disappears.
  • std::weak_ptr observes a shared_ptr-managed object without affecting its reference count, and is the standard tool for breaking ownership cycles.
  • Always prefer std::make_unique and std::make_shared over raw new for exception safety and, with shared_ptr, fewer allocations.
  • Never let two independent smart pointers manage the same raw pointer, and never keep a .get() result alive past its owner’s lifetime.