std::move Is Not Destructive Move - mcalabrese/cpp-stuff GitHub Wiki

[Butting into Sean Parent's discussion on move.]

For context, this is in reply to Sean Parent's statements on twitter and in the following: https://github.com/sean-parent/sean-parent.github.com/wiki/Reply-to-Twitter-Conversation-on-Move

https://github.com/sean-parent/sean-parent.github.com/wiki/Non-Proposal-for-Destructive-Move

First, before moving ahead, I'm going to put my stake in the ground and state without a doubt that:

  1. What Sean refers to as letting an object's invariants be violated after a current C++ move operation is nothing of the sort. What he describes is actually a weakening of the invariants of a type to include a null state, and introduce more pre-conditions to functions that deal with that type (namely that the object is not in a moved-from state). Regardless of his personal interpretation of an object's lifetime, in C++, an object is alive until its destructor is called. Period. End of story. Further, a type's invariants are held for the duration that the object is alive with respect to its public interface. This is precisely what makes them useful and able to be relied upon. A current C++ move operation does not invoke the type's destructor and so it is, quite simply, not a destructive move and cannot be thought as such. It is a mutating move. This is what we have to work with and we cannot pretend that it is something else. A moved-from object in C++ is alive and so its invariants must still be held for the time that it is alive. Putting the object into a destroy-only state after a current C++ move is, again, just a weakening of the invariants to include a null state; nothing more, nothing less.

  2. When pushed on the topic during CppNow, Sean actually only advocates that destruction is simply the minimum requirement of an object after a current C++ move operation. He does not make the more-strict claim that it would be erroneous for the creator of a type to further specify what can be expected from the object. In other words, it's perfectly fine to specify what state the object is in and/or allow it to be used after a move operation. What should be pointed out here is that when he makes this relaxed claim -- that destructibility is simply the minimum requirement after a move -- he has effectively stated absolutely nothing at all! The reason that he has stated nothing is because destructibility is always the minimum requirement of any function that deals with an object unless it internally invokes destruction via an explicit destructor call or a call to delete, etc. In other words, he's simply stated what we already know: a C++ move is a function like any other. What further can we expect? It's up to the user to decide.

So, while presented in a provocative manner, Sean's statements are not quite as controversial as one might think. However, the actual implications are still somewhat troubling. What he advocates is weakening the invariants of a type, whether he chooses to admit it or not. The side-effect of this is that types such as Eric's non_null_unique_ptr example either must not have the invariant of the object not being null (rendering it effectively pointless, pardon the pun), or such types are simply not movable with respect to the current C++ move.

All of that stated, I will go on to present a disclaimer much in the same vein as Sean's: A lot of what I am about to state is off the top of my head or based on only a relatively small amount of mulling. As well, I'm aware that Sean does not currently advocate bringing truly destructive moves into the language, but the following is a critique of his "Non Proposal" for destructive moves, some of its flaws, and why some of the complexities that it presents are illusory.

We Want Destructive Moves

One thing that I think we all agree on is that in an ideal world, what we'd have are actual destructive moves. For all of the reasons already stated, and that I will not bother repeating here, truly destructive moves can be more efficient and can allow developers to create types that have move operations which do not prevent us from having types with weakened invariants.

What Is a Truly Destructive Move?

A truly destructive move, as the name suggests, is itself a destruction operation that ends the life of an object as far as C++ is concerned. With that, there is a notion amongst some (most?) people involved in this conversation that if we have truly destructive move then we'd be introducing a double-destroy problem that does not currently exist in C++, which would come from the desire to move from an object mid-scope. I claim that this is an entirely erroneous notion that has emerged simply because people are used to move-semantics as we know them in current C++. I will go into this more later in this entry...

Sean Isn't Entirely Wrong :)

As critical I am of Sean's interpretation of object lifetimes and class invariants (and it deserves noting that it is rare that I disagree with what Sean has to say), he does present some ideas on this topic that I do agree with. For one, he presents a solid syntactical declaration of destructive move functions. I will, however, augment his ideas with some additional statements about when and how they are invoked at a call site.

Implicit Destructive Moves

First, we have the implicit cases that Sean has presented, which I agree with as being mostly the proper set:

  1. construction from true temporaries (I.E. not simply something we have an r-value reference to)

  2. return statements ...

What I have intentionally left out because I'm not [yet] convinced it is correct, however, is his claim that it is safe to destructive-move from any object that is no longer explicitly referenced as deduced through some type of analysis by the compiler. Destruction order is often important in C++ even when code does not apparently reference an object that is being destroyed (we do this with locks all the time, for instance). That said, we do get by fine with RVO and NRVO, which effectively permits altering the apparent scope of an object as an optimization, so it could be that there is still some sensible route forward with this idea as well, though I need further convincing before I fully accept it. It is not immediately obvious to me that this is a place where we can freely assume that it is okay to shorten or lengthen the lifetime of the object or its moved-to counterpart, though it would be interesting if that could be shown to be the case.

What I also left out is implicit placement-new when assigning into moved-from objects. This is completely unnecessary not to mention complicates the language and conflicts with general expectations that we have from current C++. It also has a potential implementation penalty. So what should we expect here? Well, assigning into a truly destructive-moved-from object should be UB just like assigning into an explicitly destroyed object in current C++ is UB. We do not expect foo.~T(); foo = T(); to be valid, why would we expect assigning to the destroyed object to be valid simply because it was move-destroyed from as opposed to plain destroyed? The language doesn't and shouldn't have to do extra bookeeping. The perception that it will is just flat-out wrong.

Explicit Destructive Moves

In addition to the above places where a destructive move can implicitly take place, I propose a method for explicit invocation of a destructive move:

If a destructor-invocation expression is used as an argument to a constructor, whether it's a regular constructor call or a new operation, then we get a destructive-move. For example:

// Note: This T declaration comes from Sean's example
//       and retains similar meaning.
class some_class
{
  public:
    ~some_class(some_class&& source);
    some_class& operator=(some_class source);
};

int main()
{
  some_class a;
  some_class b = a.~some_class(); // This is a destructive-move
  some_class c;
  c = b.~some_class(); // This is a destructive assign
  T d;
  e.~some_class(); // A regular old C++ destructor call with no move

  // ...
  // Forget about "double destruction" for a moment for this example.
  // Pretend we re-constructed our destroyed objects before they went
  // out of scope. I will address that "issue" shortly.
  // ...
}

The above syntax has some advantages, in my opinion. For one, it very clearly in code expresses what we are doing, which is destroying the object. We should not expect it to be safe to do this mid-scope without reconstructing a new object, just like we wouldn't with any other destructor call. As well, it allows for this functionality without introducing any additional functions or keywords to the language and without breaking backwards compatibility (as far as I can tell so far).

You will notice that I do not mention destructive-move from rvalue-references, which is what Sean recommends... this is intentional and I am not convinced that this is a good idea, but I'll leave that for another post for now.

"Tombstones," std::kill and unsafe_move Are Not Needed

As mentioned earlier, Sean and some others lean towards destructive move implying the need for some type of checking by the compiler and/or run-time for preventing double-destruction at the point where an object goes out of scope. I claim that this is completely untrue, not to mention that it would introduce a plethora of implementation concerns. Why this is a non-issue is for the same exact reason that we do not need such tracking when explicitly invoking a destructor via a call to ~T in C++ as it is today.

In real-life code, if you explicitly destroy an object, whether via a regular explicit destructor call or via a destructive move (which is a destructive operation), you will have either been dealing with raw storage that you had done placement-new on, or it is your responsibility to reconstruct an object in its place before the named object goes out of scope. This is and has always been true of any destructor call already and destructive move does not change this. As a quick example of usage of a destructive move, consider what a std::swap implementation would look like with truly destructive move operations and without the need for the language to do some kind of tracking:

// In the following example, assume raw_storage is a template that
// corresponds to properly aligned and sized raw storage for a type
// "T" to be used with placement new, and that, as a convenience,
// overloads unary * and -> for reinterpret_casts to the storage as T&.

// Swap with destructive move -- no double destroys (assume all noexcept)
// Assume T is non-const and appropriate ops are noexcept
template< class T >
void swap( T& left, T& right )
{
  raw_storage<T> tmp; // A pod that contains aligned raw storage for T
  new (&tmp) T( left.~T() ); // Destructive-move left into tmp raw storage
  new (std::addressof(left)) T( right.~T() ); // Destructive move right to left
  new (std::addressof(right)) T( tmp->~T() ); // Destructive move tmp to right
  // Note that there is no double-destruction problem here.
}

In comparison to Sean's example, this requires no changes to management of implicit destructor calls in the language. In fact, its impact to the language is actually rather minimal.

Okay, so that example does not need the compiler to do any magic tracking. What about more general cases? For many of those, we have a partially existing solution: optional (albeit slightly augmented to be destructive-move aware)

template< class T >
class optional
{
  // an optional implementation as we know it along with...

  // A "release" that does a destructive-move out
  // and sets the optional to "none"
  T release() // Generally noexcept
  {
    T result = (*this)->~T();

    ////
    // ...optional-implementation-detail-set-to-none-goes-here...
    /// 

    return result;
  }
};

int main()
{
  optional<T> bar = T();
  fun( bar.release() ); // Destructive move
  assert( !bar );
}

So with optional we get the ability to get many of the benefits of "mid-scope" moving when we need it and without weakening the invariants of our type or introducing some "std::kill" type of functionality.

What about places where you don't have raw storage, you don't have an optional, you don't want to reconstruct a new object in-place... but you still want to move mid-scope? I'd argue that here you simply do not want a destructive move and it is as simple as that. Further, short of rewriting the standard library right now or producing a proof, I am relatively confident that it could be written in a way using destructive moves as presented without running into some kind of a mid-lifetime move "issue." I leave that as a task for another day and admit that it is possible that I may be proven wrong here, as this is coming from a relatively short amount of time thinking about this. If anyone has an immediate counter-argument, I'd be interested in hearing it.

⚠️ **GitHub.com Fallback** ⚠️