CodeGym /Courses /C++ SELF /State “after move”: valid, but ...

State “after move”: valid, but ...

C++ SELF
Level 46, Lesson 2
Available

1. Introduction

When you first hear the phrase “an object after move is in a valid but unspecified state”, you might ask: “Is that like ‘alive but without a passport’?” In general — yes. The standard library formulates this as a separate contract: moved-from objects must remain valid, but their exact contents are not promised. Even in the standard drafts this is presented as a distinct idea (lib.types.movedfrom) and it is emphasized that this is a common principle for the moved-from state.

Let's break the phrase into two parts.

“Valid” means: the object is still a correct object of its type. Its destructor can be called safely. It can be assigned/reinitialized, and it is required to “come back to life” normally.

“Unspecified” means: you must not build program logic on what exactly remains inside. It may be “empty”, it may be “almost as it was”, it may be “something in between” — and this is not a bug but an allowed freedom of implementation.

A very important practical conclusion: after move you cannot use the object as a source of data, but you can use it as an object that you can destroy, clear, assign a new value to, etc.

2. Moved-from using std::string as an example

If you learned from examples where std::string becomes an empty string after std::move, that's fine: many implementations do this because it's convenient. The only problem is: this is not a promise, it's a “popular implementation habit”.

Consider an example that always compiles, but the output may vary:

#include <iostream>
#include <string>
#include <utility>

int main() {
    std::string a = "hello";
    std::string b = std::move(a);

    std::cout << "b = " << b << '\n';               // b = hello
    std::cout << "a.size() = " << a.size() << '\n'; // may be 0, or may not be 0
}

And this is where the grown-up life begins.

a after moving:

  • is not required to be empty;
  • is not required to have size() == 0;
  • is not required to print as an empty string (although often it will).

But it is required to remain an object to which you can, for example, assign a new value:

#include <iostream>
#include <string>
#include <utility>

int main() {
    std::string a = "hello";
    std::string b = std::move(a);

    a = "reused"; // this must be correct
    std::cout << "a = " << a << '\n'; // a = reused
}

This is what “validity” means: the object can be returned to a normal working state by ordinary operations of its type.

3. How to define a moved-from state in your own type

If std::string can afford to “not specify” its contents, your custom type, especially for learning, can be much friendlier. It is good engineering practice to turn a moved-from object into a clear empty state and maintain its invariants.

We will continue our instructional line with a manual resource (a char array), but we will think not only about move, but about the fact that after move our object should remain fit for use.

Invariants of the empty state

Good practice: choose simple invariants that are easy to check by eye.

For example, for a text buffer:

  • if data_ == nullptr, then size_ == 0;
  • if size_ == 0, then data_ is either nullptr or points to a buffer with '\0' (but it's better to choose one variant and stick to it).

We will choose the simplest pedagogical variant: empty object = data_ == nullptr and size_ == 0.

This is also convenient because delete[] nullptr is safe: so the destructor will be simple.

Mini-class Text: move makes the source empty

Skeleton (without fancy optimizations, but predictable):

#include <cstddef>
#include <utility>

struct Text {
    std::size_t size_{0};
    char* data_{nullptr};

    Text() = default;

    explicit Text(std::size_t n) : size_(n), data_(new char[n + 1]{}) {
        data_[n] = '\0';
    }

    ~Text() { delete[] data_; }

    Text(Text&& other) noexcept : size_(other.size_), data_(other.data_) {
        other.size_ = 0;
        other.data_ = nullptr;
    }

    Text& operator=(Text&& other) noexcept {
        if (this == &other) return *this;

        delete[] data_;
        size_ = other.size_;
        data_ = other.data_;

        other.size_ = 0;
        other.data_ = nullptr;
        return *this;
    }
};

Note: move operations do not “leave things there as they happen”. We explicitly bring the source to an empty state. That makes the moved-from state not only valid, but also understandable.

4. What you can do with an object after move

When someone tells you “valid but unspecified”, your brain usually asks: “Does that mean ‘you can do anything at your own risk’ or ‘you can do nothing’?” In practice it's the third option: you can do what does not rely on the meaning of the old data.

Let's summarize in a table. (This is not compiler magic but programmer discipline.)

Action with a moved-from object Usually allowed? Why
Destroy it (exit scope) Yes The destructor must be safe
Assign a new value Yes Part of “validity”
Move from it again Yes Moving from an empty one is usually fine (result will be empty)
Call clear() / bring to empty state Yes This does not require old data
Read contents and expect semantic meaning No Contents are unspecified
Do if (x == "old") (if there were ==) No Comparison with “old” is meaningless
Call methods that require “non-empty” (e.g., front()) Unsafe If the object is empty, preconditions are violated

The key idea here is: after move you can treat the object as a “container without guaranteed contents”.

5. Invariants and “semi-empty” objects

A very common beginner mistake is to implement move so that the source becomes “almost empty” but not quite. This is worse than just “unspecified”, because then you are creating a trap for yourself.

For example, this is bad:

Buffer(Buffer&& other) noexcept : size_(other.size_), data_(other.data_) {
    other.data_ = nullptr;
    // forgot to zero other.size_
}

Why is this bad? Because other ends up in the state:

  • data_ == nullptr
  • size_ > 0

Now any code that trusts size_ may try to do something and crash. This is no longer “unspecified”, it's simply poorly designed.

Let's add small methods to our Text that rely on invariants, thus making the empty state “official”:

#include <cstddef>

struct Text {
    std::size_t size_{0};
    char* data_{nullptr};

    bool empty() const {
        return size_ == 0; // by our contract this is consistent with data_ == nullptr
    }

    void clear() {
        delete[] data_;
        data_ = nullptr;
        size_ = 0;
    }
};

It is important that empty() checks size_, and clear() brings both fields into a consistent state. This is a direct step toward making the moved-from object not only valid but also “properly empty”.

6. How not to use an object after move in an application

Imagine (in the spirit of our previous instructional examples) that we have a console “notebook” application where a note consists of a title and a body. We do not build a complex OOP architecture now, just show that a user-defined type can move into a container, and the source can later be reused.

Let's make a minimal model:

#include <utility>

struct Note {
    Text title;
    Text body;
};

int main() {
    Note n;
    n.title = Text{5}; // roughly "space for 5 chars"
    n.body  = Text{20};

    Note saved = std::move(n); // n is now moved-from

    // reading n.body.size_ as "there are 20" is no longer valid
    n.title = Text{3}; // but you can reinitialize and use it again
}

The point here is habit rather than functionality:

  • as soon as you wrote saved = std::move(n), n is no longer a data source;
  • but n remains an object that you can use after reinitialization/assignment.

To cement this intuition, it's useful to keep in mind such a “lifecycle diagram”:

stateDiagram-v2
    state "Normal" as N
    state "MovedFrom" as M

    [*] --> N: created and filled
    N --> M: std move x
    M --> N: assignment or clear and refill
    N --> [*]: destructor
    M --> [*]: destructor

The point is that moved-from is not “broken” or “half-dead”. It's simply a state where the meaning of old data is not guaranteed.

7. Why the standard chooses “valid but unspecified” and how to live with it

Why the standard chooses “valid but unspecified”

This part sounds philosophical, but in practice it's very useful. If the standard required that a moved-from object must, for example, be empty, implementations would sometimes have to do extra work to bring it to a specific form.

The wording “valid but unspecified” gives implementations the right to:

  • make moving as cheap as possible;
  • leave internal structures in any correct state;
  • avoid supporting extra guarantees where they get in the way.

In committee documents this is even emphasized as a general principle: the moved-from state is described as “valid but unspecified” and is put into a separate library provision.

For you (as the author of a user-defined type) this means: you can do the same, but in educational and application types it is often better to choose a friendlier contract — “after move the object is empty”.

Note the nuance: “empty” is already your specification. You have the right to make the moved-from state more specific than the minimum. The main thing is not to promise something you do not keep.

Minimal set of operations for a moved-from object

To make your types convenient, mentally check this minimal set:

  1. the object can be destroyed in any state
  2. the object can be brought to an empty state (explicitly or implicitly)
  3. the object can be assigned a new value (by copy or move)
  4. methods that do not require data (for example empty()) always work

This will sound boring now, but it's like a seat belt: you rarely thank it aloud, but it makes life longer.

We already implemented clear() and empty() for Text. Let's add a “safe access” to data: if the object is empty, return "" (an empty C-string). This is not an ideal design for all cases, but for a learning type it's very illustrative:

struct Text {
    std::size_t size_{0};
    char* data_{nullptr};

    const char* c_str() const {
        return data_ ? data_ : "";
    }
};

Now even a moved-from Text can be printed (even if its meaning is not guaranteed — at least it won't crash).

8. Typical mistakes when working with moved-from state

Mistake #1: using an object after std::move as if nothing happened.
The most common scenario looks like this: you moved x into y, and then you read x and logically rely on its “old” data. The code may “work” on your machine, but then suddenly change behavior on another library or after a compiler update, because the content of moved-from is not required to match expectations.

Mistake #2: designing the moved-from state inconsistently.
If your type has related fields (size and data), and after move you zeroed only one of them, you get an object that is formally “alive” but any function that trusts invariants may break. A correct move almost always ends with bringing the source to a consistent “empty” state.

Mistake #3: writing methods that don't tolerate the empty state and not documenting preconditions.
Suppose the type has methods front() or at(0). If the object can become empty after move, these methods must either handle that correctly or have a clear precondition “object is not empty”. The beginner version is “it will somehow work”, and “somehow” in C++ usually means “at 3 a.m.”.

Mistake #4: relying on printing/logging a moved-from object as a correctness diagnosis.
Sometimes you want to do std::cout << x and check that after move x became empty — and consider that proof of correctness. This is a bad test: you check a private case of a specific implementation, not the contract. It's much better to check invariants: “does not crash”, “can be assigned again”, “can be destroyed”, empty() is consistent.

Mistake #5: promising more than you actually guarantee.
You can make the moved-from state “empty” and document it as part of your contract — that's great. But you must not write in comments “after move data is preserved” and then expose internals. In C++ a comment is not a compiler, but bugs are read by a future human (and they will be unhappy).

1
Task
C++ SELF, level 46, lesson 2
Locked
A note in transit
A note in transit
1
Task
C++ SELF, level 46, lesson 2
Locked
Moving the inventory
Moving the inventory
1
Task
C++ SELF, level 46, lesson 2
Locked
Parcel Buffer
Parcel Buffer
1
Task
C++ SELF, level 46, lesson 2
Locked
Text Without Surprises
Text Without Surprises
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION