Today, I was watching the talk “On coding guidelines, class invariants, and special member functions” 1 by Olivia Wasalski. It’s an excellent talk on issues surrounding moved from objects and the “valid but unspecified” state. Go watch it! In this post I’ll share my thoughts on what “valid but unspecified” should really mean.

In C++, what can I do with a moved from object? The standard says that moved from objects must be in a “valid but unspecified” state, which means that you must be able to call all member functions that have no preconditions. This does not tell you very much, since the author of the type can specify the preconditions as they like.

But crucially, to be able to use objects of your type with algorithms from the standard library, a moved from object rv must

[…] still meet the requirements of the library component that is using it. The operations listed in those requirements must work as specified whether rv has been moved from or not. 2

…which is a slightly stronger requirement.

But what does it mean in practice? In this post I’ll argue that a moved from object must be at least:

  • copy/move assignable to
  • destructible
  • copy/move assignable from
  • equality comparable
  • comparable with the relational operators (<, >, <=, >=, <=>)
  • hashable

These are all the operations every regular type must support 3. Your type may give stronger guarantees for moved from objects, but these operations are the minimum.

What all those operations have in common is that they are (or conceptually can be) implemented member-wise and be generated by the compiler.

An Example

I’d argue that for the following type, the compiler generated move operations are correct:

// Class invariant: `s()` returns a representation of the number `i()` in
// English. Example:
//
// - `i()`: 5, `s()`: "five"
// - `i()`: 42, `s()`: "forty-two"
//
struct number_with_string {
  explicit number_with_string(int i);

  const int& i() const { return i_; }
  const std::string& s() const { return s_; }

  bool operator==(const number_with_string&) const = default;
  auto operator<=>(const number_with_string&) const = default;

private:
  int i_;
  std::string s_;
};

// Some specialization of `std::hash`...

When an object of this type is moved, let’s say i(): 42, s(): "forty-two", calling i() on the moved from object will still return 42 while s() may return any “valid but unspecified” std::string object. Therefore, a moved from object does no longer satisfy our class invariant. However, all copy/move/assignment/comparison/hash operators still “work”, as in that they are defined and give deterministic results.

This is because the compiler generated versions of those operations are member-wise. The compiler doesn’t care about any semantic link between i() and s() which we, as class authors, may have defined.

Of course, those operations don’t have any meaning, but sometimes badly written generic library code may call for example operator< on a moved from object, ignore the result of the comparison, and in the end still deliver a correct result. I’d consider an implementation like this buggy. But as of 2025 the standard blesses this behavior, so your types should support it.

What’s nice about this is that we didn’t have to write any extra code: These operations “just work”. In other words, “valid but unspecified” composes nicely when restricted to the “regular operations”.

Writing the “Regular Operations” Manually

For the “Rule of Zero” to “just work” when composing higher level types, the types at the lowest level must be able to deal with invalid states.

For example, std::indirect must be able to compare objects that are valueless_after_move() 4. In early drafts during the standardization process this was considered undefined behavior.

It also means that every time you write copy/assignment/comparison/hash operators yourself you must consider all invalid states of your object. The behavior does not have to be meaningful, but it should be well-defined.

Invariants

Herb Sutter argued 5 that move operations are just like any member function, and thus must preserve the class invariants.

You can easily meet this requirement by including all moved from states in your class invariants. Your “true” invariants you can then call the “desired invariants” 6.

The “desired invariants” are really important when reasoning about code. For example, every function should have an implicit precondition that its arguments meet the “desired invariants” (unless otherwise specified):

int f(const number_with_string& n) {
  // ...can assume that `n` really is valid and not moved from.
}

To prevent spread of invalid objects through your program you should employ local reasoning. The canonical example is std::swap – it moves from an object, leaving an invalid object there. As the next step, the algorithm restores validity by move assigning into the invalid object:

template <class T>
void swap(T& a, T& b) {
  T tmp = std::move(a);
  a = std::move(b);      // `b` is now invalid
  b = std::move(tmp);    // meaning is restored into `b`
}

The Case of std::chrono::year_month_day

Invalid object states are not limited to the “moved from state”, but can happen even with “normal” operations.

C++ is about leveraging the physical reality of the machine in order to make operations more efficient or even more ergonomic – even if this sometimes results in invalid states. One particular extreme example is year_month_day from std::chrono.

Almost any arithmetic operation on this type can result in an invalid date, just like std::move can on our number_with_string above:

  using namespace std::chrono;

  std::chrono::year_month_day d = {2020y, std::chrono::January, 31d};
  d += std::chrono::months{1};

  // outputs: 2020-02-31 is not a valid date, false
  std::println("{}, {}", d, d.ok());

Since year_month_day is implemented with three integers under the hood, it can physically represent invalid dates. You can then ask if the date is valid with the ok() function.

To make the date valid again, you can do one of two things, depending on your use case:

  • Clamp the date to the last of the current month (2020-02-29)
  • Roll over to the next month (2020-03-02)

Again, it is helpful to think in “desired invariants” here. year_month_day is meant to represent valid dates, so you should not have to check for ok() everywhere. Invalid date objects should be contained in small areas of your code where you can reason about them locally.

Exceptions

Objects can also be left in a valid but unspecified state when an exception is thrown. If the compiler generated copy assignment operator of our number_with_string class throws an exception when trying to copy assign the std::string, the result object will be left in an inconsistent state where the int already was assigned. The “desired invariants” are broken.

As before, you must either restore meaning into the object or destroy it.

Value-Initialization

There is the concept of “value-initialization” in C++:

  std::chrono::year_month_day d{};

What are the required postconditions of value-initialization? That d is now a valid value of the type? The answer is “yes” if by “valid” you mean “meets the invariants of the type”. The answer is “no” if by “valid” you mean the stronger concept of “meets the desired invariants of the type”.

For example, in the case of year_month_day we get an invalid date after value-initialization:

  std::chrono::year_month_day d{};

  // outputs: 0000-00-00 is not a valid date, false
  std::println("{}, {}", d, d.ok());

As a consequence, you cannot assume in general for any type T that after resizing a std::vector<T> to a larger size the newly constructed, value-initialized elements have any meaning, besides supporting the “regular operations”.

Conclusion

Invalid object states are a fact of life in C++ and must be managed by employing local reasoning.

Types at the “lowest level” must be prepared to deal with invalid states in copy/assignment/comparison/hash operators, such that composition of higher level types using the Rule of Zero “just works”.


  1. https://www.youtube.com/watch?v=IuGzAvD7KdQ 

  2. https://eel.is/c++draft/utility.requirements#tab:cpp17.moveconstructible 

  3. See https://stepanovpapers.com/DeSt98.pdf. Yes, the newer standards dropped the relational operators from the “regular” concept. This doesn’t change the fact that in theory, every type has an ordering. If there is no “natural” order like for numbers, you could order the byte representation. In Elements of Programming this is called the “default total ordering”. 

  4. https://en.cppreference.com/w/cpp/memory/indirect/operator_cmp.html 

  5. Move, simply 

  6. See 1:23:25 onwards from Sean Parent’s presentation “Exceptions the Other Way Round” (https://youtu.be/mkkaAWNE-Ig?t=5005