Logo

Blog


How to achieve the rule of zero - an example

In today's post, I want to talk about the rule of zero and give an example of how to achieve it.

Sketching a Stack class

Since the beginning of C++, you might have heard of different rules about the special member function. Before C++11, we had only three, now, we have five. Whenever we touch one of these special member functions, it affects the remaining ones. Hence the idea is that once we touch one, we have to be explicit about the others. Okay, it is C++, so have to be explicit means that we can do something but don't have to.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class Stack {
  A initial value, stack can grow
  static constexpr auto INITIAL_SIZE{40};

  int  mMaxSize;
  int  mCurrentsize;
  int* mData;

public:
  Stack()
  : mMaxSize{INITIAL_SIZE}
  , mCurrentsize{}
  , mData{new int[INITIAL_SIZE]{}}
  {}

  ~Stack() { delete[] mData; }

  // access functions: push, pop, ...
};

For simplicity reasons, let's ignore potential access functions. We assume that the data stored in mData might grow. Maybe there is a resize operation as well.

Adding missing special members... wait, what?

Let's focus on the two parts, the default constructor and the destructor. By providing them, we are obviously no longer following the rule of zero. Even worse. Since we provided a destructor, we lost the move members, which can be crucial for performance since pointers are perfect for being moved. So to get all this back, we have to write the code in A:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Stack {
  static constexpr auto INITIAL_SIZE{40};

  int  mMaxSize;
  int  mCurrentsize;
  int* mData;

public:
  Stack()
  : mMaxSize{INITIAL_SIZE}
  , mCurrentsize{}
  , mData{new int[INITIAL_SIZE]{}}
  {}

  ~Stack() { delete[] mData; }

  A move & copy operations
  Stack(const Stack&) = default;
  Stack(Stack&&)      = default;

  Stack& operator=(const Stack&) = default;
  Stack& operator=(Stack&&) = default;

  // access functions: push, pop, ...
};

Great, more special members! Or better urg... Let's see how we can improve this situation. Defaulting the move and copy operations is necessary due to the user-provided destructor. Changing that seems like a good approach.

Reducing the number of user-provided special members

Aside from the rule of zero, you might have heard about no raw pointers or no naked new. How about we follow that idea? Instead of using the raw pointer int*, we use a unique_ptr<int[]>. This simplifies Stack a lot! We can drop the user-provided destructor and, by that, all the other special members we had to provide.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class Stack {
  static constexpr auto INITIAL_SIZE{40};

  int                    mMaxSize;
  int                    mCurrentsize{};
  std::unique_ptr<int[]> mData;

public:
  Stack()
  : mMaxSize{INITIAL_SIZE}
  , mCurrentsize{}
  , mData{std::make_unique<int[]>(INITIAL_SIZE)}
  {}

  // access functions: push, pop, ...
};

Knowing that the off-by-one error is a very common error in computer science, we can call it a day, right? One is nearly zero... or not?

Reaching zero

You're still hungry for more? Good, because we still have the default constructor left. There is another C++11 feature that comes in handy here, default member initialization.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Stack {
  static constexpr auto INITIAL_SIZE{40};

  int                    mMaxSize{INITIAL_SIZE};
  int                    mCurrentsize{};
  std::unique_ptr<int[]> mData{std::make_unique<int[]>(INITIAL_SIZE)};

public:
  // access functions: push, pop, ...
};

Now we can delete our implementation of the default constructor as well, giving us a class that follows the rule of zero.

Andreas