-
Notifications
You must be signed in to change notification settings - Fork 73
MoveOperatorsNeverThrow
#Move operators and constructors should not throw
Prefer to make move operators and constructors never_throws
.
##Rationale
This rule should follow naturally from our use of exceptions and move operations, because:
- move constructors operators should not allocate new resources
- the caller is expecting resources to be moved from one object to the other. It's not expecting new resources to be allocated or duplicated. Copy constructors/operators can be used in those cases.
- If there are no resources being allocated, there should no reason to throw
- exceptions should come from IO errors, or low level API errors, or formatting errors related to input data
- these types of errors shouldn't occur in move operators.
- (coding errors should just go to assert or a crash)
- a move operator may result in destruction of objects
- but again, destruction should generally not result in an exception
So a move operator that is functioning correctly should not throw anyway. But there a few reasons why explicitly marking move operators and constructors as never_throws is a good idea.
###Partially moved objects
Consider the following:
namespace BadExamples
{
Foo& Foo::operator=(Foo&& moveFrom)
{
_member0 = std::move(moveFrom._moveFrom0);
_member1 = std::move(moveFrom._moveFrom1);
// let's imagine there's some case that might
// throw in the middle of this operator...
if (!_member1) {
ThrowException(::Exceptions::BasicLabel("Bad exception"));
}
_member2 = std::move(moveFrom._moveFrom2);
return *this;
}
}
Consider what happens if we catch that exception? We succeeded in moving _member0
and _member1
, but not _member2
. That leaves both this
and moveFrom
in very strange, partially-constructed states. One or both objects are likely to become unstable and cause a crash later on.
Why would we want to do that?
I suppose we could reorder like this:
namespace BadExamples
{
Foo& Foo::operator=(Foo&& moveFrom)
{
if (!moveFrom._member1) {
ThrowException(::Exceptions::BasicLabel("moveFrom does not meet move operator preconditions"));
}
_member0 = std::move(moveFrom._moveFrom0);
_member1 = std::move(moveFrom._moveFrom1);
_member2 = std::move(moveFrom._moveFrom2);
return *this;
}
}
But in this example, we're saying that moveFrom
is in some invalid state and that it is not a valid candidate for moving. But why would any object not be a valid candidate for moving?
That could potentially be caused by a coding error. But if it's a coding error, we should use assert
, not an exception.
If we mark move operators and constructors as never_throws, then any exceptions will result in a call to std::terminate()
-- which is probably preferable behaviour.
###Objects in STL containers
There is a complex problem related to exceptions from move operators and STL containers. As I understand, this problem is actually the reason for the noexcept
keyword in C++11.
The description here gets a little complex. Let's imagine an std::vector. We want to write the resize() method for class. Our pseudo-code might look like this:
// (note -- the real std::vector::resize is slightly different! Just for demo!)
template<typename Type>
void std::vector<Type>::resize(unsigned newSize)
{
if (newSize > _currentSize) {
auto newMemory = std::make_unique<uint8[]>(newSize * sizeof(Type));
MoveExistingObjects((Type*)newMemory.get(), (const Type*)_currentMemory.get(), _currentSize);
FillUninitialised(
(Type*)PtrAdd(newMemory.get(), _currentSize * sizeof(Type)),
newSize - _currentState);
_currentMemory = std::move(newMemory);
_currentSize = newSize;
} else {
... // (not interested in this case)
}
}
template<typename Type>
void MoveExistingObjects(Type dst[], const Type src[], size_t count)
{
for (size_t c=0; c<count; ++c) {
new(dst[c]) Type(std::move(*src[c]));
}
}
Here, when we want to make the vector bigging, we need to resize the memory block underlying the vector.
We want to initialize the new memory with the contents of the old memory block (plus some uninitialised objects in the newly allocated size).
Obviously, it's natural to use a move constructor in this case. The old memory will just be discarded. So "move" makes more sense than "copy." Here, we're invoking move constructor via the placement new.
But there's a problem. Lets imagine we're moving 4 objects. What happens in an exception occurs while moving the 2nd one?
Let's imagine that:
new(dst[0]) Type(std::move(*src[0]));
new(dst[1]) Type(std::move(*src[1]));
new(dst[2]) Type(std::move(*src[2])); // EXCEPTION HERE!!
new(dst[3]) Type(std::move(*src[3]));
In this case, elements 0 and 1 were moved correctly. Element 2 is in some unknown (probably dangerous state). Element 3 was never moved at all.
Stack unwinding will leave std::vector<>::resize. _currentMemory
and _currentSize
never get updated. So _currentMemory
still points to the original array. This array now contains the follow:
- element[0] -> move completed, so this is cleared down
- element[1] -> move completed, so this is also cleared down
- element[2] -> exception in move, unknown state
- element[3] -> no move, this is fine
So now our vector array is in really bad state. This is a problem, because we want std::vector<>::resize() to have a strong exception guarantee. The strong exception guarantee means the object should return to it's previously state when an exception occurs.
So, our goal would be to return the array in the vector to it's original state. This is easy when using a copy constructor, because the copy constructor doesn't modify the source.
Imagine we'd done this with copy constructors:
new(dst[0]) Type(*src[0]);
new(dst[1]) Type(*src[1]);
new(dst[2]) Type(*src[2]); // EXCEPTION HERE!!
new(dst[3]) Type(*src[3]);
Even after the exception occurs, the src array has never been changed. So when the stack unwinds out of std::vector<>::resize() we are left with a vector that is still valid (but still the old size). That meets the strong exception guarantee that we want to achieve.
However, in the move constructor case, it's impractical to achieve this same result.
####Achieving a strong guarantee
The standard library solves this problem by making the following rule:
- if the
value_type
of a container has anoexcept(true)
move constructor, then move constructor is used - otherwise, the copy constructor is used
This is done using a mechanism like std::move_if_noexcept
. This is implemented using a compiler featured called "Substitution failure is not an error (SFINAE)."
All implicit compiler-generated move constructors are noexcept(true).
But, if we explicitly write the move operator, then we should also explicitly mark it as noexcept(true).
In XLE, we use the never_throws
macro for compatibility.
In other words:
- by marking a move constructor as
never_throws
we can make sure the compiler will use the move constructor during STL container operations (and not the less efficient copy constructor)