Beyond the Rules of Three, Five and Zero

by phil nash|

Beyond the rules of Three Five and Zero

In the previous article we looked at the Rules of Three, Five and Zero - what they are and when to use which (spoiler: use the Rule of Zero).

But rules often have exceptions - and sometimes those exceptions are important in their own right. What cases exist beyond the Rule of Zero and how can we make sense of them?

Categories of Types

In C++ the words Type and Class have subtly different meanings. But in natural language we talk more generally about types of things, or classes of things. It can be hard to find unambiguous words to talk about types or classes of… well, types or classes! Peter Sommerlad uses the term “class natures” but I’m going to use the word “category” here. However I do feel the need to add the disclaimer that this is not to be confused with the mathematical notion of a category (i.e. from Category Theory) - although, of course, there is a relation. It’s also worth mentioning that a type may belong to more than one category.

We talked already about value types and polymorphic base classes, but another common category of type is what we might call Resource Managers. These are types that directly manage a resource of some form: they typically acquire a resource in their constructor and destroy or release it in their destructor. They may do more in between, but that depends on their sub-category, as we’ll see. Perhaps the most obvious examples of these are smart pointers, such as unique_ptr and shared_ptr. These manage the resource of memory - as do std::string and std::vector (which are also good examples of belonging to more than one category - they are also value types). We also have file streams, which manage file handles, lock guards for managing mutexes, and many others.

These are where the Rules of 3 and 5 traditionally shine.

Sub-categories of Resource Manager

In terms of the special member functions, at first it seems that each resource manager type goes its own way. But broadly speaking there are three sub-categories of Resource Manager, depending on the approach towards ownership: Scoped, Unique and General.

Scoped Managers

These are non-copyable, non-moveable types. Their main purpose is to do something in their destructors - a form of deferred execution. Combined with C++’s property of deterministic destruction, if such an object is instantiated on the stack then we know exactly when that destructor will run (at the end of the scope it was declared in, or at the point of propagation of an exception) and in what order (the reverse order of construction). This can be critical for things like, for example, a scoped_lock that manages a mutex.

The destructor is clearly important, but so is the constructor. A scoped manager will typically have a custom constructor that will acquire or take ownership of some resource - perhaps from some lower level API. It may also have other constructors if the resource is created internally, or a default constructor may indicate nullability. Whichever approach makes sense we can consider these Acquire Constructors.

Copy and move constructors should be deleted, however - along with copy and move-assignment operators.

~ScopedManager() { /* custom destruction code */ }
ScopedManager( /* optional arguments */ ) { /* optional custom constructor */ }

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

So: deleted copy and move operations, again. We already saw that in the previous article when we looked at Polymorphic Base Classes. We could delete them all manually - or we can use the shorthand of just deleting the move-assignment operator.

ScopedManager operator=(ScopedManager &&) = delete;

That’s a lot less code. But wait, what? Why does this work?

Aside: C++’s special member function rules

At the center of why some of these interactions are subtle, and often unintuitive, are the rules for which special member functions are synthesized by the compiler and under what circumstances. Before C++11 the problem was that default copy operations were generated in all cases - even if you defined a destructor. The language itself violated the Rule of 3 - hence the need to apply it explicitly.

When C++11 added the move operations they didn’t make the same mistake. If you define a move constructor or move assignment operator then the copy operations are deleted. That leaves an inconsistency, so we still need to be wary. Technically the synthesized copy operations are now deprecated if one of the other original Rule of 3 functions are defined. So we shouldn’t rely on them being generated. But in practice they will be, so we can’t rely on them not being generated either.

This is all a little easier to follow in a table. Howard Hinnant produced a similar table in the past. This one is slightly different. Use the one you find most useful.

The cells with a blue background represent user declared functions. The rest of the line describes what happens with the other special member functions in that case (an empty cell means not synthesized). If more than one special member function is user declared then you can combine the rows. In that case delete and not declared trumps default (e.g. declaring a default constructor and a (possibly deleted) move-assignment operator would delete the copy operations and not declare a move constructor, as we’ll see).

What’s interesting is that you can clearly see the problem that required the Rule of 3 to address in red, underlined, text in the center (those deprecated functions).

Table showing deprecated functions

But looking at the move columns, if you provide either of those (and, remember, deleting a function counts as providing it, for our purposes) then the copy operations are deleted. Neither of the move operations are synthesized if any of the Rule of 5 functions are provided so we only need delete one of them and we end up where we want for our Scoped Manager, Polymorphic Base Class case - or any non-copyable. non-moveable type. Destructors are still defaulted - and deleting the move-assignment operator has the added advantage that it doesn’t suppress the default constructor.

Peter Sommerlad calls this approach “The Rule of DesDeMovA” (for DEStructor + DElete MOVe Assignment - and is a nod to the tragic character, Desdemona, from the Shakespeare play, Othello). Any time you want a non-copyable, non-moveable type, while still allowing a custom destructor, just provide a deleted move-assignment operator.

Unique Resource Managers

Since C++11’s move semantics made them possible, Unique Resource Managers have become a popular way to manage resources where lifetime management may be transferred from one manager to another. The archetypal Unique Resource Manager is std::unique_ptr. Unique Resource Managers work just like Scoped Managers, except that they implement move construction and/ or move-assignment.

Because these managers implement the move operations they still suppress compiler synthesis of the copy operations (see the table in the aside). Like Scoped Managers they also need Acquire Constructors and custom destructors. So the only difference to a Scoped Manager is that they implement the move operations.

UniqueManager(UniqueManager &&) { /* custom move construction */ }
UniqueManager operator=(UniqueManager &&) { /* custom move assignment */ }
~UniqueManager() { /* custom destruction code */ }

UniqueManager() { /* optional default constructor */ }
UniqueManager(auto args...) { /* optional custom constructor */ }


General Resource Managers

A General Resource Manager is both copyable and, if the copied object is independent of the original (rather than containing mutable shared resources, à la std::shared_ptr) then the manager type acts like a value type - imparting value semantics to the resource it manages. Rather than encoding the value itself, it adds a proverbial level of indirection. Some have called this an Indirect Value. Why would this be useful? Why not just use the underlying value directly?

Typically such managers manage objects in memory by pointer - like std::unique_ptr, but with the copy operations, too (so this is the full Rule of Five). A common use case is where the value being managed is polymorphic. In this case a way to invoke the correct copy operations is necessary. Traditionally this has been implemented using virtual clone() methods. Another approach, which is gaining popularity, is capturing pointers to the copy methods on acquisition and storing them in the manager object. This has the advantage of being more generic and less intrusive. It has the disadvantage of being more complex and tricky to write correctly. At time of writing there is a proposal in flight for standardizing std::polymorphic_value, which would make this easier.

Another use case is when the resource’s concrete type is fixed but you want to keep it out of the interface. This is often used as a way to break or minimize incidental dependencies in code - usually resulting in faster builds. There are several variations, and different names have been used over time, but one of the more common is the pImpl Idiom.

Either way getting it correct can be trickier than it looks, so use well proven library solutions if possible. Again, there is a proposal in flight for std::indirect_value, to help with this.

Rules, Damn Rules and Guidelines

Now that we have more of a vocabulary for the common categories of types that we use in C++ code, we have considered more fine-grained recommendations for dealing with the special member functions. We’ll round up with a table summarizing what we have discussed in this and the previous article.

We’ll throw in one more type category - Views or Reference types. These are non-owning pseudo-managers (think std::string_view, std::span, or even just raw pointers). Because these do not participate in lifetime management there are no particular recommendations. Destructors are probably not necessary. Copies are usually trivial - and moves are not necessary (would just be copies). This is all covered by the rule of Zero.

Table which summarizes categories and when to use them. Also the rules and special members.

So between the Rules of Zero and Five we can use the generation suppression rules of the move operations to give us a more fine-grained approach to how we spell out our special member functions. Remember, any deviation from the Rule of Zero should be rare - especially in application code. The Rule of Zero still rules!

Related blog post

The Rules of Three, Five and Zero