Valid But Unspecified
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
rvhas 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”.
-
https://eel.is/c++draft/utility.requirements#tab:cpp17.moveconstructible ↩
-
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”. ↩
-
https://en.cppreference.com/w/cpp/memory/indirect/operator_cmp.html ↩
-
See 1:23:25 onwards from Sean Parent’s presentation “Exceptions the Other Way Round” (https://youtu.be/mkkaAWNE-Ig?t=5005) ↩