Introduction to Smart Pointers: unique_ptr, shared_ptr, weak_ptr

Learn how C++ smart pointers work — unique_ptr, shared_ptr, and weak_ptr — with clear examples, memory safety rules, and real-world usage patterns.

Introduction to Smart Pointers: unique_ptr, shared_ptr, weak_ptr

Smart pointers in C++ are wrapper objects that behave like raw pointers but automatically manage the lifetime of the object they point to. Introduced in C++11, std::unique_ptr, std::shared_ptr, and std::weak_ptr eliminate the need for manual delete calls, prevent memory leaks, and make ownership semantics explicit and safe — all with little to no runtime overhead compared to raw pointers.

Introduction

For decades, C++ programmers managed heap memory manually using new and delete. While this approach gives maximum control, it also creates a class of bugs that are notoriously difficult to track down: memory leaks (forgetting to delete), dangling pointers (using memory after it has been freed), double frees (calling delete twice on the same pointer), and use-after-free errors. These bugs are responsible for a staggering proportion of security vulnerabilities and production crashes in C++ software.

C++11 introduced a family of smart pointers in the <memory> header that solve these problems definitively. Rather than replacing the concept of a pointer, smart pointers wrap a raw pointer in a class that enforces clear ownership rules and calls delete automatically at the right time. The three primary smart pointers — std::unique_ptr, std::shared_ptr, and std::weak_ptr — each represent a different ownership model.

This article teaches you each of these three smart pointers from the ground up. You will understand what problem each one solves, how to construct and use them, when to prefer one over another, and how to avoid the common mistakes that trip up developers new to smart pointers. Every concept is grounded in practical, runnable code with thorough step-by-step explanations.

The Problem with Raw Pointers

Before diving into smart pointers, it is worth seeing concretely what problems they are designed to solve.

C++
#include <iostream>
using namespace std;

class Resource {
public:
    Resource(int id) : id_(id) {
        cout << "Resource " << id_ << " acquired" << endl;
    }
    ~Resource() {
        cout << "Resource " << id_ << " released" << endl;
    }
    void use() {
        cout << "Using Resource " << id_ << endl;
    }
private:
    int id_;
};

void riskyFunction(bool throwError) {
    Resource* res = new Resource(1);  // Heap allocation
    res->use();

    if (throwError) {
        throw runtime_error("Something went wrong!");
        // 'res' is never deleted — MEMORY LEAK
    }

    delete res;  // Only reached if no exception
    cout << "Resource properly cleaned up" << endl;
}

int main() {
    // Case 1: No error — works fine
    try {
        riskyFunction(false);
    } catch (...) {}

    cout << "---" << endl;

    // Case 2: Exception thrown — resource leaks!
    try {
        riskyFunction(true);
    } catch (const exception& e) {
        cout << "Caught: " << e.what() << endl;
    }

    return 0;
}

Output:

Plaintext
Resource 1 acquired
Using Resource 1
Resource 1 released
Resource properly cleaned up
---
Resource 1 acquired
Using Resource 1
Caught: Something went wrong!

Step-by-step explanation:

  1. In riskyFunction(false), everything works fine: Resource is allocated, used, and deleted. The destructor message confirms cleanup.
  2. In riskyFunction(true), an exception is thrown before delete res is reached. The function exits via the exception path, skipping the delete call entirely. Notice that “Resource 1 released” is never printed — the resource has leaked.
  3. This is the fundamental fragility of raw pointer memory management. Every code path — including exception paths — must lead to delete. In real code with multiple return points, error conditions, and deeply nested logic, guaranteeing this is extremely difficult.
  4. Smart pointers solve this by tying the lifetime of the heap object to the lifetime of the smart pointer wrapper. When the smart pointer goes out of scope — for any reason, including exceptions — the wrapped object is automatically destroyed. This is the RAII principle in action.

std::unique_ptr: Exclusive Ownership

std::unique_ptr<T> represents exclusive ownership of a heap-allocated object. There is exactly one unique_ptr that owns the resource at any given time. When that unique_ptr is destroyed (goes out of scope or is explicitly reset), it automatically deletes the owned object.

The “unique” in unique_ptr means it cannot be copied — only moved. This guarantees at the language level that there is never more than one owner.

Creating and Using unique_ptr

C++
#include <iostream>
#include <memory>
using namespace std;

class Resource {
public:
    Resource(int id) : id_(id) {
        cout << "Resource " << id_ << " acquired" << endl;
    }
    ~Resource() {
        cout << "Resource " << id_ << " released" << endl;
    }
    void use() {
        cout << "Using Resource " << id_ << endl;
    }
private:
    int id_;
};

int main() {
    // Create a unique_ptr using make_unique (preferred)
    unique_ptr<Resource> ptr = make_unique<Resource>(42);

    // Use it just like a raw pointer
    ptr->use();
    (*ptr).use();  // Dereference operator also works

    cout << "ptr is " << (ptr ? "not null" : "null") << endl;

    // No need to call delete — happens automatically at end of scope
    cout << "Leaving scope..." << endl;
    return 0;  // ptr goes out of scope here, destructor is called
}

Output:

Plaintext
Resource 42 acquired
Using Resource 42
Using Resource 42
ptr is not null
Leaving scope...
Resource 42 released

Step-by-step explanation:

  1. make_unique<Resource>(42) creates a Resource object on the heap and wraps it in a unique_ptr. The argument 42 is forwarded to Resource‘s constructor. Using make_unique is always preferred over new because it is exception-safe and prevents raw pointers from ever being exposed.
  2. The -> operator on unique_ptr behaves exactly like the -> operator on a raw pointer, forwarding method calls to the underlying object. The dereference operator *ptr also works as expected.
  3. The boolean conversion of unique_ptr (used in ptr ? "not null" : "null") returns true if the pointer is non-null, exactly like a raw pointer.
  4. When main() returns, ptr goes out of scope. Its destructor is called automatically, which calls delete on the managed Resource. The destructor message confirms cleanup — no manual delete needed anywhere.

unique_ptr and Exception Safety

C++
#include <iostream>
#include <memory>
using namespace std;

class Resource {
public:
    Resource(int id) : id_(id) {
        cout << "Resource " << id_ << " acquired" << endl;
    }
    ~Resource() {
        cout << "Resource " << id_ << " released" << endl;
    }
    void use() { cout << "Using Resource " << id_ << endl; }
private:
    int id_;
};

void safeFunction(bool throwError) {
    // unique_ptr guarantees cleanup even if an exception occurs
    auto res = make_unique<Resource>(1);
    res->use();

    if (throwError) {
        throw runtime_error("Something went wrong!");
        // unique_ptr destructor STILL runs — no leak!
    }

    cout << "No error — normal exit" << endl;
}  // res destroyed here (or during stack unwinding)

int main() {
    cout << "--- No error ---" << endl;
    safeFunction(false);

    cout << "--- With error ---" << endl;
    try {
        safeFunction(true);
    } catch (const exception& e) {
        cout << "Caught: " << e.what() << endl;
    }
    return 0;
}

Output:

Plaintext
--- No error ---
Resource 1 acquired
Using Resource 1
No error — normal exit
Resource 1 released
--- With error ---
Resource 1 acquired
Using Resource 1
Resource 1 released
Caught: Something went wrong!

Step-by-step explanation:

  1. In the error case, unique_ptr‘s destructor is called during stack unwinding — the process by which C++ cleans up local variables when an exception propagates. Even though the exception bypasses normal control flow, all local objects (including smart pointers) on the stack are properly destroyed.
  2. “Resource 1 released” now appears even when an exception is thrown. The leak that existed with raw pointers is completely eliminated.
  3. This is the core value proposition of unique_ptr: automatic, reliable cleanup regardless of how a function exits.

Transferring Ownership with std::move

Because unique_ptr cannot be copied, ownership is transferred using std::move, which converts an lvalue into an rvalue reference, allowing the move constructor or move assignment operator to transfer ownership.

C++
#include <iostream>
#include <memory>
using namespace std;

class Widget {
public:
    Widget(string name) : name_(name) {
        cout << "Widget '" << name_ << "' created" << endl;
    }
    ~Widget() {
        cout << "Widget '" << name_ << "' destroyed" << endl;
    }
    void show() const {
        cout << "Widget: " << name_ << endl;
    }
private:
    string name_;
};

unique_ptr<Widget> createWidget(string name) {
    // Factory function: ownership transferred to caller
    return make_unique<Widget>(name);
}

void takeOwnership(unique_ptr<Widget> w) {
    // This function now owns the widget
    w->show();
}  // Widget destroyed here

int main() {
    // Ownership transferred from factory to 'w1'
    auto w1 = createWidget("Button");
    w1->show();

    // Transfer ownership from w1 to w2 using move
    auto w2 = move(w1);

    cout << "w1 is " << (w1 ? "valid" : "null") << endl;  // w1 is now null
    cout << "w2 is " << (w2 ? "valid" : "null") << endl;  // w2 holds the widget

    w2->show();

    // Transfer ownership into a function
    takeOwnership(move(w2));

    cout << "w2 is now " << (w2 ? "valid" : "null") << endl;  // null
    return 0;
}

Output:

Plaintext
Widget 'Button' created
Widget: Button
w1 is null
w2 is valid
Widget: Button
Widget: Button
Widget 'Button' destroyed
w2 is now null

Step-by-step explanation:

  1. createWidget returns a unique_ptr<Widget> by value. Return Value Optimization (RVO) or move semantics transfer ownership from the local variable inside the function to w1 in main() without any copying.
  2. auto w2 = move(w1) moves ownership from w1 to w2. After this line, w1 holds nullptr — it no longer owns the widget. w2 is the new sole owner. This enforces the “unique” invariant at the language level.
  3. Attempting to copy w1 into w2 directly (without move) would result in a compile error because unique_ptr‘s copy constructor is explicitly deleted. This is intentional — the compiler prevents accidental ownership sharing.
  4. takeOwnership(move(w2)) transfers ownership into the function parameter. When the function returns, the parameter goes out of scope and the Widget is destroyed.

unique_ptr for Arrays

unique_ptr also supports managing heap-allocated arrays with a specialized template:

C++
// Allocate array of 5 integers
auto arr = make_unique<int[]>(5);

for (int i = 0; i < 5; i++) {
    arr[i] = i * 10;
}

for (int i = 0; i < 5; i++) {
    cout << arr[i] << " ";
}
// Output: 0 10 20 30 40
// Array deleted automatically — calls delete[]

The unique_ptr<T[]> specialization calls delete[] instead of delete when it is destroyed, which is the correct way to free an array.

std::shared_ptr: Shared Ownership

std::shared_ptr<T> represents shared ownership of a heap-allocated object. Multiple shared_ptr instances can point to the same object simultaneously. The object is destroyed only when the last shared_ptr owning it is destroyed or reset. This is tracked by a reference count — an integer that is incremented every time a new shared_ptr is created from the same object, and decremented every time one is destroyed.

Creating and Using shared_ptr

C++
#include <iostream>
#include <memory>
using namespace std;

class Resource {
public:
    Resource(string name) : name_(name) {
        cout << "Resource '" << name_ << "' acquired" << endl;
    }
    ~Resource() {
        cout << "Resource '" << name_ << "' released" << endl;
    }
    void use() const {
        cout << "Using Resource '" << name_ << "'" << endl;
    }
private:
    string name_;
};

int main() {
    cout << "--- Creating shared ownership ---" << endl;

    shared_ptr<Resource> ptr1 = make_shared<Resource>("DatabaseConnection");
    cout << "Reference count: " << ptr1.use_count() << endl;  // 1

    {
        shared_ptr<Resource> ptr2 = ptr1;  // Copying is allowed!
        cout << "Reference count after copy: " << ptr1.use_count() << endl;  // 2

        shared_ptr<Resource> ptr3 = ptr1;
        cout << "Reference count after second copy: " << ptr1.use_count() << endl;  // 3

        ptr2->use();
        ptr3->use();

        cout << "Leaving inner scope..." << endl;
    }  // ptr2 and ptr3 destroyed here — count drops to 1

    cout << "Reference count after inner scope: " << ptr1.use_count() << endl;  // 1

    cout << "Leaving main..." << endl;
    return 0;  // ptr1 destroyed — count drops to 0, Resource released
}

Output:

Plaintext
--- Creating shared ownership ---
Resource 'DatabaseConnection' acquired
Reference count: 1
Reference count after copy: 2
Reference count after second copy: 3
Using Resource 'DatabaseConnection'
Using Resource 'DatabaseConnection'
Leaving inner scope...
Reference count after inner scope: 1
Leaving main...
Resource 'DatabaseConnection' released

Step-by-step explanation:

  1. make_shared<Resource>("DatabaseConnection") creates the Resource on the heap and also allocates the control block — a small heap allocation that contains the reference count, weak reference count, and deleter. Using make_shared combines the object and control block into a single allocation, which is more efficient than using new directly.
  2. ptr1.use_count() returns the current reference count. After creation, it is 1 (only ptr1 owns the resource).
  3. shared_ptr<Resource> ptr2 = ptr1 copies the shared_ptr. Unlike unique_ptr, copying is fully allowed. The copy increments the reference count to 2. Both ptr1 and ptr2 now co-own the same Resource object.
  4. When ptr2 and ptr3 go out of scope at the end of the inner block, their destructors decrement the reference count. After the block, the count is back to 1 — only ptr1 still owns the resource.
  5. When main() returns and ptr1 is destroyed, the count drops to 0. This triggers the deletion of both the Resource object and the control block. The destructor message confirms cleanup.

The Control Block

Every shared_ptr that shares ownership of an object points to the same control block — a separate heap allocation that contains:

  • Strong reference count: how many shared_ptr instances own the object.
  • Weak reference count: how many weak_ptr instances observe the object (explained later).
  • Deleter: a function that knows how to destroy the managed object.
  • Allocator: information for managing the control block’s own memory.

When the strong reference count reaches zero, the managed object is destroyed. When both the strong and weak reference counts reach zero, the control block itself is freed.

Shared Ownership in Practice: Caching and Shared Resources

C++
#include <iostream>
#include <memory>
#include <vector>
using namespace std;

class Texture {
public:
    string filename;
    int width, height;

    Texture(string f, int w, int h) : filename(f), width(w), height(h) {
        cout << "Loading texture: " << filename << " (" << w << "x" << h << ")" << endl;
    }

    ~Texture() {
        cout << "Unloading texture: " << filename << endl;
    }
};

class Sprite {
public:
    string name;
    shared_ptr<Texture> texture;  // Sprites share textures

    Sprite(string n, shared_ptr<Texture> tex)
        : name(n), texture(tex) {}

    void draw() const {
        cout << "Drawing sprite '" << name << "' using texture '"
             << texture->filename << "'" << endl;
    }
};

int main() {
    // Load texture once
    auto grassTex = make_shared<Texture>("grass.png", 512, 512);
    cout << "Texture ref count: " << grassTex.use_count() << endl;  // 1

    // Many sprites share the same texture
    vector<Sprite> sprites;
    sprites.emplace_back("GrassTile_1", grassTex);
    sprites.emplace_back("GrassTile_2", grassTex);
    sprites.emplace_back("GrassTile_3", grassTex);

    cout << "Texture ref count after 3 sprites: " << grassTex.use_count() << endl;  // 4

    for (const auto& s : sprites) {
        s.draw();
    }

    cout << "Clearing sprites vector..." << endl;
    sprites.clear();  // All sprites destroyed — count drops back to 1

    cout << "Texture ref count: " << grassTex.use_count() << endl;  // 1
    cout << "Texture still alive in main." << endl;

    return 0;  // grassTex destroyed — texture unloaded
}

Output:

Plaintext
Loading texture: grass.png (512x512)
Texture ref count: 1
Texture ref count after 3 sprites: 4
Drawing sprite 'GrassTile_1' using texture 'grass.png'
Drawing sprite 'GrassTile_2' using texture 'grass.png'
Drawing sprite 'GrassTile_3' using texture 'grass.png'
Clearing sprites vector...
Texture ref count: 1
Texture still alive in main.
Unloading texture: grass.png

Step-by-step explanation:

  1. A single Texture object is loaded once and wrapped in a shared_ptr. Multiple Sprite objects each store a copy of this shared_ptr, sharing ownership of the texture. The texture is loaded only once, no matter how many sprites use it.
  2. When the sprites vector is cleared, each Sprite destructor decrements the reference count. But because grassTex in main() still holds a reference, the count goes from 4 to 1 — the texture is not freed yet.
  3. The texture is only unloaded when grassTex itself is destroyed at the end of main(). This is the classic shared ownership pattern: the resource lives as long as anyone needs it.

std::weak_ptr: Non-Owning Observation

std::weak_ptr<T> is a companion to shared_ptr that holds a non-owning reference to an object managed by shared_ptr. A weak_ptr does not contribute to the reference count, so the object can be destroyed even while weak_ptr instances exist. Before using a weak_ptr, you must convert it to a shared_ptr (using lock()) to safely access the object — and this conversion fails gracefully if the object has already been destroyed.

weak_ptr solves two specific problems: breaking cyclic references and safe optional observation.

The Cyclic Reference Problem

When two objects hold shared_ptr to each other, neither ever reaches a reference count of zero — even when no external code holds any references to either object. This is a reference cycle, and it causes memory leaks even with shared_ptr.

C++
#include <iostream>
#include <memory>
using namespace std;

struct Node {
    string name;
    shared_ptr<Node> next;  // Problem: creates a reference cycle

    Node(string n) : name(n) {
        cout << "Node '" << name << "' created" << endl;
    }
    ~Node() {
        cout << "Node '" << name << "' destroyed" << endl;
    }
};

int main() {
    cout << "--- Cyclic reference (LEAK) ---" << endl;
    {
        auto a = make_shared<Node>("A");
        auto b = make_shared<Node>("B");

        a->next = b;  // A owns B
        b->next = a;  // B owns A — CYCLE!

        cout << "a use_count: " << a.use_count() << endl;  // 2
        cout << "b use_count: " << b.use_count() << endl;  // 2
    }
    // a and b go out of scope:
    // a's count: 2 -> 1 (b still holds a reference to a)
    // b's count: 2 -> 1 (a still holds a reference to b)
    // Neither reaches 0 — both nodes leak!
    cout << "End of scope — were nodes destroyed?" << endl;
    return 0;
}

Output:

Plaintext
--- Cyclic reference (LEAK) ---
Node 'A' created
Node 'B' created
a use_count: 2
b use_count: 2
End of scope — were nodes destroyed?

Notice: Neither ~Node() destructor message ever appears. Both nodes have leaked.

Breaking the Cycle with weak_ptr

C++
#include <iostream>
#include <memory>
using namespace std;

struct Node {
    string name;
    weak_ptr<Node> next;  // Non-owning: doesn't keep nodes alive

    Node(string n) : name(n) {
        cout << "Node '" << name << "' created" << endl;
    }
    ~Node() {
        cout << "Node '" << name << "' destroyed" << endl;
    }

    void visit() {
        // Must lock() weak_ptr before use
        if (auto nextNode = next.lock()) {
            cout << name << " -> " << nextNode->name << endl;
        } else {
            cout << name << " -> (no next node)" << endl;
        }
    }
};

int main() {
    cout << "--- With weak_ptr (no leak) ---" << endl;
    {
        auto a = make_shared<Node>("A");
        auto b = make_shared<Node>("B");

        a->next = b;  // weak_ptr assignment — no reference count increase
        b->next = a;  // weak_ptr assignment — no reference count increase

        cout << "a use_count: " << a.use_count() << endl;  // 1
        cout << "b use_count: " << b.use_count() << endl;  // 1

        a->visit();
        b->visit();
    }
    // a's count: 1 -> 0 — destroyed!
    // b's count: 1 -> 0 — destroyed!
    cout << "End of scope" << endl;
    return 0;
}

Output:

Plaintext
--- With weak_ptr (no leak) ---
Node 'A' created
Node 'B' created
a use_count: 1
b use_count: 1
A -> B
B -> A
Node 'A' destroyed
Node 'B' destroyed
End of scope

Step-by-step explanation:

  1. By changing shared_ptr<Node> next to weak_ptr<Node> next, the next pointers no longer contribute to the reference count. Each node’s strong reference count stays at 1 (owned only by the local variable in main()).
  2. When the scope ends, a‘s count drops from 1 to 0 and Node A is destroyed. Then b‘s count drops from 1 to 0 and Node B is destroyed. Both destructor messages confirm clean cleanup.
  3. To use a weak_ptr, you call .lock(), which returns a shared_ptr. If the pointed-to object still exists, lock() returns a valid shared_ptr with an incremented reference count, keeping the object alive for the duration of that temporary shared_ptr. If the object has already been destroyed, lock() returns an empty (null) shared_ptr.
  4. The if (auto nextNode = next.lock()) pattern is the idiomatic way to safely use a weak_ptr. It checks both that the object exists and acquires a temporary owning handle in a single expression.

weak_ptr for Cache/Observer Patterns

C++
#include <iostream>
#include <memory>
#include <vector>
#include <algorithm>
using namespace std;

class EventSystem {
public:
    // Store observers as weak_ptr — doesn't prevent their destruction
    vector<weak_ptr<class Observer>> observers;

    void registerObserver(shared_ptr<class Observer> obs) {
        observers.push_back(obs);
    }

    void notify(const string& event);  // Defined after Observer
};

class Observer {
public:
    string name;

    Observer(string n) : name(n) {
        cout << "Observer '" << name << "' created" << endl;
    }
    ~Observer() {
        cout << "Observer '" << name << "' destroyed" << endl;
    }

    void onEvent(const string& event) {
        cout << "[" << name << "] Received event: " << event << endl;
    }
};

void EventSystem::notify(const string& event) {
    // Remove expired weak_ptrs and notify valid ones
    auto it = observers.begin();
    while (it != observers.end()) {
        if (auto obs = it->lock()) {
            obs->onEvent(event);
            ++it;
        } else {
            cout << "Removing expired observer" << endl;
            it = observers.erase(it);
        }
    }
}

int main() {
    EventSystem events;

    auto obs1 = make_shared<Observer>("Logger");
    auto obs2 = make_shared<Observer>("Dashboard");

    events.registerObserver(obs1);
    events.registerObserver(obs2);

    events.notify("UserLogin");

    cout << "--- obs2 goes out of scope ---" << endl;
    obs2.reset();  // Destroy obs2

    events.notify("UserLogout");

    return 0;
}

Output:

Plaintext
Observer 'Logger' created
Observer 'Dashboard' created
[Logger] Received event: UserLogin
[Dashboard] Received event: UserLogin
--- obs2 goes out of scope ---
Observer 'Dashboard' destroyed
[Logger] Received event: UserLogout
Removing expired observer

Step-by-step explanation:

  1. EventSystem stores weak_ptr<Observer> instead of shared_ptr<Observer>. This means the event system does not prevent observers from being destroyed when their owners let go of them.
  2. On the first notify("UserLogin"), both observers are alive. lock() succeeds for both, and both receive the event.
  3. After obs2.reset(), Observer 'Dashboard' is destroyed immediately (its reference count drops to 0). The event system’s weak_ptr to it is now expired.
  4. On the second notify("UserLogout"), lock() fails for the expired weak_ptr. The code detects this, prints a message, and erases the expired entry. Only Logger receives the event. The event system gracefully handles the case where an observer has been destroyed without requiring any explicit unregistration step.

Comparing the Three Smart Pointers

Featureunique_ptrshared_ptrweak_ptr
Ownership modelExclusive (one owner)Shared (multiple owners)Non-owning observer
Copyable?No (move-only)YesYes
Movable?YesYesYes
Reference count?NoYes (strong count)Tracks weak count only
Overhead vs raw pointerMinimal (zero-cost abstraction)Control block + atomic ref countPointer + weak count check
Destroys object when?When unique_ptr goes out of scopeWhen last shared_ptr is destroyedNever (non-owning)
Access requires check?No (use directly)No (use directly)Yes (must call lock())
Solves cyclic references?N/ANo (causes them)Yes (breaks cycles)
Header<memory><memory><memory>
Typical use caseFactory results, class members, containersShared resources, caches, event listenersObservers, back-pointers, optional handles

Choosing the Right Smart Pointer

The decision of which smart pointer to use comes down to ownership semantics:

Use unique_ptr when:

  • A single owner is responsible for the lifetime of the object (the common case).
  • The object is created in one place and passed to one consumer.
  • You are writing a factory function that hands off ownership to the caller.
  • Performance is critical — unique_ptr has essentially zero overhead compared to raw pointers.

Use shared_ptr when:

  • Multiple parts of the code need to keep the object alive simultaneously.
  • The lifetime of the object is determined collectively by multiple owners.
  • You are implementing caches, resource managers, or event dispatch systems where objects are shared.
  • The order of destruction is nondeterministic across multiple owners.

Use weak_ptr when:

  • You need to observe or optionally access a shared_ptr-managed object without extending its lifetime.
  • You are breaking a cyclic reference between shared_ptr objects.
  • You are implementing the observer pattern where observers should not keep the subject alive.
  • You want a “safe optional raw pointer” — a handle that gracefully becomes null when the target is destroyed.

The default should be unique_ptr. If you reach for shared_ptr, ask yourself: is this truly shared ownership, or can the design be simplified to a single owner? Unnecessary shared_ptr usage wastes memory (the control block), incurs atomic reference count overhead, and makes ownership semantics harder to reason about.

Custom Deleters

Both unique_ptr and shared_ptr support custom deleters — functions called instead of the default delete when the managed object is destroyed. This is essential for managing resources that are not created with new, such as file handles, network sockets, or C library resources.

C++
#include <iostream>
#include <memory>
#include <cstdio>
using namespace std;

// Custom deleter for FILE*
struct FileCloser {
    void operator()(FILE* f) const {
        if (f) {
            cout << "Closing file" << endl;
            fclose(f);
        }
    }
};

int main() {
    // Manage a C-style FILE* with unique_ptr and custom deleter
    unique_ptr<FILE, FileCloser> file(fopen("test.txt", "w"), FileCloser{});

    if (file) {
        fputs("Hello, smart pointer!", file.get());
        cout << "Wrote to file" << endl;
    }

    // file automatically closed when unique_ptr goes out of scope
    cout << "Leaving scope..." << endl;
    return 0;
}

Output:

C++
Wrote to file
Leaving scope...
Closing file

Step-by-step explanation:

  1. FILE* is a C-style resource that is opened with fopen and must be closed with fclose. It is not created with new, so the default delete is wrong.
  2. unique_ptr<FILE, FileCloser> accepts a second template argument: the type of the custom deleter. FileCloser is a functor (a class with an operator()) that calls fclose instead of delete.
  3. file.get() returns the raw FILE* pointer, needed for functions like fputs that work with raw pointers.
  4. When file goes out of scope, FileCloser::operator() is called, properly closing the file.
  5. Lambda expressions are also commonly used as custom deleters: unique_ptr<FILE, decltype(&fclose)> f(fopen(...), &fclose).

Creating shared_ptr from this: enable_shared_from_this

A tricky problem arises when an object needs to create a shared_ptr to itself from within a member function. Naively wrapping this in a shared_ptr creates a second, independent control block — causing a double-free when both shared_ptr instances are destroyed.

The solution is std::enable_shared_from_this<T>:

C++
#include <iostream>
#include <memory>
using namespace std;

class Task : public enable_shared_from_this<Task> {
public:
    string name;

    Task(string n) : name(n) {
        cout << "Task '" << name << "' created" << endl;
    }
    ~Task() {
        cout << "Task '" << name << "' destroyed" << endl;
    }

    // Returns a shared_ptr to this task — safe version
    shared_ptr<Task> getSelf() {
        return shared_from_this();  // Uses the existing control block
    }
};

int main() {
    auto task = make_shared<Task>("Render");
    cout << "Count after creation: " << task.use_count() << endl;  // 1

    auto self = task->getSelf();
    cout << "Count after getSelf(): " << task.use_count() << endl;  // 2

    // Both 'task' and 'self' point to the same object with a shared control block
    cout << "Same object? " << (task.get() == self.get() ? "Yes" : "No") << endl;

    return 0;
}

Output:

Plaintext
Task 'Render' created
Count after creation: 1
Count after getSelf(): 2
Same object? Yes
Task 'Render' destroyed

Step-by-step explanation:

  1. Task inherits from enable_shared_from_this<Task>. This mixin adds a weak_ptr to this inside the object, which is initialized when the first shared_ptr is created via make_shared.
  2. shared_from_this() uses that stored weak_ptr to return a shared_ptr that shares the existing control block. The reference count increments from 1 to 2 — no new control block is created.
  3. Both task and self point to the same Task object, confirmed by comparing the raw pointers with .get().
  4. When both go out of scope, the count drops from 2 to 0 and the object is destroyed once. Without enable_shared_from_this, calling shared_ptr<Task>(this) inside a member function would create a second control block, and the object would be deleted twice — undefined behavior.

Common Mistakes to Avoid

Mistake 1: Constructing shared_ptr Directly from new

C++
// BAD: Two separate heap allocations (object + control block)
shared_ptr<Widget> p(new Widget());

// GOOD: Single heap allocation for both
auto p = make_shared<Widget>();

make_shared is more efficient because it allocates the object and control block in a single call. It also avoids a subtle exception-safety issue with new.

Mistake 2: Sharing Ownership of a Stack Object

C++
int x = 42;
shared_ptr<int> p(&x);  // BAD: shared_ptr will try to delete x, which is on the stack!

Never point a smart pointer at a stack-allocated variable. Smart pointers are for heap-allocated memory only.

Mistake 3: Calling shared_from_this Before a shared_ptr Exists

C++
class Foo : public enable_shared_from_this<Foo> {
public:
    Foo() {
        auto self = shared_from_this();  // THROWS: no shared_ptr exists yet
    }
};

shared_from_this() can only be called after the object has been wrapped in a shared_ptr. Calling it in the constructor causes a std::bad_weak_ptr exception because the weak_ptr inside the object has not been initialized yet.

Mistake 4: Mixing Raw Pointers and Smart Pointers

C++
auto p = make_shared<Widget>();
Widget* raw = p.get();
shared_ptr<Widget> p2(raw);  // BAD: Second control block, double free!

Never create a shared_ptr from a raw pointer that is already managed by another shared_ptr. Use shared_from_this() if you need a second owning handle to the same object from within the object itself, or simply copy the existing shared_ptr.

Mistake 5: Storing Raw Pointers from smart pointers Long-Term

C++
auto sp = make_shared<Widget>();
Widget* raw = sp.get();
sp.reset();         // shared_ptr destroyed — Widget deleted
raw->doSomething(); // DANGLING POINTER: undefined behavior

get() returns a raw pointer to the managed object. This raw pointer becomes invalid as soon as the smart pointer is destroyed. Only use get() to pass the pointer to APIs that need a raw pointer and don’t take ownership.

Performance Considerations

Smart pointers are designed to be as efficient as possible, but it helps to understand where overhead exists:

unique_ptr is a true zero-cost abstraction in most cases. In optimized builds, a unique_ptr<T> with a default deleter is the same size as a raw pointer (T*) and generates the same machine code for access and destruction. There is no runtime overhead.

shared_ptr carries measurable overhead. Each shared_ptr is typically two machine words in size (a pointer to the object and a pointer to the control block). Reference count operations use atomic increments and decrements to be thread-safe. These atomic operations are significantly more expensive than non-atomic arithmetic, especially on multicore systems where they can cause cache contention. For this reason, you should prefer unique_ptr when shared ownership is not genuinely required.

weak_ptr adds a small cost when converting to shared_ptr via lock(), which performs an atomic check-and-increment. The cost is similar to a shared_ptr copy.

Practical Design Guidelines

Following a few simple rules will help you use smart pointers correctly in real projects:

Express ownership in function signatures. If a function takes ownership, accept a unique_ptr by value. If a function shares ownership, accept a shared_ptr by value. If a function just uses the object without taking or sharing ownership, accept a raw pointer or reference (since raw pointers and references are not owning).

Return unique_ptr from factory functions. Factory functions that create objects on the heap should return unique_ptr. The caller can then either keep it as unique_ptr (exclusive ownership) or convert it to shared_ptr if shared ownership is needed later — a unique_ptr is implicitly convertible to shared_ptr.

C++
unique_ptr<Widget> makeWidget() {
    return make_unique<Widget>();
}

// Caller can choose ownership model:
auto uw = makeWidget();                        // unique ownership
shared_ptr<Widget> sw = makeWidget();          // convert to shared ownership

Avoid shared_ptr in hot paths. If a loop or frequently-called function copies shared_ptr objects repeatedly, the atomic reference count operations can become a bottleneck. Use raw references or raw pointers (non-owning) in inner loops, holding the shared_ptr alive in the outer scope.

Document ownership with const. A const unique_ptr<T> prevents the pointer itself from being moved or reset, while still allowing mutation of the pointed-to object. A const shared_ptr<T> similarly prevents reassignment of the pointer.

Conclusion

Smart pointers are one of the most impactful features in modern C++. They transform memory management from a manual, error-prone chore into a declarative expression of ownership semantics, all while generating code that is as fast as hand-written raw pointer management.

std::unique_ptr is the right choice for the vast majority of heap allocations. It enforces exclusive ownership, transfers cleanly with std::move, and has zero runtime overhead. Use it as your default.

std::shared_ptr handles genuinely shared resources where multiple owners collaborate to keep an object alive. Its reference counting makes it slightly heavier than unique_ptr, so reach for it only when shared ownership is truly required.

std::weak_ptr is the essential companion to shared_ptr, enabling non-owning observation, breaking cyclic references, and implementing safe optional handles to shared objects.

Together, these three tools give you a complete, principled system for managing heap memory in C++ — one that eliminates entire categories of bugs while preserving the performance and control that makes C++ such a powerful language.

Share:
Subscribe
Notify of
0 Comments
Inline Feedbacks
View all comments

Discover More

Understanding System Updates: Why They Matter and How They Work

Learn why operating system updates are crucial for security, performance, and features. Discover how updates…

Arduino Boards: Uno, Mega, Nano, and More

Learn about different Arduino boards, including Uno, Mega, Nano, and more. Discover their features, use…

Introduction to Robotics: A Beginner’s Guide

Learn the basics of robotics, its applications across industries, and how to get started with…

What Is a System Call and How Do Programs Talk to the Operating System?

Learn what system calls are and how programs interact with the operating system. Understand the…

Getting Started with Robotics Programming: An Introduction

Learn the basics of robotics programming, from selecting languages to integrating AI and autonomous systems…

Intel Debuts Revolutionary Core Ultra Series 3 Processors at CES 2026 with 18A Manufacturing Breakthrough

Intel launches Core Ultra Series 3 processors at CES 2026 with groundbreaking 18A technology, delivering…

Click For More
0
Would love your thoughts, please comment.x
()
x