Skip to main content

📝 Lesson 10: Smart Pointers

Smart pointers bring RAII to memory management in modern C++, using unique_ptr, shared_ptr, and weak_ptr to make leaks, dangling pointers, and double frees a thing of the past.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why raw pointers are error-prone and how smart pointers apply the RAII principle to manage memory automatically.
  • Use unique_ptr for exclusive ownership, including move semantics, make_unique, and custom deleters.
  • Use shared_ptr for shared, reference-counted ownership and understand its performance trade-offs.
  • Use weak_ptr to observe a shared_ptr-managed object without extending its lifetime, and to break reference cycles.
  • Apply smart pointer best practices and recognize common pitfalls: circular references, array/scalar mismatches, and misuse of this in constructors.

Estimated Time: 90–120 minutes

Project: Build a resource management system that combines unique_ptr, shared_ptr, and weak_ptr to safely manage file handles, threads, and pooled objects.

In This Lesson

Why Smart Pointers?

Smart pointers are like responsible pet owners - they automatically take care of their resources, feeding them when needed and cleaning up after them. No more memory leaks or dangling pointers!

The Smart Pointer Family

graph TD A[Smart Pointers] --> B[unique_ptr] A --> C[shared_ptr] A --> D[weak_ptr] B --> E["Exclusive ownership<br/>Most common<br/>Zero overhead"] C --> F["Shared ownership<br/>Reference counted<br/>Thread-safe counting"] D --> G["Observes shared_ptr<br/>Breaks cycles<br/>Can expire"] style B fill:#E3F2FD,color:#111827 style C fill:#E8F5E9,color:#111827 style D fill:#FFF3E0,color:#111827

unique_ptr: Exclusive Ownership

unique_ptr is like having a single key to a house - only one person can own it at a time. When they're done, the house is automatically sold (memory freed)!

unique_ptr Ownership Transfer Step 1: Creation unique_ptr p1 Object Step 2: After Move nullptr p1 Object unique_ptr p2 Ownership transferred!

unique_ptr Examples

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

// Basic unique_ptr usage
void uniquePtrBasics() {
    // Creating unique_ptr
    unique_ptr<int> p1(new int(42));           // Direct initialization
    auto p2 = make_unique<int>(42);            // Preferred: make_unique

    // Accessing the value
    cout << *p2 << endl;                       // Dereference: 42
    cout << p2.get() << endl;                  // Get raw pointer

    // unique_ptr with arrays
    auto arr = make_unique<int[]>(10);         // Array of 10 ints
    arr[0] = 100;

    // Custom deleter
    auto fileDeleter = [](FILE* f) {
        if (f) fclose(f);
    };
    unique_ptr<FILE, decltype(fileDeleter)> file(
        fopen("data.txt", "r"), fileDeleter
    );
}

// Ownership transfer
unique_ptr<string> createMessage() {
    auto msg = make_unique<string>("Hello from function!");
    return msg;  // Automatic move
}

void transferOwnership() {
    auto p1 = make_unique<int>(42);
    // unique_ptr<int> p2 = p1;             // ERROR: Can't copy
    unique_ptr<int> p2 = move(p1);         // OK: Move ownership

    if (!p1) {
        cout << "p1 is now null" << endl;
    }

    // Reset and release
    p2.reset();                             // Delete object, set to null
    p2.reset(new int(100));                 // Delete old, own new

    int* raw = p2.release();                // Release ownership
    delete raw;                             // Now we must delete manually
}

// Using unique_ptr in classes
class Resource {
private:
    unique_ptr<int[]> data;
    size_t size;

public:
    Resource(size_t n) : data(make_unique<int[]>(n)), size(n) {
        // Initialize array
        for (size_t i = 0; i < size; ++i) {
            data[i] = i;
        }
    }

    // No need for destructor - unique_ptr handles it!
    // No need for copy constructor/assignment - deleted by default

    // Move constructor (compiler-generated is fine)
    Resource(Resource&&) = default;
    Resource& operator=(Resource&&) = default;

    int& operator[](size_t i) { return data[i]; }
};

shared_ptr: Shared Ownership

shared_ptr is like a shared apartment - multiple roommates can have keys. The apartment is only sold when the last roommate moves out!

shared_ptr Examples

// Basic shared_ptr usage
void sharedPtrBasics() {
    // Creating shared_ptr
    shared_ptr<int> sp1(new int(42));           // Direct
    auto sp2 = make_shared<int>(42);            // Preferred: more efficient

    // Copying increases reference count
    shared_ptr<int> sp3 = sp2;                  // Count: 2
    {
        shared_ptr<int> sp4 = sp2;              // Count: 3
    }                                            // Count: 2 (sp4 destroyed)

    // Check reference count
    cout << "Use count: " << sp2.use_count() << endl;

    // Check if unique owner
    // Note: sp.unique() was deprecated in C++17 and removed in C++20 —
    // compare the use count instead.
    if (sp1.use_count() == 1) {
        cout << "sp1 is the only owner" << endl;
    }
}

// Circular reference problem
class Node {
public:
    int value;
    shared_ptr<Node> next;
    shared_ptr<Node> prev;    // Creates cycle!

    Node(int val) : value(val) {
        cout << "Node " << value << " created" << endl;
    }

    ~Node() {
        cout << "Node " << value << " destroyed" << endl;
    }
};

void circularReferenceProblem() {
    auto node1 = make_shared<Node>(1);
    auto node2 = make_shared<Node>(2);

    node1->next = node2;
    node2->prev = node1;    // Circular reference!

    // When function ends, nodes are NOT destroyed
    // Reference count never reaches 0
}

// Solution: Use weak_ptr
class SafeNode {
public:
    int value;
    shared_ptr<SafeNode> next;
    weak_ptr<SafeNode> prev;    // Weak reference breaks cycle

    SafeNode(int val) : value(val) {}
};

// Custom deleter with shared_ptr
void customDeleterExample() {
    auto arrayDeleter = [](int* p) {
        cout << "Deleting array" << endl;
        delete[] p;
    };

    shared_ptr<int> sp(new int[10], arrayDeleter);

    // Or use default_delete for arrays
    shared_ptr<int[]> arr(new int[10]);
}

// Aliasing constructor
struct Person {
    string name;
    int age;
};

void aliasingExample() {
    auto person = make_shared<Person>(Person{"Alice", 30});

    // Create shared_ptr to member
    shared_ptr<string> namePtr(person, &person->name);

    // namePtr keeps person alive even if person goes out of scope
}

weak_ptr: Breaking Cycles

weak_ptr is like having a business card - you can call the number to check if the business still exists, but the card itself doesn't keep the business running!

weak_ptr Lifecycle T1 Create shared_ptr T2 Create weak_ptr T3 shared_ptr destroyed T4 weak_ptr expired Object exists weak_ptr can lock() Object gone lock() returns null

weak_ptr Examples

// Basic weak_ptr usage
void weakPtrBasics() {
    shared_ptr<int> sp = make_shared<int>(42);
    weak_ptr<int> wp = sp;                      // Create weak_ptr

    // Check if still valid
    if (!wp.expired()) {
        // Convert to shared_ptr to use
        if (auto locked = wp.lock()) {
            cout << "Value: " << *locked << endl;
            // locked keeps object alive in this scope
        }
    }

    sp.reset();                                  // Destroy object

    if (wp.expired()) {
        cout << "Object has been destroyed" << endl;
    }
}

// Observer pattern with weak_ptr
class Subject;

class Observer {
private:
    weak_ptr<Subject> subject;

public:
    void observe(shared_ptr<Subject> s) {
        subject = s;
    }

    void update() {
        if (auto s = subject.lock()) {
            // Subject still exists, can safely use
            cout << "Updating based on subject" << endl;
        } else {
            cout << "Subject has been destroyed" << endl;
        }
    }
};

🔭 Beyond the basics (preview)

Two classic weak_ptr patterns build on tools from later lessons: a tree where each child refers back to its parent weakly (uses vector — the STL, Lesson 16 — plus enable_shared_from_this), and a cache of weak_ptrs whose entries expire automatically (uses std::map and templates, Lesson 15). Revisit them once you've met those — the weak_ptr idea is identical; only the containers around it are new.

Smart Pointer Best Practices

graph TD A[Smart Pointer Guidelines] --> B["Prefer make_unique/make_shared"] A --> C[Use unique_ptr by default] A --> D[shared_ptr only when needed] A --> E[weak_ptr to break cycles] B --> F["Exception safe<br/>Single allocation<br/>More efficient"] C --> G["Zero overhead<br/>Clear ownership<br/>Move semantics"] D --> H["Multiple owners<br/>Thread-safe counting<br/>Higher overhead"] E --> I["Observer pattern<br/>Caches<br/>Back pointers"]

Common Pitfalls and Solutions

Performance Considerations

// Performance comparison
class PerformanceDemo {
public:
    // unique_ptr: Zero overhead
    void uniquePtrPerf() {
        unique_ptr<int> up(new int(42));
        // Same performance as raw pointer
        // No reference counting
        // Inline destructor
    }

    // shared_ptr: Reference counting overhead
    void sharedPtrPerf() {
        shared_ptr<int> sp(new int(42));
        // Atomic reference counting (thread-safe)
        // Control block allocation
        // Virtual destructor call
    }

    // make_shared optimization
    void makeSharedOptimization() {
        // Two allocations: object + control block
        shared_ptr<int> sp1(new int(42));

        // Single allocation: object + control block together
        auto sp2 = make_shared<int>(42);  // More cache-friendly
    }

    // Moving vs copying
    void moveVsCopy() {
        auto sp1 = make_shared<LargeObject>();

        // Copying: Atomic increment/decrement
        shared_ptr<LargeObject> sp2 = sp1;          // Slower

        // Moving: No atomic operations
        shared_ptr<LargeObject> sp3 = move(sp1);    // Faster
    }
};

💡 Smart pointers can also use custom allocators (for example pooled allocation via allocate_shared) in performance-critical code — a generic technique that builds on Templates (Lesson 15) and the STL (Lesson 16).

Practice Exercise: Resource Manager

🏋️ Manage a Resource with Smart Pointers

Practice all three smart pointers on a single simple class — no templates, threads, or containers required.

Instructions:

  1. Write a small class Resource whose constructor and destructor each print a message, so you can see exactly when it's created and freed.
  2. unique_ptr: create one with make_unique inside a scope; confirm it's destroyed automatically when the scope ends — no delete.
  3. shared_ptr: create one with make_shared, make a second shared_ptr to it, print use_count(), and confirm it's freed only after both owners are gone.
  4. weak_ptr: make a weak_ptr to the shared resource, access it with lock(), and show it reports expired() once the owners are gone.

Starter Code:

#include <iostream>
#include <memory>
#include <string>
using namespace std;

class Resource {
    string name;
public:
    Resource(string n) : name(n) { cout << "Created " << name << '\n'; }
    ~Resource()                  { cout << "Destroyed " << name << '\n'; }
    void use() { cout << "Using " << name << '\n'; }
};

int main() {
    // TODO: unique_ptr in a scope
    // TODO: shared_ptr + a second owner, print use_count()
    // TODO: weak_ptr, lock(), expired()
    return 0;
}
💡 Hint

auto u = make_unique<Resource>("A"); frees automatically at scope end. For sharing: auto s1 = make_shared<Resource>("B"); auto s2 = s1; then s1.use_count() is 2. A weak_ptr<Resource> w = s1; does not raise the count; call w.lock() to get a temporary shared_ptr (check it for null), and w.expired() becomes true once all shared owners are gone.

✅ Solution
#include <iostream>
#include <memory>
#include <string>
using namespace std;

class Resource {
    string name;
public:
    Resource(string n) : name(n) { cout << "Created " << name << '\n'; }
    ~Resource()                  { cout << "Destroyed " << name << '\n'; }
    void use() { cout << "Using " << name << '\n'; }
};

int main() {
    // unique_ptr — exclusive ownership, auto-freed at scope end
    {
        auto u = make_unique<Resource>("Unique");
        u->use();
    } // "Destroyed Unique" prints here

    // shared_ptr — shared ownership, freed when the last owner goes
    weak_ptr<Resource> observer;
    {
        auto s1 = make_shared<Resource>("Shared");
        auto s2 = s1;                                    // second owner
        cout << "use_count = " << s1.use_count() << '\n'; // 2
        observer = s1;                                   // weak_ptr adds no owner
        cout << "use_count = " << s1.use_count() << '\n'; // still 2

        if (auto locked = observer.lock())               // temporary owner
            locked->use();
    } // both s1 and s2 gone -> "Destroyed Shared" prints here

    cout << "expired? " << observer.expired() << '\n';    // 1 (true)
    return 0;
}

🔭 Optional Stretch (peek ahead)

Optional — these lean on later lessons. Real resource managers combine smart pointers with tools you'll meet soon: a custom deleter to close a FILE* or socket; storing owners in a vector<unique_ptr<T>> (containers arrive in Lesson 16: STL); a generic object pool via Lesson 15: Templates; and a thread pool via Lesson 19: Multithreading. Come back and build a full ResourceManager once you've met those.

Looking Ahead: Smart Pointers in Containers

The three smart pointers you just learned become even more powerful once you can store many objects at once and model relationships between types — a pairing you'll unlock in later lessons. Here's a preview of where they lead:

Smart Pointers in STL Containers vector<unique_ptr<T>> Move-only container, efficient polymorphic storage vector<shared_ptr<T>> Shared ownership, safe copying, higher overhead map<Key, weak_ptr<T>> Cache patterns, automatic cleanup of expired entries

🔭 A preview — you'll write this soon

A hallmark of modern C++ is a container of owning smart pointers holding a family of related types — for example a vector<unique_ptr<Shape>> that stores circles, rectangles, and triangles together and calls the right draw() on each. That one line combines three things you haven't met yet:

  • Inheritance & Polymorphism (Lesson 11) — the Shape base type and its derived shapes.
  • Templates (Lesson 15) — how a single container works for any element type.
  • The STL (Lesson 16) — std::vector, std::map, and friends.

Come back once you've met those and the pattern will read naturally. The key takeaway for now: smart pointers are what make such containers safe — every element cleans itself up, with no manual delete anywhere.

Modern C++ Features

graph TD A["C++11"] --> B["unique_ptr<br/>shared_ptr<br/>weak_ptr"] C["C++14"] --> D[make_unique] E["C++17"] --> F["Array support<br/>improved"] G["C++20"] --> H["make_shared for arrays<br/>atomic operations"] style A fill:#E3F2FD,color:#111827 style C fill:#E8F5E9,color:#111827 style E fill:#FFF3E0,color:#111827 style G fill:#F3E5F5,color:#111827

Challenge Exercise: Memory Pool

🏋️ Advanced Challenge: Break a Reference Cycle

Two objects that own each other with shared_ptr will leak — their reference counts never reach zero. Reproduce it, then fix it with weak_ptr.

  1. Write a Parent that holds a shared_ptr<Child> and a Child that holds a shared_ptr<Parent>. Give both a destructor that prints a message.
  2. Create one of each with make_shared, link them to each other, then let them go out of scope. Notice the destructors never run — that's the leak.
  3. Fix it by changing one side (e.g. Child's link back to Parent) to a weak_ptr. Run again and confirm both destructors now fire.
💡 Hint

A cycle pins each object's use_count at 1 forever. A weak_ptr refers to an object without raising its count, so breaking one direction of the cycle lets both counts reach zero. Access the weak side with lock() when you need it.

✅ Solution
#include <iostream>
#include <memory>
using namespace std;

struct Child;                     // forward declaration

struct Parent {
    shared_ptr<Child> child;
    ~Parent() { cout << "Parent destroyed\n"; }
};

struct Child {
    weak_ptr<Parent> parent;       // weak_ptr breaks the cycle (was shared_ptr)
    ~Child() { cout << "Child destroyed\n"; }
};

int main() {
    {
        auto p = make_shared<Parent>();
        auto c = make_shared<Child>();
        p->child  = c;            // Parent owns Child
        c->parent = p;            // Child only observes Parent (weak)
    } // both destructors run here

    cout << "Done\n";
    return 0;
}

With both links as shared_ptr, neither destructor prints — the objects leak. Making Child::parent a weak_ptr restores proper cleanup.

🎯 Quick Quiz

Question 1: Given auto p1 = make_unique<int>(42);, why does unique_ptr<int> p2 = p1; fail to compile?

Question 2: Two Node objects are linked with node1->next = node2; and node2->prev = node1;, where both next and prev are shared_ptr<Node>. What problem does this create, and how do you fix it?

Question 3: Why is make_shared<T>(...) generally preferred over shared_ptr<T> sp(new T(...));?

Summary

🎉 Key Takeaways

  • Smart pointers provide automatic memory management
  • unique_ptr for exclusive ownership - use by default
  • shared_ptr for shared ownership - use when necessary
  • weak_ptr to break cycles and observe without owning
  • make_unique/make_shared for exception safety and efficiency
  • Custom deleters for non-memory resources
  • RAII principle - tie resource lifetime to object lifetime
  • Move semantics work perfectly with smart pointers
  • Zero overhead with unique_ptr compared to raw pointers
graph LR A[Master Smart Pointers] --> B[Write Safe Code] B --> C[Eliminate Memory Leaks] C --> D[Build Robust Systems] D --> E["Modern C++ Expert!"]

📚 Additional Resources

🚀 What's Next?

You've now mastered modern C++ memory management with smart pointers and RAII. Next, in Lesson 11: Inheritance and Polymorphism, you'll relate your classes to one another — letting one type extend another and letting a single interface behave differently depending on the actual object behind it (often held through the very pointers you just learned to manage safely).

🎉 Smart pointer mastery achieved!

You've traded manual new/delete bookkeeping for RAII-powered safety — the exact discipline that separates fragile C++ from production-grade C++.