c++move-semantics

Can a moved from object be moved again?


In C++, a moved object is left in a valid but unspecified state, which is basically a way to say you can assign to it and destroy it. But, can the object also be copied and moved?

For example, if I have an object in a container, and I get a reference to the object, and then move it to some local variable, the container then contains a moved object (unspecified state). Let's say I decide to leave the moved from object inside the container "for a bit". If the container needs to "move the object to a new location" (e.g., a vector performing a realloc), is it safe to let the object be moved again?


Solution

  • In C++, a moved object is left in a valid but unspecified state, which is basically a way to say you can assign to it and destroy it. But, can the object also be copied and moved?

    You have to understand that this is a convention. The std library provides types that behave in certain ways when moved-from, and makes certain assumptions about types it interacts with when moved-from. It describes both the behavior of its own types, and types it interacts with, in standardese.

    From this, C++ programmers have generally agreed that there is a convention on how moved-from objects should behave in order to keep things sane. The C++ language itself makes no demand on these conventions being followed, especially if you don't use the C++ std library with the types.

    This is valid C++:

    struct evil_type {
      evil_type( evil_type&& other ) {
        other.~evil_type();// destroy the moved-from object
      }
      ~evil_type();
    };
    

    by convention, you shouldn't do this. It makes the following code generate UB:

    {
      evil_type a;
      evil_type b = std::move(a);
    }
    

    because we destroy the object a when we move-from it, then we end the scope it exists in, which tries to destroy an already destroyed object.

    However, this is just a reason to obey the conventions about moved-from types. It does not demand that we do certain things.

    Meanwhile, the std provides guarantees for various types; often these are spelled out in excruciating detail, and sometimes indirectly. Some containers may guarantee that when construction-moved-from they are empty while leaving their state unspecified when assignment-moved-from.

    All std types remain in a state that is valid to destroy and to assign over when moved-from. Some give additional guarantees.

    All std containers describe the requirements on their contents, such as semiregular. The requirements that std container place on their contents have both syntactic requirements and semantic requirements.

    For example, if I have an object in a container, and I get a reference to the object, and then move it to some local variable, the container then contains a moved object (unspecified state). Let's say I decide to leave the moved from object inside the container "for a bit". If the container needs to "move the object to a new location" (e.g., a vector performing a realloc), is it safe to let the object be moved again?

    So, to answer this question, you'll have to look at the containers requirements that they place on their contents. In some containers their contents are const (std::set, the key part of std::maps), so the state of the moved-from object isn't unspecified (or fully unspecified) (and beyond being const, they also require that the ordering (or hashing/comparison) not be changed).

    If you have an object within a container, and you use a method of that container, that method is going to have preconditions on the properties of the contained objects. If you violate said preconditions, you have UB. When you put an object of some type within a container, the compiler doesn't check that all methods in all situations are valid to invoke.

    For example, if I have an incomplete type Foo, I can create a std::vector<Foo> but I cannot interact with it in almost any way, because almost every method of Foo (including destruction) requires the type to be complete.

    What this also means is you can have a std::vector<InsaneType> for whom most of the operations of std::vector lead to UB or an ill-formed program; and possibly you can even have a non-empty std::vector of some insane type. Then you move-from an element, and as the type is insane moving from it breaks something else fundamental.

    Is there a reason to do this? Not really. The point I am trying to make is that the fact you have a type in a vector in an otherwise standard compliant program provides very little information about what that type is and what you can do with it.

    By convention, we don't put insane types in vectors. We put semiregular types with sane move operations and the like. So by convention, a moved-from object is in some valid state that shouldn't break if you == or move from it again or copy it.

    The downside to this is that, by convention, when we have a pImpl type class:

    struct PublicPart {
      std::unique_ptr<PrivatePart> pImpl;
    };
    

    either we have to ensure that the PublicPart API supports a nullptr pImpl, or when we move-from PublicPart we create a "stub" pImpl that makes the object continue to be "valid".

    And sometimes we break this convention.