Improving Stability with Modern C++, Part 5 — Revisiting the Rule of Three

Ralph Kootker
FactSet
Published in
4 min readFeb 7, 2022

This was the standard advice given to new C++ programmers prior to C++11:

A class requiring one of: copy constructor, assignment operator, or destructor, generally needs all three.

Technically, it’s still true, but thanks to the addition of move semantics it’s now incomplete. Let’s take a deeper look at why, and see whether we can derive a more appropriate rule to follow.

Rationale

When move semantics were added in C++11, along with it came two new special class member functions, a move constructor and move-assignment operator. These provide the programmer with an optimization opportunity. It is now possible to define how member data can be moved, instead of copied, from an expiring temporary object (in standardese, an rvalue). This is how std::vector now behaves. As with copy and assignment, the compiler is free to generate the move operations for you. And that's where our trouble begins.

The rules for when the special member functions are generated (or more importantly, not generated) can be difficult to remember. See slide 28 of Howard Hinnant’s presentation on move semantics for a chart. If the programmer, following the Rule of Three, declares a copy constructor, copy-assignment operator, or destructor, the compiler will not generate any move operations.

But I didn’t say anything about move, shouldn’t the compiler write it for me?

It won’t unless you don’t declare any special member functions at all. Suppose your object is expensive to copy and it needs some specific behavior. Perhaps it contains a large std::vector, which would be much cheaper to move than copy. Declaring just a copy constructor or copy-assignment operator will miss out on performance by not allowing moves. The inverse is also true. Declare a move constructor or move-assignment operator only, and now the default copy constructor and copy-assignment operator won't be generated. So what can we do about this?

Rule of Zero?

One way around this problem is to design your class such that no special member functions are required at all. Recall from last time that all objects owning a resource should be wrapped in a smart pointer. All standard smart pointers and containers already know how to copy, move, and destruct themselves. If your class is limited to these and primitive types only (or objects containing them), you can omit all the special member functions because the compiler-generated ones will always do the right thing.

If you have a large class with many data members, but only one requires special handling, consider wrapping it in a resource-handling class rather than writing the special member functions yourself.

class Foo
{
private:
FILE *m_file;
// A dozen other std container or primitive type member variables
};

Only m_file requires careful consideration. Instead of writing all the boilerplate needed to copy, move, or destruct the other members, could you do something like this?

class Foo
{
private:
std::unique_ptr<FILE, decltype(&fclose)> m_file;
// ...
};

Such a change won’t always be practical. Sometimes special behavior truly is required. Perhaps you’re writing that resource-owning class, and a smart pointer would be insufficient or awkward to use. What then?

Rule of Five?

C++11 added two new expressions that allow programmers to be explicit in their treatment of special member functions. Gone are the days of declaring a copy constructor private or inheriting from boost::noncopyable to prevent copies. Instead, you can append =delete to a special member declaration to prevent the compiler from generating it. Similarly, append =default to signal that you're accepting the compiler's generated implementation. This is both simpler and safer than writing boilerplate code yourself. Keeping with the spirit of the original rule, if you declare one, declare them all.

// This class requires special destruction behavior, but the usual
// copies and moves are fine.
class Foo
{
public:
Foo() = default; // Works with the default constructor too.
Foo(const Foo&) = default;
Foo& operator=(const Foo&) = default;
Foo(Foo&&) = default;
Foo& operator=(Foo&&) = default;
~Foo(); // defined in .cxx file
};
// This class is movable, but not copyable.
class ScopedPtr
{
public:
ScopedPtr(const ScopedPtr&) = delete;
ScopedPtr& operator=(const ScopedPtr&) = delete;
ScopedPtr(ScopedPtr&&) = default;
ScopedPtr& operator=(ScopedPtr&&) = default;
~ScopedPtr() = default;
};
// A base class that prevents object slicing.
class CloneableBase
{
public:
CloneableBase(const CloneableBase&) = delete;
CloneableBase& operator=(const CloneableBase&) = delete;
CloneableBase(CloneableBase&&) = delete;
CloneableBase& operator=(CloneableBase&&) = delete;
virtual ~CloneableBase() = default;
virtual std::unique_ptr<CloneableBase> clone() const;
};

Better: Rule of Zero (or Five)

What can we conclude here? Both suggested rules are good on their own, and there’s a time and place for each. There are five special member functions that are best treated as a set: copy constructor, copy-assignment, move constructor, move-assignment, and destructor. A class that declares, defaults, or deletes one of them needs to consider all five. This prevents you from unwittingly making your class uncopyable, or suppressing moves of objects that are expensive to copy. And whenever possible, design your classes to not require any special member functions at all. Prefer Rule of Zero when you can, use Rule of Five when you must.

Where can I learn more?

The C++ Core Guidelines have much more to say on this topic than can fit in one blog post. We encourage all C++ programmers to read and understand these guidelines to ensure class safety and correctness.

What’s next?

Move semantics are a complex topic that warrants further investigation. Next time we’ll look at how to write move constructors, when to use moves, and perhaps more importantly, when not to.

Articles in this series

Acknowledgments

Special thanks to all that contributed to this blog post:

Author: Michael Kristofik
Managing Editor: Ralph Kootker
Reviewers: James Abbatiello, Jens Maurer, Tim Severeijns

--

--

Ralph Kootker
FactSet
Writer for

I publish on behalf of others or myself. Please carefully look at the acknowledgements at the bottom of each article