C++ Module 12 – Move semantics and smart pointers
C++ value categories, std::move and copy elision
What lvalues, prvalues and xvalues are, why std::move moves nothing by itself, and when C++17 and C++23 remove copies, counted on Clang and GCC.
What you will learn
- Classify expressions as lvalues, prvalues or xvalues
- Explain what std::move really does and when it moves nothing
- Predict when a return statement copies, moves or does neither
- Detect a needless std::move with the compilers' warnings
Before you start
What you’ll learn · Before you start
What you will learn
- Classify expressions as lvalues, prvalues or xvalues
- Explain what std::move really does and when it moves nothing
- Predict when a return statement copies, moves or does neither
- Detect a needless std::move with the compilers' warnings
Before you start
On this page
Add std::move to return t; and the function does more work, not less: Apple clang 21 and GCC 16 both build the
local t straight in the caller’s object, 0 copies and 0 moves, until the cast turns that into 1 move and a warning.
What an expression costs depends on its value category: Whether an expression is an lvalue, which names an object with an identity such as a variable, or an rvalue, such as a temporary or std::move(x), whose resources may be taken. Overloading, forwarding and moves depend on it. More in “Deducing this: explicit object parameters in C++23”. An
lvalue: An expression that refers to an object with an identity, such as a named variable, (x) or v[0]. decltype gives a reference type, such as int&, for an lvalue that is not a plain name. More in “C++ auto, decltype and type aliases” names an object you can refer to again, such as a variable. A
prvalue: A pure rvalue: an expression that makes a value or initialises an object, such as 42, n + 1 or a call that returns by value. Since C++17 a prvalue initialises its target directly, so no temporary is copied or moved on the way. is a value that initialises a new object, such as 42 or a call that returns by
value. An xvalue: An expiring value: an expression that denotes an object whose resources may be taken, such as std::move(s), a call that returns T&&, or a member of a temporary. It has an identity, like an lvalue, and may be moved from, like a prvalue. refers to an object the code is done with, so its resources may be taken
over. std::move only casts to an xvalue, and copy elision: Building an object directly in its final place instead of making it elsewhere and copying it. Since C++17 it is guaranteed when a function returns a temporary of its return type. More in “C++ pass by value, reference or const reference”, making no copy or move
at all, is guaranteed since C++17 only when a prvalue initialises an object of its own type.
Below, both compilers classify fifteen expressions and count what seven return statements make; then the optional elision is turned off to show which part is guaranteed, and C++23’s change to returning a local is checked.
Every expression is an lvalue, an xvalue or a prvalue
The categories come from two questions about an expression. Does it have an identity, a particular object that another expression could also refer to? Lvalues and xvalues do, and together they are the glvalue: A generalised lvalue: an lvalue or an xvalue, that is, an expression that denotes an object with an identity, which another expression could also refer to.. May the object’s resources be reused, because nobody will look at it again? Xvalues and prvalues may, and together they are the rvalue: An xvalue or a prvalue: an expression whose object may be moved from, because nobody will look at it again. An rvalue reference, T&&, binds only to rvalues.. An xvalue answers yes twice: it is a real object, and it is on its way out. The diagram puts the two groups side by side:
The three value categories and the two groups they form
Text description of the diagram
The diagram is titled "Every expression is one of three" and shows three boxes stacked from top to bottom.
- lvalue: an object you can name. Examples: n, *p, "text", ++n and longest(s, t).
- xvalue: an object to move from. Examples: std::move(s) and make_point().x.
- prvalue: an expression that initialises an object. Examples: 42, n + 1, n++, s + t and make_point().
A bracket on the left joins the lvalue and xvalue boxes under the name glvalue, "has an identity". A bracket on the right joins the xvalue and prvalue boxes under the name rvalue, "may be moved from". A line under the boxes says that an xvalue belongs to both groups.
You can ask the compiler for the category. For an expression in double parentheses, decltype((e)) is T& when e
is an lvalue, T&& when it is an xvalue and plain T when it is a prvalue, so three small templates turn the answer
into a word:
// The value category of an expression, read with decltype((e)): an lvalue gives T&, an xvalue gives T&&, and a
// prvalue gives plain T. decltype does not evaluate its operand, so ++n and n++ below change nothing.
#include <print>
#include <string>
#include <utility>
template <typename T> constexpr const char* category = "prvalue";
template <typename T> constexpr const char* category<T&> = "lvalue";
template <typename T> constexpr const char* category<T&&> = "xvalue";
#define SHOW(...) std::println("{:<22} {}", #__VA_ARGS__, category<decltype((__VA_ARGS__))>)
struct Point {
int x = 0;
int y = 0;
};
Point make_point() { return {1, 2}; }
std::string& longest(std::string& a, std::string& b) { return a.size() >= b.size() ? a : b; }
int main() {
int n = 7;
int* p = &n;
int&& r = 5;
std::string s = "hello";
std::string t = "hi";
SHOW(n);
SHOW(*p);
SHOW(++n);
SHOW(n++);
SHOW(n + 1);
SHOW(42);
SHOW("text");
SHOW(r);
SHOW(std::move(n));
SHOW(static_cast<int&&>(n));
SHOW(s);
SHOW(s + t);
SHOW(longest(s, t));
SHOW(make_point());
SHOW(make_point().x);
} from session import run
# Fifteen expressions classified by Apple clang with libc++, then by GCC with libstdc++, and the two answers compared.
run("""
clang++ -std=c++23 -Wall -Wextra categories.cpp -o categories && ./categories
g++-16 -std=c++23 -Wall -Wextra categories.cpp -o categories-gcc
./categories > clang.txt && ./categories-gcc > gcc.txt && diff clang.txt gcc.txt && echo "GCC agrees on every line"
""") Output
$ clang++ -std=c++23 -Wall -Wextra categories.cpp -o categories && ./categories n lvalue *p lvalue ++n lvalue n++ prvalue n + 1 prvalue 42 prvalue "text" lvalue r lvalue std::move(n) xvalue static_cast<int&&>(n) xvalue s lvalue s + t prvalue longest(s, t) lvalue make_point() prvalue make_point().x xvalue $ g++-16 -std=c++23 -Wall -Wextra categories.cpp -o categories-gcc $ ./categories > clang.txt && ./categories-gcc > gcc.txt && diff clang.txt gcc.txt && echo "GCC agrees on every line" GCC agrees on every line
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 categories_session.py
A few rows deserve a second look:
"text"is an lvalue. A string literal is an array that exists for the whole run of the program, so it has an identity like any variable.ris an lvalue, although it is declaredint&& r. The name of a variable is an lvalue whatever its type (C++23 makes an exception for a returned name, near the end of this lesson): the&&says whatrmay bind to, not what the expressionris.++ngives backnitself, an lvalue, whilen++gives back a copy of the old value, a prvalue.make_point()is a prvalue, butmake_point().xis an xvalue. To reach a member, the compiler first turns the prvalue into a temporary object (the standard calls this temporary materialisation: The step that turns a prvalue into a temporary object when an object is needed, for example to bind a reference or to reach a member, as in make_point().x. The temporary is an xvalue and ends with its full-expression.), and that temporary dies at the end of the statement.std::move(n)andstatic_cast<int&&>(n)give the same answer because they are the same thing.
Expecting a named rvalue reference to move
Inside void store(std::string&& s), the line std::string kept = s; copies the caller’s string: s names a
variable, so the expression s is an lvalue, as the r row shows. Write std::string kept = std::move(s); to
take the string over.
Checkpoint
Check: Lvalues, xvalues and prvalues
std::move is a cast that moves nothing by itself
The standard defines std::move(t) as static_cast<std::remove_reference_t<T>&&>(t): a cast to an
rvalue reference: A reference declared with &&, such as int&& r, that binds to temporaries and other objects about to expire but never to a named variable. Unlike a const reference, it lets you change the object it names. More in “C++ references: lifetime extension and dangling”, which makes the expression an xvalue. It reads nothing and
changes nothing. Whether anything moves is decided where the xvalue is used:
// std::move only casts its argument to an xvalue. Something is moved only when that xvalue selects a move
// constructor or a move assignment operator.
#include <print>
#include <string>
#include <utility>
int main() {
std::string s(40, 'a'); // 40 characters: too long for the string's inline buffer
const char* buffer = s.data();
std::string&& r = std::move(s); // a reference to s: nothing is moved
std::println("after std::string&& r = std::move(s); s.size() = {}, r.size() = {}", s.size(), r.size());
std::string t = std::move(s); // the move constructor runs here
std::println("after std::string t = std::move(s); s.size() = {}, t took s's array: {}", s.size(), t.data() == buffer);
const std::string c(40, 'c');
std::string u = std::move(c); // const std::string&& can only bind to the copy constructor
std::println("after std::string u = std::move(c); c.size() = {}, u has its own array: {}", c.size(), u.data() != c.data());
} from session import run
# A string of 40 characters lives in an array on the heap, so its move can hand that array over.
run("""
clang++ -std=c++23 -Wall -Wextra move_cast.cpp -o move_cast && ./move_cast
g++-16 -std=c++23 -Wall -Wextra move_cast.cpp -o move_cast-gcc && ./move_cast-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra move_cast.cpp -o move_cast && ./move_cast after std::string&& r = std::move(s); s.size() = 40, r.size() = 40 after std::string t = std::move(s); s.size() = 0, t took s's array: true after std::string u = std::move(c); c.size() = 40, u has its own array: true $ g++-16 -std=c++23 -Wall -Wextra move_cast.cpp -o move_cast-gcc && ./move_cast-gcc after std::string&& r = std::move(s); s.size() = 40, r.size() = 40 after std::string t = std::move(s); s.size() = 0, t took s's array: true after std::string u = std::move(c); c.size() = 40, u has its own array: true
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 move_cast_session.py
Step through the same three statements and watch which string owns each heap array:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 40 characters on the heap. s holds 40 characters, too many for the string's inline buffer, so they live in an array on the heap. The program saves s.data(), the array's address, to compare later.
- Only a cast. std::string&& r = std::move(s); casts s to an xvalue and binds the reference r to it. A reference is not a new string, so no constructor runs: s.size() and r.size() both print 40.
- The move constructor runs. std::string t = std::move(s); initialises a new string from the same xvalue, so the move constructor is chosen. t takes over the array that s held: t.data() equals the saved address.
- Valid, value unspecified. Both libraries left s empty, with size 0. The standard promises only a valid string with an unspecified value, so give s a new value before reading it again.
- A const source. c is const, so std::move(c) is a const std::string&&. The move constructor takes a std::string&&, which cannot bind to a const object.
- The copy runs instead. The copy constructor takes a const reference, which can bind, so it is chosen: u gets an array of its own and c keeps its 40 characters. Neither compiler warns.
Binding a reference to std::move(s) moves nothing: s still holds its 40 characters. Initialising t from the same
xvalue selects std::string’s move constructor, which takes over s’s heap array, so t.data() is the address that
s held. Both libraries leave s empty, but the standard promises only that a moved-from library object is valid with
an unspecified value, so give it a new value before you read it again. The last line is the quiet trap: std::move on
a const string gives a const std::string&&, the move constructor’s std::string&& parameter cannot bind to it, and
the copy constructor runs without any warning.
Key idea
std::move moves nothing: it casts to an xvalue, and only a move constructor or move assignment that accepts that
xvalue takes anything over. On a const object, the copy runs instead.
Checkpoint
Check: std::move as a cast
Seven return statements: built in place, moved or copied
Tracker counts its copies and moves. Each of seven functions returns one in a different way, and main receives each
result through a reference, so only the return statement is counted:
// Tracker counts its copies and moves, and each function returns one in a different way. The file uses std::printf
// and nothing newer than C++14, so that the same code can show what C++17 changed.
#include <cstdio>
#include <utility>
int copies = 0;
int moves = 0;
struct Tracker {
Tracker() = default;
Tracker(const Tracker&) { ++copies; }
Tracker(Tracker&&) noexcept { ++moves; }
};
struct Pair {
Tracker first;
Tracker second;
};
Tracker temporary() { return Tracker{}; }
Tracker named() {
Tracker t;
return t;
}
Tracker named_moved() {
Tracker t;
return std::move(t);
}
Tracker one_of_two(bool first) {
Tracker a;
Tracker b;
if (first) return a;
return b;
}
Tracker parameter(Tracker t) { return t; }
Tracker member() {
Pair p;
return p.first;
}
Tracker conditional(bool first) {
Tracker a;
Tracker b;
return first ? a : b;
}
// Prints what happened since the last report (the result is only received), then counts from zero again.
void report(const char* style, const Tracker&) {
std::printf("%-24s copies: %d, moves: %d\n", style, copies, moves);
copies = moves = 0;
}
int main() {
report("return Tracker{};", temporary());
report("return t;", named());
report("return std::move(t);", named_moved());
report("return a; or return b;", one_of_two(true));
report("return t; (a parameter)", parameter(Tracker{}));
report("return p.first;", member());
report("return first ? a : b;", conditional(true));
} from session import run
# Seven ways to return a Tracker, built with the warnings on: libc++ with Apple clang, then libstdc++ with GCC.
run("""
clang++ -std=c++23 -Wall -Wextra returns.cpp -o returns && ./returns
g++-16 -std=c++23 -Wall -Wextra returns.cpp -o returns-gcc && ./returns-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra returns.cpp -o returns && ./returns
returns.cpp:27:12: warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
27 | return std::move(t);
| ^
returns.cpp:27:12: note: remove std::move call here
27 | return std::move(t);
| ^~~~~~~~~~ ~
1 warning generated.
return Tracker{}; copies: 0, moves: 0
return t; copies: 0, moves: 0
return std::move(t); copies: 0, moves: 1
return a; or return b; copies: 0, moves: 1
return t; (a parameter) copies: 0, moves: 1
return p.first; copies: 1, moves: 0
return first ? a : b; copies: 1, moves: 0
$ g++-16 -std=c++23 -Wall -Wextra returns.cpp -o returns-gcc && ./returns-gcc
returns.cpp: In function 'Tracker named_moved()':
returns.cpp:27:21: warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
27 | return std::move(t);
| ~~~~~~~~~^~~
returns.cpp:27:21: note: remove 'std::move' call
return Tracker{}; copies: 0, moves: 0
return t; copies: 0, moves: 0
return std::move(t); copies: 0, moves: 1
return a; or return b; copies: 0, moves: 1
return t; (a parameter) copies: 0, moves: 1
return p.first; copies: 1, moves: 0
return first ? a : b; copies: 1, moves: 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 returns_session.py
return Tracker{};returns a prvalue of the return type. Since C++17 a prvalue no longer makes a temporary that is then copied: it initialises the object it is used for, here the caller’s result. Nothing is left to elide.return t;names a local of the return type, so the compiler may buildtdirectly in the caller’s object, the named return value optimisation: Copy elision for return of a named local variable: the compiler builds the local directly in the caller's variable. The standard allows it but does not require it; -fno-elide-constructors turns it off. More in “C++ pass by value, reference or const reference” (NRVO). Both compilers did: no copy, no move.return std::move(t);is no longer the name of a local, so NRVO does not apply, and the move constructor runs. Both compilers warn under-Wall. Rule F.48 of the C++ Core Guidelines says the same: don’t returnstd::move(local).- Two locals and two returns: which one becomes the result is known only at run time, so neither compiler built one of them in place. The return still moves instead of copying, because a returned local counts as an rvalue (the implicit move: The rule that a local variable or by-value parameter named in a return statement is treated as an rvalue, so return t; moves where a copy would otherwise run. Since C++23 the returned name is an xvalue.).
- A by-value parameter was built by the caller, so the standard excludes parameters from NRVO; it is moved too.
return p.first;andreturn first ? a : b;copy. The implicit move applies only to a returned name, not to a member or a conditional expression.return std::move(p.first);, or anifwith two return statements, moves.
The seven rows follow one rule, a question at a time from the top, and the first answer that fits decides. Step through it with the counts the run printed:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Seven returns. Each function of returns.cpp returns a Tracker in its own way, and main counts only what the return statement made. The rule asks about what follows return, from the top, and the first answer that fits decides.
- return Tracker{};. Tracker{} is a prvalue of the return type, so it initialises the caller's object directly, as C++17 requires: 0 copies, 0 moves.
- return t;. t is a local of the return type and the only object the function returns, so both compilers built it straight in the caller's object: 0 copies, 0 moves. The standard allows this NRVO but does not require it.
- return a; or return b;. Which of two locals becomes the result is known only at run time, so neither compiler built one in place. A returned local counts as an rvalue, so it was moved: 0 copies, 1 move.
- A by-value parameter. The caller built the parameter, so the standard excludes it from NRVO, but it is moved like a returned local: 0 copies, 1 move.
- return std::move(t);. std::move(t) is not a name, so neither NRVO nor the implicit move applies. The xvalue picks the move constructor: 1 move where return t; made none, and -Wall warns about it.
- return p.first;. A member is not a returned name either. p.first is an lvalue, so the copy constructor runs: 1 copy, 0 moves.
- return first ? a : b;. A conditional expression is not a name, and here it is an lvalue: 1 copy, 0 moves. return std::move(p.first); or an if with two return statements would move instead.
Key idea
What follows return decides the cost: a prvalue of the return type makes nothing, a single local may be built in
place, any other returned local or by-value parameter is moved, and every other lvalue is copied.
Checkpoint
Check: What each return statement makes
Only the prvalue rule is guaranteed
Which of these rows does the standard require, and which did the compilers choose? -fno-elide-constructors, which
both compilers support, turns off every elision that the standard permits but does not require:
from session import run
# -fno-elide-constructors turns off every copy elision that the standard allows but does not require.
run("""
g++-16 -std=c++23 -fno-elide-constructors returns.cpp -o c23 && ./c23
g++-16 -std=c++14 -fno-elide-constructors returns.cpp -o c14 && ./c14
clang++ -std=c++23 -fno-elide-constructors returns.cpp -o c23-clang && ./c23-clang > clang23.txt && ./c23 > gcc23.txt
clang++ -std=c++14 -fno-elide-constructors returns.cpp -o c14-clang && ./c14-clang > clang14.txt && ./c14 > gcc14.txt
diff gcc23.txt clang23.txt && diff gcc14.txt clang14.txt && echo "Clang prints the same in both modes"
""") Output
$ g++-16 -std=c++23 -fno-elide-constructors returns.cpp -o c23 && ./c23
return Tracker{}; copies: 0, moves: 0
return t; copies: 0, moves: 1
return std::move(t); copies: 0, moves: 1
return a; or return b; copies: 0, moves: 1
return t; (a parameter) copies: 0, moves: 1
return p.first; copies: 1, moves: 0
return first ? a : b; copies: 1, moves: 0
$ g++-16 -std=c++14 -fno-elide-constructors returns.cpp -o c14 && ./c14
return Tracker{}; copies: 0, moves: 1
return t; copies: 0, moves: 1
return std::move(t); copies: 0, moves: 1
return a; or return b; copies: 0, moves: 1
return t; (a parameter) copies: 0, moves: 2
return p.first; copies: 1, moves: 0
return first ? a : b; copies: 1, moves: 0
$ clang++ -std=c++23 -fno-elide-constructors returns.cpp -o c23-clang && ./c23-clang > clang23.txt && ./c23 > gcc23.txt
$ clang++ -std=c++14 -fno-elide-constructors returns.cpp -o c14-clang && ./c14-clang > clang14.txt && ./c14 > gcc14.txt
$ diff gcc23.txt clang23.txt && diff gcc14.txt clang14.txt && echo "Clang prints the same in both modes"
Clang prints the same in both modes
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 elision_off_session.py
As C++23, return Tracker{}; still makes nothing: that row is a rule of the language. return t; becomes a move, so
NRVO was the compilers’ choice. Built as C++14, from before the rule, the prvalue costs a move as well, and handing
Tracker{} to the parameter adds another. The P0135R1 paper made the prvalue rule part of C++17. The three builds side
by side, as both compilers counted them:
| Criterion | C++23 | C++23, elision off | C++14, elision off |
|---|---|---|---|
| return Tracker{}; | Nothing | Nothing | 1 move |
| return t; | Nothing | 1 move | 1 move |
| return std::move(t); | 1 move | 1 move | 1 move |
| return a; or return b; | 1 move | 1 move | 1 move |
| return t; (a parameter) | 1 move | 1 move | 2 moves |
| return p.first; | 1 copy | 1 copy | 1 copy |
| return first ? a : b; | 1 copy | 1 copy | 1 copy |
Only the first row keeps “Nothing” with the optional elision off. A guarantee also means that the type needs no copy or
move constructor at all. A std::mutex can be neither copied nor moved, and neither can a struct that holds one:
// Guarded holds a std::mutex, which can be neither copied nor moved, so Guarded cannot be copied or moved either.
#include <mutex>
struct Guarded {
std::mutex lock;
int value = 0;
};
Guarded make_guarded() {
return Guarded{}; // a prvalue: it initialises the caller's object in place
}
Guarded make_named() {
Guarded g;
return g; // a named local: needs a move or copy constructor
}
int main() {
Guarded a = make_guarded();
return a.value;
} from session import run
# Only a syntax check: the question is which of the two return statements each compiler accepts.
run("""
clang++ -std=c++23 -fsyntax-only no_moves.cpp
g++-16 -std=c++23 -fsyntax-only no_moves.cpp
""") Output
$ clang++ -std=c++23 -fsyntax-only no_moves.cpp
no_moves.cpp:15:12: error: call to implicitly-deleted copy constructor of 'Guarded'
15 | return g; // a named local: needs a move or copy constructor
| ^
no_moves.cpp:5:16: note: copy constructor of 'Guarded' is implicitly deleted because field 'lock' has a deleted copy constructor
5 | std::mutex lock;
| ^
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__mutex/mutex.h:30:3: note: 'mutex' has been explicitly marked deleted here
30 | mutex(const mutex&) = delete;
| ^
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -fsyntax-only no_moves.cpp
no_moves.cpp: In function 'Guarded make_named()':
no_moves.cpp:15:12: error: use of deleted function 'Guarded::Guarded(Guarded&&)'
15 | return g; // a named local: needs a move or copy constructor
| ^
no_moves.cpp:4:8: note: 'Guarded::Guarded(Guarded&&)' is implicitly deleted because the default definition would be ill-formed:
4 | struct Guarded {
| ^~~~~~~
no_moves.cpp: At global scope:
no_moves.cpp:4:8: error: use of deleted function 'std::mutex::mutex(const std::mutex&)'
In file included from /opt/homebrew/Cellar/gcc/16.2.0/include/c++/16/mutex:49,
from no_moves.cpp:2:
/opt/homebrew/Cellar/gcc/16.2.0/include/c++/16/bits/std_mutex.h:110:5: note: declared here
110 | mutex(const mutex&) = delete;
| ^~~~~
no_moves.cpp:4:8: note: use '-fdiagnostics-all-candidates' to display considered candidates
4 | struct Guarded {
| ^~~~~~~
no_moves.cpp: In function 'Guarded make_named()':
no_moves.cpp:15:12: note: use '-fdiagnostics-all-candidates' to display considered candidates
15 | return g; // a named local: needs a move or copy constructor
| ^
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 no_moves_session.py
Both compilers accept return Guarded{}; and reject only return g; on line 15. NRVO is an optimisation, so the code
must be valid without it, and without it g would have to be moved or copied.
Key idea
Only the prvalue rule is guaranteed: a prvalue of the return type needs no copy or move constructor, while a returned local must still be movable or copyable, because NRVO is the compiler’s choice.
Checkpoint
Check: Guaranteed and optional copy elision
In C++23 a returned name is an xvalue
C++23 adopted P2266, “Simpler implicit move”: in a return statement, the name of a local variable or of an rvalue-reference parameter is an xvalue. Before, it was an lvalue that the compiler tried as an rvalue first when it chose a constructor. The difference shows when a function returns a reference:
// In a return statement, C++23 treats the name of a local variable or of an rvalue-reference parameter as an xvalue
// (P2266, "Simpler implicit move"). C++20 still treated it as an lvalue here.
#include <string>
std::string& dangling() {
std::string s = "local";
return s; // a reference to a local that is about to die
}
std::string&& pass_on(std::string&& s) {
return s; // an lvalue in C++20, an xvalue in C++23
}
int main() {
std::string text = "abc";
std::string moved = pass_on(std::move(text));
return static_cast<int>(moved.size()) - 3;
} from session import run
# The same file checked as C++20 and as C++23, by both compilers.
run("""
clang++ -std=c++20 -fsyntax-only implicit_move.cpp
clang++ -std=c++23 -fsyntax-only implicit_move.cpp
g++-16 -std=c++20 -fsyntax-only implicit_move.cpp
g++-16 -std=c++23 -fsyntax-only implicit_move.cpp
""") Output
$ clang++ -std=c++20 -fsyntax-only implicit_move.cpp
implicit_move.cpp:7:12: warning: reference to stack memory associated with local variable 's' returned [-Wreturn-stack-address]
7 | return s; // a reference to a local that is about to die
| ^
implicit_move.cpp:11:12: error: rvalue reference to type 'basic_string<...>' cannot bind to lvalue of type 'basic_string<...>'
11 | return s; // an lvalue in C++20, an xvalue in C++23
| ^
1 warning and 1 error generated.
[exit status 1]
$ clang++ -std=c++23 -fsyntax-only implicit_move.cpp
implicit_move.cpp:7:12: error: non-const lvalue reference to type 'basic_string<...>' cannot bind to a temporary of type 'basic_string<...>'
7 | return s; // a reference to a local that is about to die
| ^
1 error generated.
[exit status 1]
$ g++-16 -std=c++20 -fsyntax-only implicit_move.cpp
implicit_move.cpp: In function 'std::string& dangling()':
implicit_move.cpp:7:12: warning: reference to local variable 's' returned [-Wreturn-local-addr]
7 | return s; // a reference to a local that is about to die
| ^
implicit_move.cpp:6:17: note: declared here
6 | std::string s = "local";
| ^
implicit_move.cpp: In function 'std::string&& pass_on(std::string&&)':
implicit_move.cpp:11:12: error: cannot bind rvalue reference of type 'std::string&&' {aka 'std::__cxx11::basic_string<char>&&'} to lvalue of type 'std::string' {aka 'std::__cxx11::basic_string<char>'}
11 | return s; // an lvalue in C++20, an xvalue in C++23
| ^
[exit status 1]
$ g++-16 -std=c++23 -fsyntax-only implicit_move.cpp
implicit_move.cpp: In function 'std::string& dangling()':
implicit_move.cpp:7:12: error: cannot bind non-const lvalue reference of type 'std::string&' {aka 'std::__cxx11::basic_string<char>&'} to an rvalue of type 'std::string' {aka 'std::__cxx11::basic_string<char>'}
7 | return s; // a reference to a local that is about to die
| ^
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 implicit_move_session.py
As C++20, dangling() compiles with a warning and pass_on() is an error, because s is an lvalue and an rvalue
reference cannot bind to one. As C++23 the two swap places. pass_on() compiles, since s is now an xvalue, and
dangling() fails, since a non-const lvalue reference cannot bind to an xvalue: a
dangling reference: A reference whose object has already been destroyed, such as one returned from a function to its own local variable. Reading or writing through it is undefined behaviour, even when the program happens to print the right value. More in “C++ references: lifetime extension and dangling” became a compile error. Returns by value, like every row of
the Tracker table, count the same in both versions.
Walk through the versions in order, each with what changed and what the runs of this lesson showed for it:
- C++11
A returned local is tried as an rvalue first
From C++11 on, a local named in a return statement is treated as an rvalue first when the result is initialised, so a function can return a move-only type by value.
- C++14
A returned temporary may still cost a move
Before C++17, return Tracker{}; made a temporary that was then moved into the result, and skipping that move was only allowed. Built as C++14 with -fno-elide-constructors, the row counted 1 move, and parameter(Tracker{}) counted 2.
- C++17
A prvalue initialises its target directly new rule
P0135R1 makes a prvalue initialise the object it is used for, with no temporary in between. With elision turned off, as C++23, return Tracker{}; still counted 0 copies and 0 moves, and Guarded, which cannot be moved, can be returned this way.
- C++20
A returned name is still an lvalue
The implicit move helps only when the return statement initialises an object. pass_on(), which returns std::string&&, cannot bind its parameter s, an error on both compilers, while dangling() returns a reference to its own local with only a warning.
- C++23
A returned name is an xvalue changed rule
P2266, simpler implicit move: in a return statement, the name of a local or of an rvalue-reference parameter is an xvalue. pass_on() compiles, and dangling() is now a compile error on both compilers.
The timeline has 5 events, from C++11 to C++23.
C++20 code can stop compiling or change its type
The standard’s list of changes from C++20 gives two more cases. int& g(int&& x) { return x; } stops compiling,
like dangling(), and -std=c++23 reports it as an error. decltype(auto) f(int&& x) { return (x); } still
compiles but now returns int&& instead of int&, so a build reports it only where a caller relied on the old type.
Checkpoint
Check: Returning a name in C++23
Both compilers warn about a needless std::move
Both compilers recognise the pattern, and the warnings are on with the flags this track uses everywhere:
What follows return |
The warning, and the flag that turns it on |
|---|---|
std::move of a local |
-Wpessimizing-move, part of -Wall with both compilers |
std::move of a temporary |
-Wpessimizing-move, part of -Wall with both compilers |
std::move of a by-value parameter |
-Wredundant-move, part of -Wall with Clang and of -Wextra with GCC |
A pessimizing move: A std::move that makes code slower: return std::move(local); or std::move of a temporary blocks copy elision, so a move runs where none was needed. -Wpessimizing-move, part of -Wall on Clang and GCC, reports it. costs a move that would not have happened; a redundant one costs
nothing but tells the reader something false. Rule ES.56 of the Core Guidelines gives the positive version: write
std::move only where you hand an object over to another scope and will not use its value again.
A std::move on every return, to be sure
return std::move(result); turns the 0 moves of NRVO into 1, and -Wpessimizing-move says so. Write
return result;: a returned local is moved anyway whenever it is not built in place. Keep the cast for the returns
that copy, such as return std::move(p.first);, where p is a local that is about to end.
Checkpoint
Check: The warnings for a needless std::move
Practise: count the copies
The starter holds ten short snippets and a table of predictions, all set to {-1, -1}. Write your answer for each
snippet as {copies, moves}, then build and run it with both compilers. The program says which predictions are
right and, for a wrong one, what the snippet really made:
// Predict how many copies and moves each of the ten snippets makes, then run the program to check.
// Replace each {-1, -1} in `predictions` with your answer: {copies, moves}.
#include <array>
#include <print>
#include <utility>
#include <vector>
struct Prediction {
int copies;
int moves;
};
constexpr std::array<Prediction, 10> predictions = {{
{-1, -1}, // 1
{-1, -1}, // 2
{-1, -1}, // 3
{-1, -1}, // 4
{-1, -1}, // 5
{-1, -1}, // 6
{-1, -1}, // 7
{-1, -1}, // 8
{-1, -1}, // 9
{-1, -1}, // 10
}};
int copies = 0;
int moves = 0;
struct Tracker {
Tracker() = default;
Tracker(const Tracker&) { ++copies; }
Tracker(Tracker&&) noexcept { ++moves; }
Tracker& operator=(const Tracker&) { ++copies; return *this; }
Tracker& operator=(Tracker&&) noexcept { ++moves; return *this; }
};
Tracker make() {
Tracker t;
return t;
}
void keep(const Tracker&) {} // uses a Tracker without copying it
void snippet(int n) {
Tracker a;
std::vector<Tracker> v;
switch (n) {
case 1: { Tracker b = a; keep(b); break; }
case 2: { Tracker b = std::move(a); keep(b); break; }
case 3: { Tracker b = Tracker{}; keep(b); break; }
case 4: { const Tracker c; Tracker b = std::move(c); keep(b); break; }
case 5: { Tracker&& r = std::move(a); Tracker b = r; keep(b); break; }
case 6: { v.push_back(Tracker{}); break; }
case 7: { v.emplace_back(); break; }
case 8: { std::vector<Tracker> w(3); std::vector<Tracker> x = std::move(w); break; }
case 9: { Tracker b = std::move(Tracker{}); keep(b); break; }
case 10: { Tracker b; b = make(); break; }
}
}
int main() {
int right = 0;
for (int n = 1; n <= 10; ++n) {
copies = moves = 0;
snippet(n);
const Prediction p = predictions[n - 1];
if (p.copies < 0 || p.moves < 0) {
std::println("snippet {:>2}: no prediction yet", n);
} else if (p.copies == copies && p.moves == moves) {
std::println("snippet {:>2}: right", n);
++right;
} else {
std::println("snippet {:>2}: you said {} copies and {} moves; it made {} and {}", n, p.copies, p.moves, copies, moves);
}
}
std::println("{} of 10 right", right);
} from session import run
# The starter as it is, with no prediction filled in, built by both compilers with the warnings on.
run("""
clang++ -std=c++23 -Wall -Wextra predict.cpp -o predict && ./predict
g++-16 -std=c++23 -Wall -Wextra predict.cpp -o predict-gcc && ./predict-gcc | tail -n 1
""") Output
$ clang++ -std=c++23 -Wall -Wextra predict.cpp -o predict && ./predict
predict.cpp:56:27: warning: moving a temporary object prevents copy elision [-Wpessimizing-move]
56 | case 9: { Tracker b = std::move(Tracker{}); keep(b); break; }
| ^
predict.cpp:56:27: note: remove std::move call here
56 | case 9: { Tracker b = std::move(Tracker{}); keep(b); break; }
| ^~~~~~~~~~ ~
1 warning generated.
snippet 1: no prediction yet
snippet 2: no prediction yet
snippet 3: no prediction yet
snippet 4: no prediction yet
snippet 5: no prediction yet
snippet 6: no prediction yet
snippet 7: no prediction yet
snippet 8: no prediction yet
snippet 9: no prediction yet
snippet 10: no prediction yet
0 of 10 right
$ g++-16 -std=c++23 -Wall -Wextra predict.cpp -o predict-gcc && ./predict-gcc | tail -n 1
predict.cpp: In function 'void snippet(int)':
predict.cpp:56:36: warning: moving a temporary object prevents copy elision [-Wpessimizing-move]
56 | case 9: { Tracker b = std::move(Tracker{}); keep(b); break; }
| ~~~~~~~~~^~~~~~~~~~~
predict.cpp:56:36: note: remove 'std::move' call
0 of 10 right
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 predict_session.py
One snippet draws a warning from both compilers, which is a hint. When both compilers print 10 of 10 right, change
snippet 4 so that it moves, and snippet 10 so that it makes nothing at all.
Then test the six ideas of this lesson on cases you have not seen. The questions start with the idea you know least and bring back one you missed, and every choice says why:
Practice that adapts to you
Practise this lesson
17 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What does std::move do?
Nothing at run time. std::move(x) is a cast: it returns x as an rvalue reference, which makes the expression an
xvalue, and that only changes which function is chosen next. If the xvalue initialises or is assigned to an object
whose type has a move constructor or move assignment operator that accepts it, that function runs and may take x’s
resources; otherwise the copy runs, as it does for a const object. A bare std::move(x), or binding it to a reference,
changes nothing. After a real move, a library object such as a string is valid but has an unspecified value, so assign
it or let it be destroyed. Use std::move only where you hand an object over and no longer need its value.
An interviewer usually follows up with a case that tests whether you can use the rule. Decide these two:
Checkpoint
Interview round: two follow-up questions
The next two questions take the same ideas into a code review and into a function that returns a local through
std::optional:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A pull request adds std::move to the return statements of twelve factory functions, saying that it guarantees a move instead of a copy. Review it: where does the change make the code slower, where does it change nothing, and where is std::move in a return statement actually needed?
-
Mid to senior
A function returns std::optional<Order>. It builds Order order; and ends with return order;. A teammate says this copies the order, because the types differ and NRVO cannot apply. Is that right in C++17, C++20 and C++23? How would you prove it, and what would you write instead?
Key takeaways
- Every expression is exactly one of lvalue, xvalue and prvalue: glvalues (lvalues and xvalues) have an identity, rvalues (xvalues and prvalues) may be moved from, and the name of a variable is an lvalue whatever its type, except as a returned name in C++23.
std::moveis a cast to an xvalue and moves nothing by itself; on a const object it leads to a copy.- A prvalue of the return type makes nothing, one returned local may be built in place, other returned locals and by-value parameters are moved, and members and conditional expressions are copied.
- Since C++17 the prvalue rule is guaranteed and needs no copy or move constructor, while NRVO is the compilers’
choice, which
-fno-elide-constructorsturns off. - In C++23 a returned name is an xvalue, so returning a local through a non-const reference no longer compiles.
return std::move(t);turns NRVO into a move, and-Wallwarns about it on Clang and GCC: writestd::moveonly to hand an object over.
Terms in this lesson
- glvalue
- A generalised lvalue: an lvalue or an xvalue, that is, an expression that denotes an object with an identity, which another expression could also refer to.
- implicit move
- The rule that a local variable or by-value parameter named in a return statement is treated as an rvalue, so return t; moves where a copy would otherwise run. Since C++23 the returned name is an xvalue.
- pessimizing move
- A std::move that makes code slower: return std::move(local); or std::move of a temporary blocks copy elision, so a move runs where none was needed. -Wpessimizing-move, part of -Wall on Clang and GCC, reports it.
- prvalue
- A pure rvalue: an expression that makes a value or initialises an object, such as 42, n + 1 or a call that returns by value. Since C++17 a prvalue initialises its target directly, so no temporary is copied or moved on the way.
- rvalue
- An xvalue or a prvalue: an expression whose object may be moved from, because nobody will look at it again. An rvalue reference, T&&, binds only to rvalues.
- temporary materialisation
- The step that turns a prvalue into a temporary object when an object is needed, for example to bind a reference or to reach a member, as in make_point().x. The temporary is an xvalue and ends with its full-expression.
- xvalue
- An expiring value: an expression that denotes an object whose resources may be taken, such as std::move(s), a call that returns T&&, or a member of a temporary. It has an identity, like an lvalue, and may be moved from, like a prvalue.
References
- Working Draft, Programming Languages: C++ (N4950): [basic.lval], [expr.prim.id.unqual], [dcl.type.decltype], [dcl.init.general], [class.copy.elision], [forward], [lib.types.movedfrom] (ISO/IEC JTC1/SC22/WG21)
- Working Draft, Programming Languages: C++ (N4950): Annex C, [diff.cpp20.expr], changes from C++20 (ISO/IEC JTC1/SC22/WG21)
- P0135R1: Wording for guaranteed copy elision through simplified value categories (ISO/IEC JTC1/SC22/WG21)
- P2266R3: Simpler implicit move (ISO/IEC JTC1/SC22/WG21)
- C++ Core Guidelines: F.48, don't return std::move(local) (Standard C++ Foundation)
- C++ Core Guidelines: ES.56, write std::move() only when you need to explicitly move an object to another scope (Standard C++ Foundation)
- Options Controlling C++ Dialect (-Wpessimizing-move, -Wredundant-move, -fno-elide-constructors) (GNU Project)
- Diagnostic flags in Clang (-Wmove, -Wpessimizing-move, -Wredundant-move) (LLVM Project)
- Clang command line argument reference (-fno-elide-constructors) (LLVM Project)
Report a problem with this lesson
Kept only in this browser. Your Learn progress