Skip to Content
Course content

178: Implementing a Custom Smart Pointer from Scratch

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you first start writing your own resource managers in C++, it's easy to think that simply putting a delete call in the destructor is enough. I remember doing this early in my career, thinking I'd "solved" memory leaks, only to spend three hours debugging a segmentation fault that only happened when I passed my object into a function by value. Here is exactly what that looks like.

struct SimplePtr {
    int* data;
    SimplePtr(int val) : data(new int(val)) {}
    ~SimplePtr() { delete data; }

    int& operator*() { return *data; }
};

void printValue(SimplePtr p) { 
    // p is passed by value, so a copy is made
    std::cout << *p << std::endl; 
} // p's destructor is called here!

int main() {
    SimplePtr myPtr(42);
    printValue(myPtr); // This looks fine...
    std::cout << *myPtr << std::endl; // CRASH!
    return 0;
}

The Double-Free Disaster

If you run the code above, it will likely crash. Why? Because we didn't define a copy constructor. C++ provides a default one that does a "shallow copy," meaning it just copies the memory address stored in data. When printValue finishes, the local copy p is destroyed, and it calls delete data. Now, the myPtr object in main is holding a pointer to memory that has already been freed. When main ends, it tries to delete that same memory again. That's a double-free, and it's a great way to get your program killed by the OS.

Enforcing Unique Ownership

If we want this to behave like a std::unique_ptr, we have to be explicit: this pointer cannot be copied. We don't want two objects thinking they "own" the same piece of memory. The fix is to use the = delete syntax on the copy constructor and copy assignment operator. This tells the compiler to throw an error if anyone tries to copy our pointer, catching the bug at compile-time instead of crashing at runtime.

struct UniquePtr {
    int* data;
    UniquePtr(int val) : data(new int(val)) {}
    ~UniquePtr() { delete data; }

    // Stop the madness: No copying allowed
    UniquePtr(const UniquePtr&) = delete;
    UniquePtr& operator=(const UniquePtr&) = delete;

    int& operator*() { return *data; }
    int* operator->() { return data; }
};

Transferring Ownership via Move Semantics

Now we have a problem. We can't copy the pointer, but what if we actually want to move the resource from one object to another? This is where move semantics come in. We need a move constructor and a move assignment operator that "steal" the pointer from the source object and then set the source's pointer to nullptr. This ensures that only one object ever owns the memory, but we can still pass it around.

I personally find it helpful to think of the move constructor as a "hand-off." The old object lets go, and the new object takes over.

struct UniquePtr {
    int* data;
    UniquePtr(int val) : data(new int(val)) {}
    ~UniquePtr() { delete data; }

    UniquePtr(const UniquePtr&) = delete;
    UniquePtr& operator=(const UniquePtr&) = delete;

    // Move constructor: Transfer ownership
    UniquePtr(UniquePtr&& other) noexcept {
        this->data = other.data;
        other.data = nullptr; // The "hand-off"
    }

    // Move assignment operator
    UniquePtr& operator=(UniquePtr&& other) noexcept {
        if (this != &other) {
            delete data;        // Clean up existing resource
            this->data = other.data;
            other.data = nullptr;
        }
        return *this;
    }

    int& operator*() { return *data; }
    int* operator->() { return data; }
};

By adding these move operations, we can now use std::move() to transfer our pointer into functions or containers without triggering a copy. We've successfully implemented the core logic of a smart pointer: RAII for automatic cleanup, deleted copies for safety, and move semantics for flexibility.




📋 Practical Task

Implementing a Reference-Counting SharedPtr

Now that you've mastered unique ownership, it's time to tackle shared ownership. Your task is to implement a simplified version of std::shared_ptr called SharedPtr.

Requirements:

  • The SharedPtr must manage a dynamically allocated integer.
  • You must implement a separate ref_count (a pointer to an integer on the heap) that is shared between all copies of the SharedPtr.
  • The copy constructor must increment the reference count.
  • The destructor must decrement the reference count and only call delete on the managed data when the count reaches zero.
  • Overload operator* and operator-> to allow access to the underlying data.

Testing your implementation: Create three different SharedPtr objects that all point to the same integer (via copying). Print the value of the reference count at each step to ensure it increments and decrements correctly as the objects go out of scope.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.