Saturday, December 14, 2013

Can you write an assignment operator?

This would be the first in hopefully a series of blog posts to talk of C++11 features and libraries that matter. But in this article, I wouldn't pick up a whole lot of C++11. Instead I shall lay some groundwork first, talking about exception safety in a very informal way and looking at the nothrow swap idiom for copy assignment. Along the way, we'll use some C++11 features (like auto) and libraries (like std::unique_ptr) with obvious syntax and simple usage. Consider a simple class that wraps a character buffer.

#include <iostream>
#include <cstring>
#include <algorithm>

class MyString
{
public:
  // constructor
  explicit MyString(const char *str) : buffer(NULL)
  {
    if (str && str[0] != '\0') {
      auto ln = strlen(str);    // C++11: auto -  
                                //  compiler determines correct type for ln
      buffer = new char[ln + 1];
      std::copy(str, str + ln, buffer);  // more general than strncpy
    }
  }

  // destructor
  ~MyString()
  {
    delete []buffer;
  }

  size_t len() const
  {
    if (buffer) {
      return strlen(buffer);
    } else {
      return 0;
    }
  }

  std::ostream& print(std::ostream& os)
  {
    return (os << buffer);
  }

private:
  char *buffer;
};

What would it mean to copy an object of the above class? What would it mean to assign one object of this class to another? What would the behaviour be of such code:
MyString en("Hello");
MyString es(en);

And of such?
MyString en("Hello");
MyString es("Hola");
en = es;

Without rolling out your own copy constructor and copy assignment operator, disastrous. In the first case the default copy constructor would create object es as a shallow copy of the object en. That would mean that after construction, es and en would both have their data member buffer pointing to the same address. When the scope in which both of these objects are created is exited, es would be destroyed first, followed by en. The destructor of es would have deallocated all the heap-memory pointed to by buffer in one fell swoop, and soon after, en's destructor would try doing the same - and disaster should strike.

The second case is worse in some respects, except that it shouldn't matter: on line 3, as es is assigned to en, the en.buffer starts pointing to the same location as the es.buffer. But en.buffer already pointed to an address at the head of a block of bytes on the heap that had "Hello" in it. Now that both en.buffer and es.buffer point to another location (with "Hola" in it), all references to the "Hello" bytes are lost. This program doesn't have any hopes of being able to track down, and deallocate when it had to, the buffer with "Hello". We have a leak, but it shouldn't matter. Shortly afterwards, when en and es both fall out of scope, es's destructor gets called followed by en's, and as in the case of copy construction above, disaster strikes.

The remedy is well-known - roll out your own copy-constructor and copy-assignment operator.
class MyString
{
public:
  // constructor
  // destructor

  // copy constructor
  MyString(const MyString& that)
  {
    auto ln = that.len();
    if (ln) {
      buffer = new char[ln + 1];
      std::copy(that.buffer, that.buffer + ln, buffer);
    }
  }

  // copy assignment
  MyString& operator = (const MyString& that)
  {
    auto ln = that.len();
    if (this != &that) {
      // release earlier content
      delete [] buffer;
      // and mimic copy construction
      buffer = new char[ln + 1];
      std::copy(that.buffer, that.buffer + ln, buffer);
    }

    return *this;
  }

  // rest of the class
};


Now copy construction creates a copy of the buffer for each new object created and copy assignment takes care of deallocating the older buffer before reallocating the new buffer and copying content. Congratulations. You've just fixed a couple of bad crashes in the code. Bad news, if you wrote this code in an interview, they'll offer you a good C++ book and not the job. Porque? Que pasa? Because you goofed up the copy assignment. For what would happen if the call to std::copy threw? Ok, in this rather unimaginatively contrived example, it would likely not. But in general, we carry out several steps in the assignment: deletion of the old buffer, allocation of a new buffer and then copying. If the allocation of the new buffer fails, you have no way to get back and salvage your older data. Nor if the copy fails after that. In simple terms, the code we've written is not exception safe.

The key problem is losing the previous buffer before the new buffer is ready. If we first create the new buffer separately, then cache the old buffer, assign the new buffer and finally delete the old buffer, we've made a start.
class MyString
{
...
  MyString& operator = (const MyString& that)
  {
    auto ln = that.len();
    if (this != &that) {
      // allocate and set aside
      char *new_buffer = new char[ln + 1];
      std::copy(that.buffer, that.buffer + ln, new_buffer);
      
      // cache the old
      char *old_buf = buffer;
      // assign the new
      buffer = new_buffer;
      // delete the old
      delete [] old_buf;
    }

    return *this;
  }
...
};
But problems still abound. If copy threw, we'd be left with a leak. Besides, we are still dealing with a single member and this scheme quickly gets out of hand if you deal with two or more members with similar requirements. We can make a small improvement here.
class MyString
{
...
  MyString& operator = (const MyString& that)
  {
    if (this != &that) {
      // Use RAII
      MyString tmpStr(that.buffer);
      
      // swap the two pointers
      std::swap(buffer, tmpStr.buffer);
      // et voila!
    }

    return *this;
  }
...
};
If an exception is thrown before line 8, nothing changes. If one is thrown after line 8, tmpStr.buffer is deallocated by a call to its destructor. The call to swap cannot throw. Once that call is complete, ownership of buffers have been exchanged and the destructor of tmpStr takes care of deallocating the older buffer of the current object (this). If we are dealing with multiple members, extending this logic requires a little extra effort. Define a swap member function, or specialize std::swap for MyString, and implement it with no-throw guarantees. A set of pointer swaps for one should be able to provide that guarantee. Your code would then look like:
namespace std
{
  void swap(MyString& lhs, MyString& rhs)
  {
    if (&lhs != &rhs) {
      char *tmp = lhs.buffer;
      lhs.buffer = rhs.buffer;
      rhs.buffer = tmp;
    }
  }
}

class MyString
{
...
  MyString& operator = (const MyString& that)
  {
    if (this != &that) {
      // Use RAII
      MyString tmpStr(that.buffer);
      
      // swap the two objects
      std::swap(*this, tmpStr); // or swap(tmpStr) if swap were a member
      // et voila!
    }

    return *this;
  }
...
};
This is the standard idiom for writing copy assignments using no-throw swaps and on another day I would have happily concluded this article here. Alas! We still have a problem. If you've been attentive you may have already noticed it. What if the MyString constructor threw at line 20? It mighty well can, if say the call to std::copy threw. Ok, I hear you - it won't in the case of this example. But we are performing two operations in the constructor - allocation and assignment of values to the cells of the allocated buffer. If the latter operation throws, the destructor of MyString won't get called and we'd be leaking the memory allocated for buffer. The fool-proof way to deal with the lack of atomicity of this kind of resource allocation plus initialization issues is to harness RAII in some form to protect the smallest units of allocation. We'll use a C++11 smart pointer to do the trick for us. Here is the full listing.
#include <iostream>
#include <cstring>
#include <algorithm>
#include <memory>

class MyString
{
public:
  // constructor
  explicit MyString(const char *str)
  {
    if (str && str[0] != '\0') {
      auto ln = strlen(str);
      buffer.reset(new char[ln + 1]);
      std::copy(str, str + ln, buffer.get());
    }
  }

  // copy constructor
  MyString(const MyString& that)
  {
    auto ln = that.len();
    if (ln) {
      buffer.reset(new char[ln + 1]);
      std::copy(that.buffer.get(), that.buffer.get() + ln, buffer.get());
    }
  }

  // destructor
  ~MyString()
  {}

  size_t len() const
  {
    if (buffer) {
      return strlen(buffer.get());
    } else {
      return 0;
    }
  }

  // copy assignment
  MyString& operator = (const MyString& that)
  {
    if (this != &that) {
      // copy the right side
      MyString tmp(that);

      // relinquish our data's ownership
      // to tmp, and acquire tmp's data
      swap(tmp);
    }

    return *this;
    // let tmp go out of scope and release
    // our older data in its destructor
  }

  // nothrow swap
  void swap(MyString& rhs)
  {
    buffer.swap(rhs.buffer);
  }

  std::ostream& print(std::ostream& os)
  {
    return (os << buffer.get());
  }

private:
  // C++11 smart pointer to make resource
  // management of buffer exception-safe
  std::unique_ptr<char[]> buffer;
};


int main()
{
  MyString m1("Hello"), m2("Hola");
  MyString m3(m1);
  m1 = m2;

  m1.print(std::cout) << std::endl;
  m2.print(std::cout) << std::endl;
  m3.print(std::cout) << std::endl;
}

Three points to note:
  • The member buffer is now a std::unique_ptr smart pointer (actually its std::unique_ptr specialization for arrays).
  • If std::copy throws on line 15 or 25 in the constructor, the destructor of buffer is called correctly and there is no leak.
  • std::unique_ptr provides a no-throw swap function which can be used to perform the copy assignment.
Prior to C++11 standard library smart pointers were limited to auto_ptr and they would be of limited use here. Using unique_ptr from C++11 makes the code a whole lot succinct. To be sure, the only real difference between the last listing and the one before that is in how we wrapped individual units of allocation (buffer) in RAII wrappers (std::unique_ptr). This last listing can be seamlessly extended to more such members and would still work.

Read more!