Before the introduction of the move constructor and move assignment in C++, I had two clear conceptual categories of classes: values for which using a copy was not different from using the original value, entities for which it was different. For values, I provided the copy constructor and assignment operator (and equality operators), for entities I didn’t (if a copying operation made sense, the copy constructor was explicit, and the copy operation was not provided by assignment operator; equality was never a meaningful concept).
Introduction of move introduced a new category, and sadly I seem not to have found out a conceptual criteria which would allow me to do the choice of providing move operations or not for non value classes. Currently, I’m mostly driven by implementation considerations and that leaves me unsatisfied. More, recently I had to make a class evolve and I had to make the choice of either removing the move operations or introduce unwarranted complexity. Luckily, move operations were provided and not used and the choice was thus not too difficult. That event highlighted the fact that I had a conceptual issue.
How to make the choice between non-copyable non-movable types and move-only types without relying on intuition or implementation details?
The way to look at this is not a matter of copy vs. move vs. immobility. It’s two separate questions:
Mobility vs. immobility. Do you want it to be able to transfer the contents of the object to another object in some way?
If it’s mobile, then is it fully copyable or move-only?
The thing is, the answer to either question is not always a matter of design.
mutex is not immobile because we conceptually think of a
mutex as something that cannot be moved. We know exactly what it ought to mean to move a
mutex: the locks on that
mutex still work. It isn’t immobile by design; it is immobile by implementations. Some backends could make a mobile
mutex, but others cannot (without memory allocations or other overhead). Thus, we get the lowest-common-denominator.
lock_guard is immobile by design. That’s its purpose. The same goes for types like
boost::scoped_ptr; the whole point is that the lifetime of the managed construct will end at the termination of that scope. While at some times we may desire to move such objects, from the perspective of those who designed them, they did not want them to be mobile.
When it comes to the question of what form of mobility a type has, the same things apply.
unique_ptr is move-only because that’s its job. That’s why it exists: to permit the transfer of unique ownership from one location to another.
By contrast, one could conceive of the ability to copy a
std::thread. You would just pause the thread’s execution, copy its call stack, and start a new thread of execution with the copied call stack. That’s quite simple conceptually, yet it is impossible to implement with C++’s object model. After all, you might have a
scoped_ptr on the stack, a type you’re not allowed to copy. You might have a pointer/reference to a stack object on the stack; how do you update it to point to the correct object?
Implementations inform interfaces. The problem with your “conceptual criteria” is that it makes you think that you can build a couple of simple rules to categorize the world, and if you stick to those categories, you’ll be alright.
Programming is not that simple. You’re going to have to learn how to exercise judgment; it’s one of the skills that any good programmer needs.
You are correct that determining whether a type should be copyable or not is usually pretty straightforward. For example, it makes no sense to copy an object that represents an open network connection.
For moveability, the important question to ask yourself is whether you expect users to maintain multiple long-lived references to the object. Indeed, when something is moved out of an object, the contents of the original object are now “empty”, which typically means any existing references to the original object cannot be used anymore. That’s especially true when you consider that once an object is moved out, the original object is typically destroyed shortly afterwards.
In the case of
std::unique_ptr, the choice is pretty obvious: it’s not typical to hold long-lived references to a
unique_ptr object (usually it’s the pointee that’s referenced). Hence move semantics are acceptable there – there’s little risk of breaking existing references or pointers.
In the case of an open network connection, it might make sense to allow move semantics if you expect these connections to be short lived and only have one reference to them at any given time. That’s somewhat contrived, though, so when in doubt I would not implement move operations for such an object.
In some instances there is a clear case against supporting move semantics. For example, the whole point of
std::mutex is to be used from multiple locations at the same time; if move semantics were allowed there, the resulting behavior would be highly dangerous, as existing mutex locks could easily become invalid due to a spurious move.
Having a move constructor is just a (very useful) optimisation for situations where the language used to force you to have two objects for a short time, when you really only wanted one.
For example, if your function returns a vector, your function builds a vector object, then the vector object is copied into a new one that the caller can access, then often that vector is copied into another vector that the function result is assigned to. For a short times, two complete objects must exist, because the language said so, even though the programmer had no intention for this to happen; the second unwanted object will be deleted very soon. Move constructors make this a lot more efficient: You have created one object that you wanted to create. This is move-constructed into the return value; at no time do you have two “real” objects, the object basically just moved to a different location in memory. And the same when assigned to a variable.
If you have objects that only can exist once and cannot be copied, then a move constructor is very helpful. It allows for a function to build and return such an object. Or it allows such objects to be stored in a vector where resizing the vector otherwise needs copies to be made. (That’s all situations where you didn’t really want to copy an object, it just happens temporarily).