C++ – Choosing Between Non-Copyable Non-Movable and Move-Only Types

c

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?

Best Answer

The way to look at this is not a matter of copy vs. move vs. immobility. It's two separate questions:

  1. Mobility vs. immobility. Do you want it to be able to transfer the contents of the object to another object in some way?

  2. 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.

By contrast, 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.

Related Topic