Skip to content
C++ Better Explained
Go back

Smart Pointers in Modern C++: unique_ptr, shared_ptr, and weak_ptr Explained

Edit page

Smart Pointers in Modern C++: unique_ptr, shared_ptr, and weak_ptr Explained

Introduction: The Problem with Raw Pointers

If you’ve worked with C++ long enough, you’ve probably experienced one of these nightmares:

These aren’t edge cases — they’re the daily reality of managing raw pointers manually. This is where smart pointers come in. They’re one of the most important features of modern C++ (since C++11), and once you understand them, you’ll write safer, cleaner code with far fewer memory-related bugs.

In this article, you’ll learn what smart pointers are, how to use unique_ptr, shared_ptr, and weak_ptr, and most importantly, when to use each one.

What is a Smart Pointer? The RAII Concept

A smart pointer is an object that acts like a pointer but automatically manages the memory it points to. It’s built on a principle called RAII (Resource Acquisition Is Initialization):

Think of it like this: a regular pointer is like a borrowed book with no checkout system — you have to remember when to return it. A smart pointer is like a library system with automatic returns — the book is returned the moment you’re done with it.

The key insight: a smart pointer transfers the responsibility of memory management from you to the compiler. The compiler knows when objects go out of scope, so it can automatically call delete.

unique_ptr: Exclusive Ownership

unique_ptr represents exclusive ownership of a dynamically allocated object. Only one unique_ptr can own a given piece of memory at any time.

Creating and Using unique_ptr

#include <iostream>
#include <memory>

class FileHandler {
public:
    FileHandler(const std::string& filename) {
        std::cout << "Opening file: " << filename << "\n";
    }
    ~FileHandler() {
        std::cout << "Closing file\n";
    }
};

int main() {
    // Create a unique_ptr
    std::unique_ptr<FileHandler> file1(new FileHandler("data.txt"));

    // Better: use std::make_unique (C++14+)
    auto file2 = std::make_unique<FileHandler>("config.txt");

    // The object is automatically destroyed when file2 goes out of scope
    return 0;  // Output: "Closing file"
}

Why You Can’t Copy a unique_ptr (and How to Move It)

Here’s the core rule: you cannot copy a unique_ptr. This makes sense — if ownership is exclusive, how could you copy it? You’d have two owners, violating the exclusivity principle.

std::unique_ptr<FileHandler> file1 = std::make_unique<FileHandler>("a.txt");

// This does NOT compile:
// std::unique_ptr<FileHandler> file2 = file1;  // Error!

// Instead, you MOVE ownership:
std::unique_ptr<FileHandler> file2 = std::move(file1);

// Now file2 owns the object, and file1 is nullptr
if (file1 == nullptr) {
    std::cout << "file1 is now empty\n";  // This prints
}

When you move a unique_ptr, you’re transferring ownership. The original pointer becomes nullptr, and the new pointer takes responsibility. This is perfectly safe because the compiler tracks it.

Practical Example: Managing a File Resource

Let’s see unique_ptr solving a real problem — a function that loads data from a file:

class DataFile {
private:
    std::string filename;
    std::vector<std::string> data;

public:
    DataFile(const std::string& name) : filename(name) {
        std::cout << "Opening: " << filename << "\n";
    }

    ~DataFile() {
        std::cout << "Closing: " << filename << "\n";
    }

    void load() {
        data = {"line1", "line2", "line3"};
    }
};

std::unique_ptr<DataFile> openDataFile(const std::string& filename) {
    auto file = std::make_unique<DataFile>(filename);
    file->load();
    return file;  // Ownership transfers to the caller
}

int main() {
    {
        auto myFile = openDataFile("data.txt");
        // Use myFile...
    }  // DataFile destructor called automatically here

    return 0;
}

This pattern is elegant: the function signals “I’m giving you ownership of this resource” by returning a unique_ptr. The caller knows they’re responsible for the lifetime, and the compiler enforces it automatically.

shared_ptr: Shared Ownership

shared_ptr represents shared ownership of a dynamically allocated object. Multiple shared_ptr instances can point to the same object, and the object is only deleted when the last shared_ptr is destroyed.

Reference Counting Explained

How does shared_ptr know when it’s safe to delete? It uses reference counting:

int main() {
    std::shared_ptr<int> ptr1 = std::make_shared<int>(42);

    {
        std::shared_ptr<int> ptr2 = ptr1;  // Reference count: 2
        std::cout << "Use count: " << ptr1.use_count() << "\n";  // Prints: 2
    }  // ptr2 destroyed, reference count: 1

    std::cout << "Use count: " << ptr1.use_count() << "\n";  // Prints: 1

    return 0;  // ptr1 destroyed, reference count: 0, memory deleted
}

Creating and Using shared_ptr

class DatabaseConnection {
public:
    DatabaseConnection(const std::string& host) {
        std::cout << "Connecting to " << host << "\n";
    }
    ~DatabaseConnection() {
        std::cout << "Disconnecting\n";
    }
};

int main() {
    // Using make_shared (preferred — more efficient)
    auto conn1 = std::make_shared<DatabaseConnection>("localhost");

    // Copy the shared_ptr
    auto conn2 = conn1;  // Reference count is now 2

    std::cout << "Use count: " << conn1.use_count() << "\n";  // Prints: 2

    {
        auto conn3 = conn1;  // Reference count: 3
    }  // conn3 goes out of scope, reference count: 2

    // Both conn1 and conn2 are still valid
    return 0;  // When all are destroyed, connection closes
}

The shared_ptr Overhead: When It Matters

shared_ptr isn’t free — it has costs:

  1. Memory overhead: Each shared_ptr maintains a control block (reference count, deleter, etc.)
  2. Runtime cost: Reference counting on copy/destruction
  3. Cache misses: The control block is separate from the actual object

For most applications, this overhead is negligible. But in performance-critical code (game loops, HFT systems), it matters.

// If you only have ONE owner, use unique_ptr instead!
auto fast = std::make_unique<int>(100);  // No reference counting

// If you need MULTIPLE owners:
auto shared = std::make_shared<int>(100);  // Reference counting needed

The Circular Reference Problem

Here’s a subtle but dangerous issue with shared_ptr:

struct Node {
    std::shared_ptr<Node> parent;
    std::shared_ptr<Node> child;
};

int main() {
    auto node1 = std::make_shared<Node>();
    auto node2 = std::make_shared<Node>();

    // Create a cycle
    node1->child = node2;
    node2->parent = node1;

    // Reference count for node1: 2 (node1 variable + node2->parent)
    // Reference count for node2: 2 (node2 variable + node1->child)

    return 0;
    // When node1 and node2 go out of scope:
    // - node1 ref count becomes 1 (node2->parent still holds it)
    // - node2 ref count becomes 1 (node1->child still holds it)
    // MEMORY LEAK! Neither can be deleted because they reference each other
}

This is a classic circular reference — two objects holding shared_ptr to each other create a deadlock that no automatic garbage collection can break.

weak_ptr: Breaking Circular References

weak_ptr solves the circular reference problem. A weak_ptr is a “non-owning reference” to an object managed by a shared_ptr.

Key properties of weak_ptr:

How weak_ptr Solves the Problem

struct Node {
    std::shared_ptr<Node> child;
    std::weak_ptr<Node> parent;  // Use weak_ptr for back-references!
};

int main() {
    auto node1 = std::make_shared<Node>();
    auto node2 = std::make_shared<Node>();

    node1->child = node2;
    node2->parent = node1;  // Weak reference — doesn't increment ref count!

    // Reference count for node1: 1 (only node1 variable)
    // Reference count for node2: 2 (node2 variable + node1->child)

    return 0;  // Both are properly deleted with no leak
}

How to Lock a weak_ptr Safely

Before using a weak_ptr, you must lock it to check if the object still exists:

std::weak_ptr<Node> weakParent = node->parent;

// Lock the weak_ptr to get a shared_ptr (if still valid)
if (auto strongParent = weakParent.lock()) {
    // Object still exists, safe to use
    std::cout << "Parent exists\n";
} else {
    // Object was deleted
    std::cout << "Parent has been deleted\n";
}

make_unique vs make_shared — Always Prefer These

Never write this:

std::unique_ptr<int> ptr(new int(42));  // Don't do this
std::shared_ptr<int> ptr(new int(42));  // Don't do this either

Always write this instead:

auto ptr1 = std::make_unique<int>(42);   // C++14+
auto ptr2 = std::make_shared<int>(42);   // C++11+

Why? Three reasons:

  1. Exception safety: make_unique/make_shared are exception-safe in all cases
  2. Efficiency: make_shared allocates the object and control block together
  3. Readability: Cleaner syntax

Comparison Table: Raw Pointer vs Smart Pointer Types

AspectRaw Pointerunique_ptrshared_ptrweak_ptr
OwnershipManual (you manage)ExclusiveShared (multiple)None (observer)
CopyingShallow copyNot allowedIncrement refcountAllowed, safe
DeletionManual deleteAutomaticAutomatic (when count=0)Never deletes
Reference CountingNoneNoneYesNone
Memory OverheadNoneMinimalControl block + refcountSmall
When to UseObserver onlyDefault choiceMultiple owners neededBack-references in graphs
PerformanceFastestFastModerate overheadFast

Common Mistakes with Smart Pointers

Mistake 1: Mixing Raw and Smart Pointers

// DON'T do this:
int* rawPtr = new int(42);
std::unique_ptr<int> smartPtr(rawPtr);  // Dangerous!
// If you delete rawPtr manually later, undefined behavior!

// DO this:
auto smartPtr = std::make_unique<int>(42);  // rawPtr never created

Mistake 2: Using .get() to Store and Delete

auto ptr = std::make_unique<int>(42);
int* raw = ptr.get();
delete raw;  // CRASH! Smart pointer will try to delete again

// The .get() method is for non-owning observation only

Mistake 3: Using new with shared_ptr Instead of make_shared

// Less efficient:
std::shared_ptr<int> ptr(new int(42));

// More efficient:
auto ptr = std::make_shared<int>(42);  // Single allocation

Mistake 4: Forgetting to Lock weak_ptr

std::weak_ptr<Node> weakNode = node;

// WRONG - weak_ptr doesn't have operator->
// weakNode->value = 10;  // Compilation error

// RIGHT - lock it first
if (auto strong = weakNode.lock()) {
    strong->value = 10;  // Safe
}

When to Still Use Raw Pointers: Non-Owning Observers

Surprisingly, raw pointers still have a place in modern C++. Use them as non-owning references:

class GameEngine {
private:
    std::vector<std::unique_ptr<GameObject>> objects;
    GameObject* activeObject;  // Non-owning observer!

public:
    void setActive(GameObject* obj) {
        // We're not taking ownership, just observing
        activeObject = obj;
    }
};

Raw pointers are perfect for:

The key: if you use a raw pointer, make it clear it’s not your responsibility to delete it. Often, a comment helps:

void processNode(Node* node) {  // Non-owning pointer
    // This function doesn't own the Node
    if (node != nullptr) {
        // Use node...
    }
}

Conclusion: Smart Pointers = Safer C++

Smart pointers are one of the most impactful features of modern C++. They let you:

✓ Eliminate most memory leaks automatically ✓ Make ownership explicit in your code ✓ Let the compiler handle resource cleanup ✓ Write exception-safe code naturally

The golden rules:

  1. Use unique_ptr by default — it has zero overhead and clear semantics
  2. Use shared_ptr when you need multiple owners — but rarely
  3. Use weak_ptr to break cycles — in complex ownership graphs
  4. Always use make_unique/make_shared — never use new directly
  5. Use raw pointers only for non-owning observation — make it explicit with comments

If you’re still writing C++ code with new and delete, you’re living in the past. Modern C++ (C++11 onward) gives you smart pointers — use them, and your code will be faster, safer, and cleaner.

Next steps: Try refactoring an existing project to use smart pointers. Start with unique_ptr everywhere, and you’ll be amazed at how much cleaner your code becomes. Which smart pointer are you most excited to start using?


Take Your C++ Further

If you’re looking to go deeper with C++, the C++ Better Explained Ebook is perfect for you — whether you’re a complete beginner or looking to solidify your understanding. Just $19.

👉 Get the C++ Better Explained Ebook — $19



Edit page
Share this post on:

Previous Post
How to Use Pointers in C++: A Complete Beginner's Guide
Next Post
C++ STL Containers Explained: Choosing the Right Container for Every Situation