“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.
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.
Latest posts by Glennan Carnie (see all)
- Your handy cut-out-and-keep guide to std::forward and std::move - April 26, 2018
- Setting up Sublime Text to build your project - April 12, 2018
- “May Not Meet Developer Expectations” #77 - February 15, 2018