Lesson 02 / 11
Move Semantics
std::move is nothing but a type conversion; the actual move is done by the move constructor and the move assignment operator. Measuring what is left in the source after a std::string is moved.
Published Updated
In this lesson
- distinguishes among rvalue, lvalue, and rvalue reference
- builds an example showing that a std::move call is nothing but a type conversion
- correctly handles a moved std::string that stays in a valid but unspecified state
- proves with a printed line that the source's destructor still runs after a move
Contents
The previous lesson showed that a moved unique_ptr‘s source is emptied by a promise the standard gives: once the move finishes, the source becomes nullptr, and this guarantee comes from the pointer’s own type. But not every type speaks with the same certainty. unique_ptr can cheaply guarantee emptiness because it carries nothing but a pointer; when a type that carries its own data, such as std::string, is moved, the standard does not give the same promise. This lesson measures that less certain outcome.
Rvalue, Lvalue, Rvalue Reference
Whether an expression is an lvalue or an rvalue has less to do with whether it has a name and more to do with whether its address can be taken. A named variable like s is an lvalue; a temporary result like std::string("temp") or the expression std::move(s) returns is an rvalue. An rvalue reference, declared with T&&, can only bind to rvalues; this binding signals to the compiler, “this value is a temporary nobody needs anymore, you may take its content.”
std::move looks like a function but actually moves no data at all; the only thing it does is convert an lvalue it holds into an rvalue reference — it is nothing but a plain static_cast<T&&> call. This conversion alone calls no move constructor or move assignment; it only lets the compiler’s overload resolution pick the rvalue-specific version, if one exists.
That is why the expression std::move(s) by itself produces no side effect: s’s type does not change, its address does not change, its content does not change. The side effect appears only when this expression is passed as an argument to a constructor or assignment operator, and the compiler picks the move version for that call. std::move(s) can also be written without ever entering a function call, for instance just assigned to a variable and never used again — in that case no move happens at all, because no move constructor or move assignment is ever called. The name “move” being in it does not mean the call itself guarantees a move; it only expresses a request, a permission.
When a class declares both a T(const T&) and a T(T&&) version, the compiler decides which one is called by looking at the value category of the argument: given a named lvalue, the const T& version is picked; given an rvalue produced by std::move or already temporary, the T&& version is picked. This selection requires no extra signal from the caller — the same call syntax (T fresh(source);) routes to a different constructor depending on source’s type. In a class with no T&& parameter, the only option, the const T& version, runs in either case; being handed an rvalue makes no difference here, because there is no other overload for the compiler to pick.
The Move Constructor and the Move Assignment Operator
A class’s move constructor is declared with the signature T(T&& other), and its move assignment operator with the signature T& operator=(T&& other). Both are written to hand over the source’s resource — a buffer, a handle, a number — to the target; their difference from the copying versions is that they take over what already exists instead of opening a new resource.
The Box class below shows this by hand. The move constructor moves other’s label_ member into its own label_ member — here again std::move only converts other.label_ into an rvalue; the actual move is done by std::string’s own move constructor. The move assignment operator does the same handover, but over an object that already exists. Both are marked noexcept; this mark is not just a documentation note — standard library containers like std::vector decide whether they can move their elements during reallocation by looking at this mark. A move constructor that is not noexcept can force a container to copy instead of move.
// box.cpp — move constructor, move assignment, and the source's destructor
#include <cstdio>
#include <string>
#include <utility>
class Box {
public:
explicit Box(std::string label) : label_(std::move(label)) {}
Box(Box&& other) noexcept : label_(std::move(other.label_)) {
std::printf("move constructor ran\n");
}
Box& operator=(Box&& other) noexcept {
label_ = std::move(other.label_);
std::printf("move assignment ran\n");
return *this;
}
Box(const Box&) = delete;
Box& operator=(const Box&) = delete;
~Box() { std::printf("Box destructor ran\n"); }
private:
std::string label_;
};
static void move_functions() {
Box a("first label, a text long enough to go past the small string optimization");
Box b(std::move(a));
Box c("second label, again a text long enough to go past the small string optimization");
c = std::move(b);
}
static void string_move() {
std::string s = "a text long enough to go past the small string buffer, for a move";
std::string t = std::move(s);
std::printf("t (the value coming from the source) = %s\n", t.c_str());
s = "new value";
std::printf("s (after a new value is given) = %s\n", s.c_str());
}
static void const_string() {
const std::string c = "a constant text, too constant to bind to a move constructor";
std::string before = c;
std::string copy_obj = std::move(c);
std::printf("copy_obj (the value coming from c) = %s\n", copy_obj.c_str());
std::printf("c unchanged: %s\n", (c == before) ? "yes" : "no");
}
int main() {
move_functions();
string_move();
const_string();
}
move constructor ran move assignment ran Box destructor ran Box destructor ran Box destructor ran t (the value coming from the source) = a text long enough to go past the small string buffer, for a move s (after a new value is given) = new value copy_obj (the value coming from c) = a constant text, too constant to bind to a move constructor c unchanged: yes
OM3. None of the Box objects inside move_functions are left without a destructor for having been moved: every Box object that is opened prints its own Box destructor ran line at the end of scope. Even after a‘s content is moved into b, and b‘s into c, all of them are destroyed normally at the end of the function. This confirms the previous lesson’s M10/K02 and M11/K02 comparison again: in Rust, a moved variable is treated by the compiler as no longer usable, and no destruction code runs for it at scope end; in C++, the moved object keeps living, its destructor still runs — a move affects the object’s content, not its lifetime.
Another subtlety of the move assignment operator is its behavior toward self-assignment: a call like x = std::move(x); should be writable and must not corrupt the object. Box’s move assignment operator relies here on std::string‘s own move assignment through the line label_ = std::move(other.label_);; std::string‘s own self-move-assignment is safe: it does not lead to undefined behavior and leaves the object valid, but the standard does not say which value the string will hold afterward; the result falls into this lesson’s unspecified kind. When writing a class that manages a resource by hand (say, a class holding a raw pointer), this check has to be added explicitly — otherwise self-assignment can release the object’s resource and then immediately try to read that released resource.
Why the Source’s Destructor Still Runs
Every Box destructor ran line in the move_functions output is not a random detail; it is a direct consequence of C++‘s move model. Once an object is moved, it keeps staying in scope — neither its lifetime shortens nor is its destructor call skipped. Even after a hands its content to b, a is still a valid Box object; when scope ends, the compiler still generates the normal destructor call for it too. The destructor itself makes no distinction between having been moved and not having been moved — ~Box() always runs the same body, because the class author writes the destructor only once, for the object’s ordinary end.
The condition for this behavior to be safe is that the move constructor and move assignment leave the source in a state the destructor can safely run against. Here Box hands off its label_ member to std::string; std::string‘s own move constructor puts the source string into a valid but unspecified state and guarantees that this state can be safely released by its own destructor. If Box held a raw char* instead of a std::string, the move constructor would have had to null out the source’s pointer — otherwise both the source and the target would try to release the same memory, and the program would fall into a double-free error. This is the implicit rule of writing a move constructor: leave the source in a state where that source’s destructor can run a second time without doing harm.
This is exactly where it parts ways with Rust’s model. In Rust, the compiler generates no destruction code at all for a moved variable at scope end — since the variable is no longer counted as “alive,” a drop call is never applied to it. In C++ there is no such exemption: the moved object keeps living until the end of scope, and its destructor, just like that of an object never moved, is guaranteed to run. This is why it matters in C++ to know which kind (unchanged, changed, empty, unspecified) the source falls into after a move — the object does not disappear, and the rest of the code will still encounter it (or at least its destructor).
A Moved std::string: A Valid But Unspecified State
After the line std::string t = std::move(s); in the string_move function, s’s content is never read anywhere; it is directly overwritten with s = "new value";. This ordering is not arbitrary: the standard says a moved std::string remains in a “valid but unspecified state” — the object is still valid, calling size() on it or assigning it a new value is safe, but what it holds at that moment is not defined by the standard. That is why this lesson never prints s’s content after the move; it only reads its state after a new value has been given.
t‘s value is different: the destination object’s content after a move is fully defined by the standard — it will be identical to the source’s value before the move. That is why the t (the value coming from the source) = ... line in the output can be printed safely; only the source falls under unspecified, not the destination.
OM4. This is also where the reason a move is cheaper than a copy is hidden: std::string‘s move constructor takes over t‘s buffer pointer from s, it does not copy the characters one by one. If a copy constructor were called instead, a new buffer would be allocated for every character in the source, and data would be written into it one character at a time from the source — an operation proportional to the string’s length. The move constructor, on the other hand, only copies a handful of members (the buffer pointer, the length, the capacity) into the destination and zeroes out the source’s own — a fixed-cost operation independent of how long the string’s content is. This lesson does not count this handover — how many characters were moved or how many bytes were not copied is not what this lesson measures; the only thing measured is which kind the source falls into once the operation finishes.
The “which object” question the previous course measured (M11/K04) does not apply here: there, the copy kind carried a destination’s name, here unchanged carries a source’s own name. A move always establishes the source’s old value at the destination — the destination’s kind is not the subject of this measurement; what is measured is the source that is left behind.
The Bounding Measurement
The const_string function shows this lesson’s boundary: since c is a const std::string, the call std::move(c) produces an rvalue reference of type const std::string&&. std::string’s move constructor expects a std::string&& (non-const) parameter; const std::string&& cannot bind to it. In this case the compiler falls back in overload resolution and calls the copy constructor. The result: c stays unchanged even after a call that looks like a move — the c unchanged: yes line in the output confirms this.
This is the clearest example showing that writing std::move does not guarantee a move. std::move is only a type conversion; which constructor actually gets called is decided by the compiler’s overload resolution, and a const qualification can steer that resolution away from the move constructor. In practice this means passing a const variable into a function with the intent of moving it is usually a design mistake: as long as the variable stays const, it is not possible for the compiler to pick the move version, the code always falls back to the copy version — so if a resource is really meant to be moved, that resource must not be const.
Summary
- An lvalue is a named expression whose address can be taken; an rvalue is a temporary result; an rvalue reference (
T&&) binds only to rvalues. std::moveis a type conversion; it moves no data itself — which constructor gets called is decided by overload resolution.- A moved
std::string’s source is in a valid but unspecified state (the unspecified kind); this lesson always gave it a new value before reading its content. - The moved object’s destructor keeps running normally — in C++, a move does not end a lifetime, it only hands over content.
- Calling
std::moveon aconstsource falls to the copy constructor, not the move constructor; the source stays unchanged in that case — this is this lesson’s bounding measurement.
Next Step
This lesson showed that whether a move actually happens is decided not by writing std::move but by which constructor is actually called. Whether a class’s move constructor is implicitly written by the compiler at all depends on a similar resolution — and even another special member function the class writes can break that resolution. The next lesson measures how writing a destructor suppresses the implicit move constructor, and how the rule of zero and the rule of five manage this.
To keep your progress and take notes, Log in
My notes
Log in to take notes.