Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Three stacked boxes, lvalue, xvalue and prvalue; a bracket groups lvalue and xvalue as glvalue, another xvalue and prvalue as rvalue.Every expression is one of threelvaluean object you can namen, *p, "text", ++nlongest(s, t)xvaluean object to move fromstd::move(s)make_point().xprvalueinitialises an object42, n + 1, n++s + t, make_point()glvaluehas anidentityrvaluemay bemovedfromAn xvalue belongs to both groups.

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.

  1. lvalue: an object you can name. Examples: n, *p, "text", ++n and longest(s, t).
  2. xvalue: an object to move from. Examples: std::move(s) and make_point().x.
  3. 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:

categories.cpp C++ · categories.cpp
// 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);
}
Fifteen expressions, classified by both compilers Python · categories_session.py
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.
  • r is an lvalue, although it is declared int&& 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 what r may bind to, not what the expression r is.
  • ++n gives back n itself, an lvalue, while n++ gives back a copy of the old value, a prvalue.
  • make_point() is a prvalue, but make_point().x is 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) and static_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

  1. Question 1 of 2 int&& r = 5; What is the value category of the expression r?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: lvalue

    The && in the declaration says what r may bind to, not what the expression r is. Outside a return statement a variable's name is an lvalue, so passing r on as an rvalue needs std::move(r).

  2. Question 2 of 2 make_point() returns a Point by value. What is the category of make_point().x?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An xvalue, because the prvalue is turned into a temporary to reach the member, and that temporary ends with the statement

    A prvalue has no identity until it is materialised. Reaching a member forces that step, and the temporary it makes is an object whose resources may be taken: an xvalue.

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:

move_cast.cpp C++ · move_cast.cpp
// 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());
}
A cast, a move and a copy Python · move_cast_session.py
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:

Interactive · step by step A cast, a move and a copy Step through move_cast.cpp and watch which string owns each heap array. The right column holds the arrays.
A cast, a move and a copy, step 6 of 6. Showing std::string s (size 0), heap array, std::string&& r (a reference to s), std::string t (size 40), const std::string c, c's heap array, std::string u (size 40), u's own array. In focus: std::string u, u's own array.refers toownsownsownsstd::string ssize 0heap array40 × 'a'std::string&& ra reference to sstd::string tsize 40const std::string csize 40c's heap array40 × 'c'std::string usize 40u's own arraya copy of c's characters

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 s holds 40 characters. After std::string&& r = std::move(s);, what does s.size() return?

    Type a number.

    A hint

    Does binding a reference construct a new string?

    Show the answer: Question 1 of 2

    Answer: 40 characters

    std::move(s) only casts s to an xvalue, and binding a reference to it constructs nothing, so s keeps its 40 characters. The run printed s.size() = 40, r.size() = 40.

  2. Question 2 of 2 std::string t = std::move(s); has just run. What may code rely on about s, with any standard library?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It is a valid string with an unspecified value, so it may be assigned or destroyed, but not read for its old value

    A move leaves the source alive and valid. Its value is whatever the library left there, so the safe uses are assigning it, destroying it and calling functions with no precondition, such as size().

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:

returns.cpp C++ · returns.cpp
// 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));
}
Seven return statements on both compilers Python · returns_session.py
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 build t directly 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 return std::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; and return 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 an if with 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:

Interactive · stage by stage How a return statement makes the caller's object Step through the seven returns of returns.cpp. Each stage asks one question, from the top; the first that fits decides.
How a return statement makes the caller's object, step 8 of 8. Showing return expr;, A prvalue of the type?, Always the same local?, A local or a parameter?, Anything else. In focus: Anything else.return expr;what follows the keyword?A prvalue of the type?it makes nothing at allAlways the same local?NRVO may build it in placeA local or a parameter?moved: the implicit moveAnything elselvalue copies, xvalue moves

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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

  1. Question 1 of 2 Tracker named() { Tracker t; return t; } How many moves did the return make on both compilers, built with -Wall?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 0 moves

    Both compilers built t directly in the caller's object, the named return value optimisation: 0 copies and 0 moves.

  2. Question 2 of 2 Tracker member() { Pair p; return p.first; } What did the return make, and why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 1 copy, because a member is not a returned name, so the implicit move does not apply

    return p.first; copied with both compilers. return std::move(p.first); moves, which is safe because p ends with the function.

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:

Optional elision turned off, as C++23 and as C++14 Python · elision_off_session.py
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:

What each return statement made: with -Wall as C++23, then with -fno-elide-constructors as C++23 and as C++14
Criterion C++23C++23, elision offC++14, elision off
return Tracker{}; NothingNothing1 move
return t; Nothing1 move1 move
return std::move(t); 1 move1 move1 move
return a; or return b; 1 move1 move1 move
return t; (a parameter) 1 move1 move2 moves
return p.first; 1 copy1 copy1 copy
return first ? a : b; 1 copy1 copy1 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:

no_moves.cpp C++ · no_moves.cpp
// 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;
}
Returning a type that cannot be moved Python · no_moves_session.py
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

  1. Question 1 of 2 Built as C++23 with -fno-elide-constructors, what did return t; of a local make?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 1 move

    With elision off, return t; counted 0 copies and 1 move on both compilers, while return Tracker{}; still counted nothing: only the prvalue row is a rule of the language.

  2. Question 2 of 2 Guarded holds a std::mutex, so it can be neither copied nor moved. Which of these functions compile?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • Guarded a() { return Guarded{}; }
    • Guarded b() { return a(); }

    Since C++17 a prvalue of the return type needs no copy or move constructor at all. A named local always needs one, because the code must be valid without NRVO.

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:

implicit_move.cpp C++ · implicit_move.cpp
// 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;
}
The same file as C++20 and as C++23 Python · implicit_move_session.py
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:

Timeline · 5 events in order Returning an object, version by version Walk through the versions in order. Each event says what changed and what the runs of this lesson showed.
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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

  1. Question 1 of 2 std::string&& pass_on(std::string&& s) { return s; } What did both compilers say about it as C++20?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An error, because s is an lvalue there and an rvalue reference cannot bind to an lvalue

    A function that returns a reference initialises no object, so before C++23 nothing tried s as an rvalue, and binding std::string&& to the lvalue s failed.

  2. Question 2 of 2 std::string& dangling() { std::string s = "local"; return s; } What changed between C++20 and C++23?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A warning became an error, because s is now an xvalue, which a non-const lvalue reference cannot bind to

    P2266 makes a returned local an xvalue. std::string& cannot bind to an xvalue, so a reference to a dying local is rejected instead of compiling with a warning.

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

  1. Question 1 of 2 GCC is run with -Wall only, without -Wextra. Which of these return statements does it warn about?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: return std::move(t); for a local t

    With GCC, -Wall turns on -Wpessimizing-move and -Wextra turns on -Wredundant-move; with Clang, -Wall turns on both.

  2. Question 2 of 2 Rule ES.56 says to write std::move only to hand an object over to another scope. Which line follows it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: names.push_back(std::move(name)); where name is not read again

    std::move belongs where an object really changes hands and the code no longer needs its value. On a returned local or on a temporary it gets in the way of elision.

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.cpp C++ · predict.cpp
// 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);
}
The starter, with no prediction filled in Python · predict_session.py
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.

  1. Question 1 of 17 What is the value category of std::move(s), where s is a std::string variable?

    Choose one answer.

    Show the answer: Question 1 of 17

    Answer: xvalue

    std::move returns std::string&&, and a call to a function that returns an rvalue reference is an xvalue. The category never depends on the type's constructors; those only decide what happens when the xvalue initialises another string.

  2. Question 2 of 17 Which two categories together are the rvalues?

    Choose one answer.

    Show the answer: Question 2 of 17

    Answer: xvalues and prvalues

    An xvalue is in both groups: it has an identity, so it is a glvalue, and its resources may be taken, so it is an rvalue.

  3. Question 3 of 17 What happens to the caller's string when this function runs?

    Read the code, then choose one answer.

    void store(std::string&& s) {
        std::string kept = s;
    }
    Show the answer: Question 3 of 17

    Answer: It is copied, because s has a name, so the expression s is an lvalue

    The type of s is std::string&&, but the expression s names a variable, which makes it an lvalue. The copy constructor is chosen. Writing std::string kept = std::move(s); would move.

  4. Question 4 of 17 int n = 7; Which of the expressions ++n and n++ is an lvalue?

    Choose one answer.

    Show the answer: Question 4 of 17

    Answer: ++n, which gives back n itself

    ++n is n after the increment, an lvalue; n++ is the old value, a prvalue. The categories program printed both.

  5. Question 5 of 17 How does the standard define std::move(t)?

    Choose one answer.

    Show the answer: Question 5 of 17

    Answer: As static_cast<std::remove_reference_t<T>&&>(t), a cast to an rvalue reference

    The cast makes the expression an xvalue, which changes only which function is chosen next.

  6. Question 6 of 17 const std::string c(40, 'c'); std::string u = std::move(c); What runs?

    Choose one answer.

    Show the answer: Question 6 of 17

    Answer: The copy constructor, because a const std::string&& cannot bind to the move constructor's parameter

    std::move(c) is an xvalue of type const std::string. The move constructor takes std::string&&, which cannot bind to a const object, so overload resolution picks the copy constructor. The run shows c keeping its 40 characters and u getting its own array.

  7. Question 7 of 17 What does b get, and what is left in a?

    Read the code, then choose one answer.

    std::string a(30, 'x');
    std::string&& r = std::move(a);
    std::string b = r;
    Show the answer: Question 7 of 17

    Answer: A copy of the 30 characters, and a keeps them too

    Two rules meet here: a cast moves nothing by itself, and the name of an rvalue reference is an lvalue. Both strings end with 30 characters.

  8. Question 8 of 17 one_of_two() returns one of two local Trackers, chosen at run time. How many moves did its return make on both compilers?

    Type a number.

    Show the answer: Question 8 of 17

    Answer: 1 moves

    Neither compiler built either local in place, because which one is returned is known only at run time. The returned local was moved by the implicit move: 0 copies, 1 move.

  9. Question 9 of 17 Tracker make() { Tracker t; return std::move(t); } What does the std::move change?

    Choose one answer.

    Show the answer: Question 9 of 17

    Answer: It stops the compiler from building t in the caller's object, so a move happens where none was needed

    return t; lets both compilers construct t directly in the result (named return value optimisation): 0 copies and 0 moves in the lesson's run. std::move(t) is not the name of a local, so that optimisation no longer applies and the move constructor runs once. Both compilers warn about it with -Wall (-Wpessimizing-move).

  10. Question 10 of 17 return first ? a : b; with two local Trackers copies. Which rewrite moves instead?

    Choose one answer.

    Show the answer: Question 10 of 17

    Answer: if (first) return a; return b;

    The implicit move needs a returned name. Splitting the conditional into two return statements gives it one in each branch.

  11. Question 11 of 17 Which of these does the standard require since C++17?

    Choose one answer.

    Show the answer: Question 11 of 17

    Answer: return Tracker{}; initialising the caller's object directly

    P0135R1 made the prvalue rule part of C++17. Everything else that saves a move in a return is the compiler's choice.

  12. Question 12 of 17 Since C++17, which of these return statements are guaranteed to make neither a copy nor a move? (make() returns a Tracker by value.)

    Choose every answer that is right.

    Show the answer: Question 12 of 17

    Answer:

    • return make();
    • return Tracker{};

    Tracker{} and make() are prvalues of the return type, so they initialise the caller's object directly, a rule of the language that works even when Tracker cannot be copied or moved. Returning a named local is elided by both compilers by default, but the standard only allows it, and -fno-elide-constructors turns it into a move.

  13. Question 13 of 17 With elision off, parameter(Tracker{}) counted 2 moves as C++14 and 1 as C++23. Where did the extra move of C++14 come from?

    Choose one answer.

    Show the answer: Question 13 of 17

    Answer: From building the parameter, because before C++17 the prvalue Tracker{} made a temporary that was moved into it

    The guaranteed rule applies to every prvalue that initialises an object of its type, a parameter as much as a result. As C++14, both steps cost a move once the optional elision was off.

  14. Question 14 of 17 In C++23, what is the category of s in return s; when s is a local variable?

    Choose one answer.

    Show the answer: Question 14 of 17

    Answer: xvalue

    In a return statement, C++23 treats the name of a local or of an rvalue-reference parameter as an xvalue. Returns by value count the same as before; references to dying locals stop compiling.

  15. Question 15 of 17 std::string& f() { std::string s = "local"; return s; } What do the two compilers do with -std=c++23?

    Choose one answer.

    Show the answer: Question 15 of 17

    Answer: Both reject it, because the returned name is now an xvalue and a non-const lvalue reference cannot bind to it

    With C++23's simpler implicit move (P2266), s in the return statement is an xvalue. In C++20 the code compiled with a dangling-reference warning; in C++23 it is an error with Apple clang 21 and GCC 16, as the lesson's run shows.

  16. Question 16 of 17 Which warning reports return std::move(t); for a local t, and which flag group turns it on?

    Choose one answer.

    Show the answer: Question 16 of 17

    Answer: -Wpessimizing-move, part of -Wall with both compilers

    A pessimizing move costs a move that NRVO would have saved, and both compilers report it under -Wall.

  17. Question 17 of 17 What does a redundant move, return std::move(p); for a by-value parameter p, cost at run time?

    Choose one answer.

    Show the answer: Question 17 of 17

    Answer: Nothing, because p is moved either way; the cast only tells the reader something false

    Redundant and pessimizing moves differ in cost: the redundant one makes the same single move, while the pessimizing one adds a move that would not have happened.

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

  1. Question 1 of 2 Follow-up: a getter returns std::string by value with return name_;, a member. Should it be return std::move(name_);?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No, because the object keeps living after the call, so its member must keep its value

    return name_; copies, and that is correct for a getter. std::move on a member is for a function whose object is ending, such as a local Pair whose first member is returned.

  2. Question 2 of 2 Follow-up: a class deletes its copy and move constructors. Can a factory function still return one by value?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Yes, by returning a prvalue such as return Widget{}; which needs no constructor since C++17

    Guaranteed copy elision is what lets a factory return a type such as one holding a std::mutex: build the result as a prvalue in the return statement.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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::move is 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-constructors turns 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 -Wall warns about it on Clang and GCC: write std::move only 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.