C++ / MOVE SEMANTICS AND SPECIAL MEMBER FUNCTIONS
Moved-from states and what you may still do
Reason precisely about what a moved-from object is: which calls stay safe, which types promise more, and how to restore a known state.
What you will learn
- A moved-from object is still alive; its destructor still runs at end of scope.
- Call only precondition-free members on it: size(), empty(), clear(), assignment.
- unique_ptr and shared_ptr become null; optional keeps has_value() unchanged.
- Restore a known value with assignment, clear() or reset() before reading it.
Understanding Moved-from states and what you may still do
A move does not unbind the name or end the object's lifetime. The source is still a fully constructed object whose destructor will run at the end of its scope, and its type has not changed at all: nothing in C++ marks a variable as moved-from. What changed is only the value, and for standard library types the standard describes the result as valid but unspecified, meaning every class invariant still holds but no particular value is promised.
That phrase draws the exact line between what you may call and what you may not. Anything with no precondition is fine, including destruction, assignment, size(), empty(), clear() and push_back, because those are defined for any legal value. Anything with a precondition, such as front(), back(), pop_back() or indexing, requires you to know the value first, and unspecified means you do not: a moved-from std::string is not required to be empty, since an implementation may copy a short string rather than steal a buffer. Testing on one compiler therefore proves nothing about another.
A few types promise more than the blanket rule, and it pays to know which. unique_ptr and shared_ptr are specified to be null after being moved from, which is why checking if (p) on a moved-from smart pointer is meaningful; std::optional keeps its engaged state, so a moved-from optional<std::string> still has a value, just a string whose contents are unspecified. When you write move operations for your own type you are choosing that contract yourself, and the useful default is empty and reusable: null the source's pointers and zero its sizes so the destructor, size(), and a later assignment all behave.
In practice this makes moved-from variables write-only until you refill them.
<iostream>
<memory>
<optional>
<string>
<utility>
<vector>
int main() {
std::vector<int> src{1, 2, 3};
std::vector<int> dst = std::move(src);
std::cout << "dst.size() = " << dst.size() << '\n';
// src is still a live object: empty() and clear() have no preconditions,
// so both calls are legal. What empty() would return is unspecified, so
// we do not print it; clear() forces a state we can rely on.
src.clear();
std::cout << "after clear, src.size() = " << src.size() << '\n';
src = {7, 8}; // assignment also restores a fully known state
std::cout << "after assign, src.front() = " << src.front() << '\n';
std::unique_ptr<int> a = std::make_unique<int>(42);
std::unique_ptr<int> b = std::move(a);
std::cout << "moved-from unique_ptr is null: " << (a == nullptr) << '\n';
std::cout << "*b = " << *b << '\n';
std::optional<std::string> o1{"payload"};
std::optional<std::string> o2 = std::move(o1);
std::cout << "moved-from optional still engaged: " << o1.has_value() << '\n';
std::cout << "o2 holds [" << *o2 << "]\n";
}
After a move the source is a valid object whose value you no longer know, so only operations without preconditions remain safe until you give it a new value.
Worked examples
The contract your own move constructor writes
For a hand-written type, the moved-from state is whatever your move operations leave behind, so make size() honest and the destructor safe.
<cstddef>
<iostream>
<utility>
class Buffer {
int* data_;
std::size_t n_;
public:
explicit Buffer(std::size_t n) : data_(new int[n]{}), n_(n) {}
~Buffer() { delete[] data_; }
Buffer(Buffer&& other) noexcept : data_(other.data_), n_(other.n_) {
other.data_ = nullptr; // documented contract: a moved-from Buffer is empty
other.n_ = 0;
}
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
n_ = other.n_;
other.data_ = nullptr;
other.n_ = 0;
}
return *this;
}
std::size_t size() const { return n_; } // no precondition
int& at(std::size_t i) { return data_[i]; } // precondition: i < size()
};
int main() {
Buffer a(4);
Buffer b = std::move(a);
std::cout << "a.size() = " << a.size() << ", b.size() = " << b.size() << '\n';
a = Buffer(2); // a was never destroyed, so assigning to it is legal
a.at(0) = 99;
std::cout << "a.size() = " << a.size() << ", a.at(0) = " << a.at(0) << '\n';
}
Example explained
Line 1Zeroing other.n_ is what makes a.size() report 0 truthfully; without it, at() would trust a size with no memory behind it.
Line 2delete[] nullptr is a no-op, so the moved-from Buffer destroys cleanly, which is the minimum any moved-from state must satisfy.
Line 3a = Buffer(2) works because the move only emptied a, and move assignment gives it a fully specified value again.
Line 4Calling a.at(0) before that reassignment would dereference a null pointer: at() has a precondition, size() does not.
Moving an element out of a container
Moving from v[i] leaves a live element with unspecified contents inside the container, so overwrite or erase it rather than reading it.
<iostream>
<string>
<utility>
<vector>
int main() {
std::vector<std::string> lines{"alpha", "beta", "gamma"};
std::string taken = std::move(lines[1]); // only the element is plundered
std::cout << "taken = " << taken << '\n';
std::cout << "size is unchanged: " << lines.size() << '\n';
lines[1] = "beta-replaced"; // overwrite the husk instead of reading it
for (const std::string& s : lines) std::cout << s << ' ';
std::cout << '\n';
std::string other = std::move(lines.back());
lines.pop_back(); // or erase it; the moved-from element still counts
std::cout << "other = " << other << ", size = " << lines.size() << '\n';
}
Example explained
Line 1std::move(lines[1]) casts one element to an rvalue; the vector itself is untouched, which is why size() is still 3.
Line 2The element at index 1 is a fully constructed std::string with an unspecified value, so assigning to it is the correct repair.
Line 3pop_back() has the precondition that the vector is not empty, and that still holds: the moved-from element occupies a slot.
Line 4Printing lines[1] between the move and the assignment would compile and run, but show whatever the implementation happened to leave.
Types with a stronger promise
shared_ptr's moved-from state is specified exactly, so you may branch on it instead of treating it as unknown.
<iostream>
<memory>
<utility>
int main() {
std::shared_ptr<int> a = std::make_shared<int>(7);
std::shared_ptr<int> b = a; // copy: two owners
std::cout << "use_count after copy: " << a.use_count() << '\n';
std::shared_ptr<int> c = std::move(a); // move: ownership handed over
std::cout << "a is empty: " << (a == nullptr) << ", a.use_count() = " << a.use_count() << '\n';
std::cout << "c.use_count() = " << c.use_count() << '\n';
a = c; // an empty shared_ptr is a normal, reusable object
std::cout << "owners after reusing a: " << a.use_count() << '\n';
}
Example explained
Line 1a.use_count() == 0 is guaranteed rather than merely typical: shared_ptr's move constructor is specified to leave the source empty.
Line 2The owner count stays at 2 across the move because ownership was transferred from a to c, not shared with it.
Line 3Unlike a moved-from vector, a moved-from shared_ptr can be tested with if (a), since emptiness is promised by the standard.
Line 4a = c shows the payoff of the strong guarantee: no clear() or reset() is needed first, and reuse bumps the count to 3.
Important notes
Valid but unspecified is a guarantee about standard library types only. For your own type the moved-from state is exactly as usable as your move operations make it, and a type with no move operations is copied, so its source is unchanged.
No compiler diagnostic tells you in general that a variable has been moved from, because its type does not change; static analysis such as clang-tidy's bugprone-use-after-move or GCC's -Wself-move catches the common cases.
Common mistakes
Assuming a moved-from std::string or std::vector is empty and then calling front() or [0]: whenever the implementation left contents behind the precondition is violated, which is undefined behaviour rather than just a surprising value.
Stealing the pointer in a move constructor without nulling the source, so both objects delete the same buffer at scope exit and you get a double free that crashes far from the real bug.
Writing x = std::move(x), or v[i] = std::move(v[j]) where i happens to equal j, and expecting the value to survive: library move assignment is allowed to leave the target valid but unspecified, silently losing data.
Try it yourself
Change, predict, then run
Build a std::vector<std::string> of three names, move v[0] into a local string, print v.size(), then assign a new value to v[0] and print all elements. Finally move the whole vector into a second one and bring the source back to a known state with clear() before printing its size again.
Open the C++ workspaceCheck your understanding
Given std::string a = "hello"; std::string b = std::move(a); which statement about a afterwards is guaranteed by the standard?
- a.size() == 0, because the characters were transferred to b.
- a is a valid string with unspecified contents, so a.clear() and a = "x" are safe while a.front() is not.
- a's lifetime has ended, so any use of a before it is reassigned is undefined behaviour.
- a.front() is safe because a is still a valid object and only its value is unknown.
Show answer
A move leaves a library object valid but unspecified, so operations with no preconditions such as clear() and assignment are always fine. Option 3 is the tempting one: validity means the class invariants hold, not that front()'s precondition of a non-empty string holds, so if the implementation left a empty, front() is undefined behaviour. Option 0 fails because the small-string optimisation lets an implementation copy short contents instead of stealing a buffer, so emptiness is never promised.