DEV Community

Dave Cridland
Dave Cridland

Posted on

Smart Pointers in C++

Smart Pointers

Smart Pointers are clever little things. Understanding them is key to writing solid C++, and this article aims to take you on an ever deeper dive into them.

Resource Acquisition Is Initialisation

The golden rule of C++ is that the compiler will do a lot of work for you as long as you put your resource acquisition - particularly memory - into a constructor somehow, and ensure the resource is cleaned up by a destructor.

The C++ compiler will then ensure that your resource, whatever it is, is always cleaned up when you exit the variable's scope - whether that was reaching the end of the scope normally, returning from a function call, having the parent object destroyed, or having an exception thrown.

The technique of doing so is called Resource Acquisition Is Initialisation, or RAII for short. If you only know one thing about C++ best practises, it should be this.

Memory

So it's something of a surprise that ordinary pointers don't do this - instead, "bare pointers" do no clean-up at all. That makes code like this risky:

{
  auto * p = new std::string("Hello World!");
  std::cout << *p << std::endl;
  delete p;
}
Enter fullscreen mode Exit fullscreen mode

We have to explicitly delete the object we created, and should the output generate an exception, we'll lose track of that memory entirely.

This is a contrived example, of course - it's trivial to just use a stack object here, instead - but non-contrived examples are more difficult, so we'll stick with this.

A (Too) Trivial Smart Pointer

Just tracking the memory is quite easy. We'll grab the pointer into an object at the earliest opportunity, and delete the object on destruction:

class smart_string_pointer {
    std::string * const m_ptr;
  public:
    smart_string_pointer(std::string * const ptr)
      : m_ptr(ptr) {};
    smart_string_pointer()
      : m_ptr(nullptr) {}
    ~smart_string_pointer() {
      delete m_ptr;
    }
};
Enter fullscreen mode Exit fullscreen mode

So far, so good. I've defined the pointer (but not the string it points to) as const, to indicate that we don't actually want to change it.

We can use this like:

{
  smart_string_pointer p(new std::string("Hello World!"));
  // Erm?
}
Enter fullscreen mode Exit fullscreen mode

OK, so we can't get the pointer, which makes this a bit useless. Luckily, C++ gives us operator overloading to help here.

// ...
  std::string & operator*() {
    return *m_ptr;
  }
  std::string const & operator*() const {
    return *m_ptr;
  }
Enter fullscreen mode Exit fullscreen mode

Now we can just use it like a normal pointer - if it's a const smart pointer, than the object is automatically const too.

There's a similar overload available to us for the arrow operator too - we do that in the same way.

{
  smart_string_pointer p(new std::string("Hello World!"));
  std::cout << *p << std::endl;
} // p->~smart_string_pointer() called here and cleans up.
Enter fullscreen mode Exit fullscreen mode

But this still has problems - the C++ compiler is going to be simply too helpful here, and create us a copy constructor, a move constructor, and assignment operators - all of which will copy the pointer. When the other object is destroyed, that means we'll have a pointer which has already been deleted.

When we try to delete that a second time, we touch on what the standard calls undefined behaviour, and that generally means the program crashes:

{
  smart_string_pointer ptr(new std::string("Hello World!"));
  {
    smart_string_pointer another_ptr(ptr); // Works!
  } // another_ptr->~smart_string_pointer() called, deletes object.
} // ptr->~smart_string_pointer() called, crash!
Enter fullscreen mode Exit fullscreen mode

We're going to need to solve this. And that means deciding what to do when we try to copy (or move) a smart pointer.

A Short Interlude About Templates

I don't really want to have to make a new smart pointer type for every different type I'm pointing to. That means making it generic, by using templates.

People think templates are complicated, and that's really not so. All a template is is just some code where there's a variable that contains a type.

We use a different syntax for these because they're handled at compile-time, not runtime, but beyond a slightly unfamiliar syntax, that's it.

So let's make this smart pointer we have nicely generic.


template<typename T>
class smart_ptr {
  T * const m_ptr;
public:
  explicit smart_ptr(T * const ptr) : m_ptr(ptr) {}
  smart_ptr() : m_ptr(nullptr) {}
  T & operator*() { return *m_ptr; }
  T const & operator*() const { return *m_ptr; }
  T * operator->() { return m_ptr; }
  T const * operator->() const { return m_ptr; }
  ~smart_ptr() { delete m_ptr; }
};
Enter fullscreen mode Exit fullscreen mode

I've included the arrow operators this time, and also made the pointer constructor explicit, which prevents it being used in object conversions we don't ask for.

OK? Let's move on.

Move It!

If we intend that a smart_ptr's object can be moved into another one - useful for being able to return them from functions, for example - we can do that by overloading the move constructor and assignment operators:

  // ...
  delete smart_ptr(smart_ptr const &);
  smart_ptr(smart_ptr && other) : m_ptr(nullptr) {
    std::swap(m_ptr, other.m_ptr);
  }
  delete operator=)smart_ptr const &);
  smart_ptr & operator=(smart_ptr && other) {
    delete m_ptr;
    m_ptr = nullptr;
    std::swap(m_ptr, other.m_ptr);
  }
Enter fullscreen mode Exit fullscreen mode

So, we need to delete any pointer we have, copy the new pointer from the smart_ptr being moved, and then set the smart_ptr's one to nullptr.

You'll see I'm not doing that, quite - instead I set my own pointer to nullptr and swap them, since this is a little safer.

Also, I've told the compiler not to generate implicit copy functions.

If we do this, we should also rename it, and then, with regret, throw it away - what we have there is a std::unique_ptr, and it's a certainty that the one that comes with your compiler will be better written.

Copy It!

If instead we want to be able to have lots of these smart pointers, all pointing at the same object, and copy them about happily, we're going to need to do something more clever.

Because we're going to need to know when to delete the object, we'll need to track how many of these smart pointers exist - only when the last one is destroyed do we delete the pointer.

Moreover, they'll need to all share the same counter.

That's a job for yet another pointer... Let's look at just the simple constructor and destructor cases:

template<typename T>
class shared_ptr {
  unsigned long * const m_counter;
  T * const m_ptr;
public:
  shared_ptr() : m_counter(nullptr), m_ptr(nullptr) {}
  explicit shared_ptr(T * const ptr) : m_counter(new unsigned long(1)), m_ptr(ptr) {}
  shared_ptr(shared_ptr const & other)
  : m_counter(other.m_counter),
    m_ptr(other.m_ptr) {
    if (m_counter) ++(*m_counter);
  }
  ~shared_ptr() {
    if (--(*m_counter) == 0) {
      delete m_ptr;
      delete m_counter;
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

So, when we bring a new object under the control of this shared pointer, we create a counter to go alongside it. Whenever we copy it, we increment the counter. Whenever we get destroyed, we decrement it - if it falls to 0, there are no remaining shared_ptr instances pointing at the same pointer, so we also delete both object and counter.

A confession

And that code, above, doesn't entirely work, and isn't very good anyway.

The reasons are many and varied, and mostly subtle. When allocating the counter, for example, we might encounter an exception and then leak the original pointer.

Luckily, the fix is trivial - if you need this kind of behaviour, just use std::shared_ptr, which has a host of additional features.

And one more thing.

A particular challenge left is the initial object creation. Exceptions thrown at the wrong moment can still leak memory, and we don't want that.

The standard library includes a couple of useful helper functions for this. std::make_unique creates (and returns) a std::unique_ptr with the object you need initialized, and std::make_shared does the same for std::shared_ptr.

The arguments are the same as the constructors of the object you want, so you can do:

{
  auto ptr = std::make_unique<std::string>("Hello World!");
}
Enter fullscreen mode Exit fullscreen mode

Or:

{
  auto ptr = std::make_shared<point>(0, 0);
}
Enter fullscreen mode Exit fullscreen mode

Happy smart pointering!

Top comments (0)