“Do, or do not; there is no ‘try’.”
Previously, we’ve looked at The Rule of Zero which, in essence, says: avoid doing your own resource management; use a pre-defined resource-managing type instead.
This is an excellent guideline and can significantly improve the quality of your application code. However, there are some circumstances where you might not get exactly what you were expecting. It’s not that the code will fail; it just might not be as efficient as you thought.
Luckily, the solution is easy to implement and has the additional side-effect of making your code even more explicit.
Leading by example
We’ll use a simple (if slightly contrived) embedded example to illustrate the point. If our system has General-Purpose (digital) Input-Output (GPIO) we may wish to abstract the hardware access with a class. To keep things simple our GPIO hardware will be controlled by three registers
- A function select register which allows us to configure a hardware pin as either GPIO or some other function
- A direction register, that allows us to set a GPIO pin as either an input or an output
- A data register, from which we can drive the hardware pin high or low.
To help us out some kind soul has written a Register class that abstracts memory-mapped hardware access.
The implementation details of the Register class are unimportant for this article; the thing to note is that the Register class supports copying and moving explicitly (with the caveat that this is a contrivance for the purposes of this article).
We can now construct a GPIO class that encapsulates the three hardware registers we have to control and provide an abstract interface for clients.
Once again, implementation details for the GPIO class are unimportant.
Notice, however, that we haven’t implemented any copy or move semantics. This is the Rule of Zero in action – we don’t have to, since the Register class provides its own copy and move semantics.
Making use of the Rule of Zero
Let’s use our new GPIO class:
I’m deliberately using push_back() (and, yes, deliberately not using emplacement) here to force the copy-construction of the GPIO objects into the vector.
Even though we haven’t written a copy constructor for the GPIO class the compiler will automatically provide one for us that performs a (shallow) copy of the members of GPIO. This will invoke the copy constructor on the (composite) Register objects.
As vectors now support move semantics as well, we could ‘improve’ our design by moving the GPIO objects into the vector.
As before, since we haven’t provided a move constructor for the GPIO class the compiler generates one for us, automatically moving the members of the class. This will invoke the move-constructor on the Register objects.
(Remember, calling std::move on input and/or output yields an x-value expression – meaning after the push_back() they are effectively in an ‘empty’ state so they probably won’t behave as expected if you try and use them!)
A simple change, a subtle side-effect
So far so good.
After a design review (you do those, right?) it is decided that our GPIO class should leave the hardware in a ‘safe’ state – for example, with any enabled GPIO functions turned off, or all pins set to inputs, etc. Quite reasonably, the destructor of the GPIO class is the best place to do this.
Revisiting our vector of GPIO objects, adding them by copy works exactly as before.
However, when we switch to insertion-by-moving we get an unexpected result:
All the move-constructor calls have gone, replaced by copying!
OK, the code still works and it might not be a critical overhead in this particular case, but in another situation this could seriously impact the performance of your code.
What’s happening here? An inexperienced programmer may start to explore the Register class as the obvious candidate (“What does that noexcept do?”)
Compiler-generated move operations
The answer, in fact, lies with your GPIO class – or, more specifically, the rules the compiler uses when generating move and copy operations for your class.
Under normal circumstances the compiler will, indeed, generate a default move-constructor and move-assignment operator. However, as we’ve now added a destructor to the GPIO class the rules change.
Usually, the only reason to write a destructor is to perform some level of clean-up when an object goes out of scope. Of course, that’s just what we’ve done in this case. The compiler, though, sees it as “I am doing resource management”. If you’ve written a destructor the compiler assumes that the default ‘shallow-move’ semantics it can provide are no longer adequate. Therefore, it does not generate a move-constructor or move-assignment.
Why does my code still compile?
std::move doesn’t actually do any moving. It simply casts the supplied object to an r-value reference. This then binds against the r-value overload of the vector’s push_back() method. The r-value overload of push_back() attempts to move-construct the supplied object into the vector, thus calling the object’s move-constructor.
If your class doesn’t have a move-constructor the compiler searches for the next most-compatible constructor which, in this case, is the copy-constructor. Why? Because a reference-to-const can always be bound to an r-value object; just as it has always done in C++.
(The description I’ve given is a simplification; but the end result is the same)
Thus, attempting to move an object that doesn’t support move semantics always results in copying; and backward compatibility with pre-C++11 code is maintained.
Invoking default move semantics
Our GPIO class does need its destructor but it doesn’t need deep-copy semantics. Does this mean we have to implement our own move-constructor and move-assignment and do the work the compiler used to do?
Fortunately, no. It is a simple job to cajole the compiler into automatically generating the move-constructor and move-assignment operator:
The =default syntax tells the compiler to generate the default implementation for these functions. You don’t have to provide any body for them.
It was all going so well…
Remember this code?
Hang on, wasn’t this code just working? What’s going on?
The answer is the compiler’s move / copy function generation rules (again). By explicitly declaring the move-constructor and move-assignment functions you are informing the compiler that you are performing non-standard move semantics. If moving is non-standard, the compiler assumes copying is also non-standard, hence it no longer generates a copy-constructor and copy-assignment operator! In fact, it marks them as deleted functions (=delete)
In order to use the default copy-constructor and copy-assignment operator you must explicitly declare them, as with the move-constructor and move-assignment.
The rules for compiler-generated copy and move operations are summarised in the table below:
The compiler will provide default implementations for copy-constructor and copy-assignment as required; you may provide your own implementations of either, letting the compiler provide the other.
If you provide a destructor the compiler will no longer generate default implementations for the move-constructor and move-assignment operators.
If you provide an implementation for either a move-constructor or move-assignment operator the compiler will no longer generate an implementation for the other.
If you provide an implementation for either a move-constructor or move-assignment operator the compiler will no longer generate an implementation for the copy-constructor or copy-assignment.
By attempting to enforce good practices on resource management, whilst still retaining backward compatibility, a Modern C++ compiler can leave a programmer with a degree of uncertainty about their program’s copy/move behaviour. To keep things simple I offer the following rules-of-thumb:
- Where possible, adhere to the Rule of Zero – use resource-management classes rather than performing your own; and don’t write destructor, copy/move operations.
- If you are going to deviate from the Rule of Zero then be explicit about your entire move/copy policy.
- Practice makes perfect, part 3 – Idiomatic kata - February 27, 2020
- Practice makes perfect, part 2– foundation kata - February 13, 2020
- Practice makes perfect, part 1 – Code kata - January 30, 2020
Glennan is an embedded systems and software engineer with over 20 years experience, mostly in high-integrity systems for the defence and aerospace industry.
He specialises in C++, UML, software modelling, Systems Engineering and process development.
Nice article, particularly the helpful table.
One thing, could you please rename Register to RegisterRef? It isn't a register, just a reference to one - ie the copy constructor doesn't make _another_ register, it makes another reference to the same register. And that's semantically important.
(Same with GPIO. Which in fact means that you probably don't want to reset the registers in your destructor, since another (copied) CPIO class could still be using them. But that would spoil the story of why you need the dtor, and spoil the example... (And would you ever want two pieces of code modifying copies of the same GPIO, potentially at the same time?))
Overall a great article. However, it is nonsensical to say that "after moving, input and output are x-value objects"; xvalue is a value category, a static (compile-time) property of expressions. Rather, input and output become moved-from, a dynamic (runtime) property of objects. As you observe later in the article, simply calling std::move on an object does not cause it to become moved-from; the resulting xvalue expression must be passed or forwarded to a move-constructor or move-assignment operator.
Fair point, well made 🙂
It's always a difficulty coming up with examples that allow me to explore the concepts without resorting to 'A', 'B', 'Cat', 'Dog' 'BankAccount' -type examples. I like to (at least try) to come up with something (vaguely) relevant to our target audience 🙂
I was being lazy in my writing. I've updated the article to to reflect your comments.
In the summary section you advocate being explicit about copy/move all the time, but the rule of zero is to rely on the compiler defaults as much as possible, by factoring out copy/move into classes dedicated to that function. You may argue for always being explicit, but that's not the rule of zero.
Nevertheless, you point out an interesting case where following the rule of zero is difficult, because you need a destructor but can't easily factor that into its own class, so I enjoyed the article from that perspective.
Then, should we concluded that
1. should explicitly declare copy/move constructor/assignment operator as default or delete.
2. should NEVER use a raw pointer to manage a resource.
combining the rule of big four and the rule of zero.
@Yindong: What I'm saying is: that has nothing to do with the rule of zero, other than being counter to it.
Rule of zero: Declare zero special members as often as possible.
Always declare move/copy is the opposite. It's more like The Rule of Always 4 or 5.