AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

09 Copies, moves and the rule of five / Lesson 5 of 5

Value categories, temporaries and copy elision

15 min · +10 XP · 2 exercises · 7 questions

Read the text15 min
Exercises0/2
Quiz7 q

Quick answer

What is the difference between an lvalue and an rvalue in C++?

In C++, an lvalue is an expression that names an object which persists beyond it, such as a variable, *p or v[i]; you can take its address. An rvalue is either a prvalue — a pure value with no object yet, like 42, a + b or a call returning by value — or an xvalue, such as std::move(a), whose contents may be taken. T&& binds only to rvalues.

Every C++ expression has a type and a value category, and the category is what decides whether an initialisation copies, moves or does neither. The three categories have unlovely names — lvalue, prvalue, xvalue — but the ideas behind them are plain: does the expression name an object that persists, is it a pure value that has not been given a home yet, or is it an object whose contents may be taken? This lesson gives the taxonomy in those terms, explains when a temporary is created and when it dies, states the C++17 guarantee that a prvalue initialises its target with no copy at all, and settles the two return-statement questions every C++ interviewer asks.

Three categories in plain terms

CategoryMeaningExamples
lvaluenames an object that outlives the expressiona, *p, v[i], s.name, a call returning T&
prvaluea pure value with no object yet42, a + b, Probe(3), a call returning T
xvaluean object whose contents may be reusedstd::move(a), a call returning T&&, Probe(3).value

Two groupings matter. An lvalue or xvalue has identity — it refers to an actual object — so together they are glvalues. A prvalue or xvalue may be moved from, so together they are rvalues. When lesson 3 said T&& binds to rvalues, it meant both kinds: a temporary from a prvalue, or an lvalue that std::move has turned into an xvalue.

The category is a property of the expression, not the variable. Probe&& r = std::move(a); declares r as an rvalue reference, but the expression r is an lvalue — it has a name — so Probe b = r; copies. That is why a forwarding function has to write std::move(r) again.

Which reference binds to which

void f(Probe& p);         // lvalues only
void g(const Probe& p);   // anything
void h(Probe&& p);        // rvalues only

Probe a(1);
f(a);  g(a);  h(std::move(a));   // h(a) does not compile: a is an lvalue
g(Probe(2));  h(Probe(2));       // f(Probe(2)) does not compile: a temporary is not an lvalue

With g and h both present, an rvalue argument picks h — the more specific match — which is how the copy constructor and move constructor are chosen. With only g present, an rvalue argument binds to const Probe& and is copied. That last case is what "declaring a copy suppresses the move" from lesson 4 looks like at the call site.

Temporaries and their lifetime

A prvalue becomes an object — is materialised into a temporary — only when something needs an object: binding it to a reference, calling a member function on it, passing it to a by-reference parameter. The temporary lives until the end of the full-expression that created it, usually the semicolon:

std::size_t n = (std::string("hello") + " world").size();   // the temporary string dies at the ;

Binding a temporary directly to a const T& or T&& local extends its lifetime to the reference's:

const std::string& greeting = makeGreeting();   // the temporary lives as long as greeting
std::string&& owned = makeGreeting();           // same, and owned may be modified

Extension applies only to the direct binding. A reference obtained through the temporary — const std::string& first = makePair().first; extends, but const char* p = makeGreeting().c_str(); leaves p dangling the moment the statement ends. Module 5, lesson 6 showed the same trap with std::string_view.

Guaranteed copy elision

Since C++17, initialising an object from a prvalue of the same type does not copy and does not move: the prvalue's value is constructed directly in the target. There is no temporary to elide, which is why the standard calls this "unmaterialised value passing" rather than an optimisation.

Probe make(int v) { return Probe(v); }       // prvalue returned

Probe a = Probe(1);        // construct #1 — one event, no copy, no move
Probe b = make(2);         // construct #2 — the object is built in b
take(Probe(3));            // construct #3 — built in the parameter
Probe c = flag ? Probe(4) : Probe(5);       // both arms are prvalues: still one construct

Because no copy or move is performed, none is required: a factory static Widget create() { return Widget(...); } works for a type whose copy and move constructors are deleted. This is what makes returning by value the right default — the call make(2) costs exactly one construction wherever its result ends up.

Named return value optimisation

return Probe(v); is a prvalue and always elides. return local; is different: local is an lvalue with a name, and the compiler is permitted, not required, to build it directly in the caller's object. This is NRVO, named return value optimisation. Every major compiler does it whenever the function has one local that every return statement returns; it cannot when different paths return different locals, when the returned object is a parameter, or when the returned expression is not plain local.

When NRVO does not happen, the fallback is still not a copy. A return statement that names a local variable (or a by-value parameter) treats it as an rvalue first — implicit move — so the return object is move-constructed from it. Returning a parameter is the fully deterministic case: NRVO is never applied to parameters, so Probe pass(Probe p) { return p; } always performs exactly one move construction. In a trace, that shows as one move event; return Probe(v) shows none.

Two rules for return

You writeWhat happensVerdict
return local;NRVO (usually), else implicit moveright
return Probe(v);guaranteed elisionright
return std::move(local);NRVO disabled; a move alwayspessimisation; GCC and Clang warn with -Wpessimizing-move
return std::move(param);one move, same as return param;redundant; -Wredundant-move
const Probe& f() { Probe p; return p; }reference to a destroyed localundefined behaviour

The pessimisation is the most common misuse of std::move in real code. std::move(local) is an xvalue, not a name, so it no longer qualifies for NRVO; the compiler must do the move it was previously allowed to skip. Return by value, return the name, and let the compiler choose.

std::move on a const object copies

const Probe k(7);
Probe m = std::move(k);       // copy #… from k: quietly

std::move(k) yields const Probe&&. The move constructor takes Probe&&, which cannot bind to a const object, so overload resolution falls through to const Probe& — the copy constructor. No error, no warning by default, just a copy where a move was intended. The same happens for a const member: a class with a const std::string name; can never move that member, so its generated move constructor copies it. The lesson is that const and moving are opposites, which is why a class's data members are almost never const.

Pitfalls

MistakeWhat happens
return std::move(local);Disables NRVO and forces a move
const char* p = makeString().c_str();Dangling as soon as the statement ends
Treating a named T&& as an rvalueIt is an lvalue; the callee copies unless you std::move it again
std::move on a const object or memberSelects the copy constructor without a diagnostic
Assuming NRVO in a trace or a testPermitted, not guaranteed; only prvalue elision and parameter returns are certain
Returning a reference to a localUndefined behaviour; return by value instead

Key takeaways

  • lvalue: has a name and persists; prvalue: a pure value not yet stored; xvalue: an object whose contents may be taken. Rvalues are prvalues and xvalues; T&& binds to those.
  • A named rvalue reference is an lvalue; forwarding it on needs another std::move.
  • A temporary dies at the end of its full-expression unless bound directly to a const T& or T&&, which extends it — not through a member function's return.
  • C++17 guarantees that initialising from a prvalue of the same type performs no copy or move at all; return T(...) and the object is built in place.
  • return local; gets NRVO or an implicit move; return std::move(local); gets only the move. Returning a by-value parameter is always exactly one move.
  • std::move on a const object or member silently copies.

Common questions

What is copy elision in C++?

Copy elision is constructing an object directly in its final location instead of copying or moving a temporary into it. Since C++17 it is guaranteed when an object is initialised from a prvalue of the same type, as in T a = T(1); or return T(v);, so it works even for types whose copy and move constructors are deleted.

What is NRVO in C++?

Named return value optimisation lets the compiler build a returned local variable directly in the caller's object, skipping the copy or move. Unlike prvalue elision it is permitted, not guaranteed; when it does not happen, return local; falls back to an implicit move rather than a copy.

Should I use return std::move(local);?

No. std::move(local) is not a plain name, so it disqualifies NRVO and forces a move the compiler could have skipped; GCC and Clang warn with -Wpessimizing-move. Write return local; and the compiler applies NRVO or, failing that, an implicit move.

How long does a temporary live in C++?

Until the end of the full-expression that created it, usually the semicolon. Binding it directly to a const T& or T&& local extends its lifetime to the reference's, but only for a direct binding: const char* p = makeString().c_str(); leaves p dangling when the statement ends.

Why does std::move on a const object copy?

std::move(k) on a const T yields const T&&, which cannot bind to the move constructor's T&& parameter, so overload resolution picks the copy constructor instead, with no error or warning. For the same reason a const data member is always copied, never moved.

Mark as readSign in to keep your place.

Lvalue vs Rvalue in C++: Copy Elision and NRVO Explained

Every C++ expression is an lvalue, prvalue or xvalue, and the category decides whether it is copied or moved. Temporaries, copy elision and NRVO.

  • Course: C++ study plan
  • Module: Copies, moves and the rule of five
  • Kind: Lesson
  • Reading time: 15 min
  • Runtime: Clang 18 · C++20

What is the difference between an lvalue and an rvalue in C++?

In C++, an lvalue is an expression that names an object which persists beyond it, such as a variable, *p or v[i]; you can take its address. An rvalue is either a prvalue — a pure value with no object yet, like 42, a + b or a call returning by value — or an xvalue, such as std::move(a), whose contents may be taken. T&& binds only to rvalues.

Lesson

Every C++ expression has a type and a value category, and the category is what decides whether an initialisation copies, moves or does neither. The three categories have unlovely names — lvalue, prvalue, xvalue — but the ideas behind them are plain: does the expression name an object that persists, is it a pure value that has not been given a home yet, or is it an object whose contents may be taken? This lesson gives the taxonomy in those terms, explains when a temporary is created and when it dies, states the C++17 guarantee that a prvalue initialises its target with no copy at all, and settles the two return-statement questions every C++ interviewer asks.

Three categories in plain terms

CategoryMeaningExamples
lvaluenames an object that outlives the expressiona, *p, v[i], s.name, a call returning T&
prvaluea pure value with no object yet42, a + b, Probe(3), a call returning T
xvaluean object whose contents may be reusedstd::move(a), a call returning T&&, Probe(3).value

Two groupings matter. An lvalue or xvalue has identity — it refers to an actual object — so together they are glvalues. A prvalue or xvalue may be moved from, so together they are rvalues. When lesson 3 said T&& binds to rvalues, it meant both kinds: a temporary from a prvalue, or an lvalue that std::move has turned into an xvalue.

The category is a property of the expression, not the variable. Probe&& r = std::move(a); declares r as an rvalue reference, but the expression r is an lvalue — it has a name — so Probe b = r; copies. That is why a forwarding function has to write std::move(r) again.

Which reference binds to which

void f(Probe& p);         // lvalues only
void g(const Probe& p);   // anything
void h(Probe&& p);        // rvalues only

Probe a(1);
f(a);  g(a);  h(std::move(a));   // h(a) does not compile: a is an lvalue
g(Probe(2));  h(Probe(2));       // f(Probe(2)) does not compile: a temporary is not an lvalue

With g and h both present, an rvalue argument picks h — the more specific match — which is how the copy constructor and move constructor are chosen. With only g present, an rvalue argument binds to const Probe& and is copied. That last case is what "declaring a copy suppresses the move" from lesson 4 looks like at the call site.

Temporaries and their lifetime

A prvalue becomes an object — is materialised into a temporary — only when something needs an object: binding it to a reference, calling a member function on it, passing it to a by-reference parameter. The temporary lives until the end of the full-expression that created it, usually the semicolon:

std::size_t n = (std::string("hello") + " world").size();   // the temporary string dies at the ;

Binding a temporary directly to a const T& or T&& local extends its lifetime to the reference's:

const std::string& greeting = makeGreeting();   // the temporary lives as long as greeting
std::string&& owned = makeGreeting();           // same, and owned may be modified

Extension applies only to the direct binding. A reference obtained through the temporary — const std::string& first = makePair().first; extends, but const char* p = makeGreeting().c_str(); leaves p dangling the moment the statement ends. Module 5, lesson 6 showed the same trap with std::string_view.

Guaranteed copy elision

Since C++17, initialising an object from a prvalue of the same type does not copy and does not move: the prvalue's value is constructed directly in the target. There is no temporary to elide, which is why the standard calls this "unmaterialised value passing" rather than an optimisation.

Probe make(int v) { return Probe(v); }       // prvalue returned

Probe a = Probe(1);        // construct #1 — one event, no copy, no move
Probe b = make(2);         // construct #2 — the object is built in b
take(Probe(3));            // construct #3 — built in the parameter
Probe c = flag ? Probe(4) : Probe(5);       // both arms are prvalues: still one construct

Because no copy or move is performed, none is required: a factory static Widget create() { return Widget(...); } works for a type whose copy and move constructors are deleted. This is what makes returning by value the right default — the call make(2) costs exactly one construction wherever its result ends up.

Named return value optimisation

return Probe(v); is a prvalue and always elides. return local; is different: local is an lvalue with a name, and the compiler is permitted, not required, to build it directly in the caller's object. This is NRVO, named return value optimisation. Every major compiler does it whenever the function has one local that every return statement returns; it cannot when different paths return different locals, when the returned object is a parameter, or when the returned expression is not plain local.

When NRVO does not happen, the fallback is still not a copy. A return statement that names a local variable (or a by-value parameter) treats it as an rvalue first — implicit move — so the return object is move-constructed from it. Returning a parameter is the fully deterministic case: NRVO is never applied to parameters, so Probe pass(Probe p) { return p; } always performs exactly one move construction. In a trace, that shows as one move event; return Probe(v) shows none.

Two rules for return

You writeWhat happensVerdict
return local;NRVO (usually), else implicit moveright
return Probe(v);guaranteed elisionright
return std::move(local);NRVO disabled; a move alwayspessimisation; GCC and Clang warn with -Wpessimizing-move
return std::move(param);one move, same as return param;redundant; -Wredundant-move
const Probe& f() { Probe p; return p; }reference to a destroyed localundefined behaviour

The pessimisation is the most common misuse of std::move in real code. std::move(local) is an xvalue, not a name, so it no longer qualifies for NRVO; the compiler must do the move it was previously allowed to skip. Return by value, return the name, and let the compiler choose.

std::move on a const object copies

const Probe k(7);
Probe m = std::move(k);       // copy #… from k: quietly

std::move(k) yields const Probe&&. The move constructor takes Probe&&, which cannot bind to a const object, so overload resolution falls through to const Probe& — the copy constructor. No error, no warning by default, just a copy where a move was intended. The same happens for a const member: a class with a const std::string name; can never move that member, so its generated move constructor copies it. The lesson is that const and moving are opposites, which is why a class's data members are almost never const.

Pitfalls

MistakeWhat happens
return std::move(local);Disables NRVO and forces a move
const char* p = makeString().c_str();Dangling as soon as the statement ends
Treating a named T&& as an rvalueIt is an lvalue; the callee copies unless you std::move it again
std::move on a const object or memberSelects the copy constructor without a diagnostic
Assuming NRVO in a trace or a testPermitted, not guaranteed; only prvalue elision and parameter returns are certain
Returning a reference to a localUndefined behaviour; return by value instead

Key takeaways

  • lvalue: has a name and persists; prvalue: a pure value not yet stored; xvalue: an object whose contents may be taken. Rvalues are prvalues and xvalues; T&& binds to those.
  • A named rvalue reference is an lvalue; forwarding it on needs another std::move.
  • A temporary dies at the end of its full-expression unless bound directly to a const T& or T&&, which extends it — not through a member function's return.
  • C++17 guarantees that initialising from a prvalue of the same type performs no copy or move at all; return T(...) and the object is built in place.
  • return local; gets NRVO or an implicit move; return std::move(local); gets only the move. Returning a by-value parameter is always exactly one move.
  • std::move on a const object or member silently copies.

Common questions

What is copy elision in C++?

Copy elision is constructing an object directly in its final location instead of copying or moving a temporary into it. Since C++17 it is guaranteed when an object is initialised from a prvalue of the same type, as in T a = T(1); or return T(v);, so it works even for types whose copy and move constructors are deleted.

What is NRVO in C++?

Named return value optimisation lets the compiler build a returned local variable directly in the caller's object, skipping the copy or move. Unlike prvalue elision it is permitted, not guaranteed; when it does not happen, return local; falls back to an implicit move rather than a copy.

Should I use return std::move(local);?

No. std::move(local) is not a plain name, so it disqualifies NRVO and forces a move the compiler could have skipped; GCC and Clang warn with -Wpessimizing-move. Write return local; and the compiler applies NRVO or, failing that, an implicit move.

How long does a temporary live in C++?

Until the end of the full-expression that created it, usually the semicolon. Binding it directly to a const T& or T&& local extends its lifetime to the reference's, but only for a direct binding: const char* p = makeString().c_str(); leaves p dangling when the statement ends.

Why does std::move on a const object copy?

std::move(k) on a const T yields const T&&, which cannot bind to the move constructor's T&& parameter, so overload resolution picks the copy constructor instead, with no error or warning. For the same reason a const data member is always copied, never moved.

Exercises

Elision, traced

Complete the five special members of Probe — copy constructor (copy #<id> from #<source>), copy assignment (copy-assign …), move constructor (move …, and the source's value becomes 0), move assignment (move-assign …, source zeroed) — and write Probe make(int v), which returns Probe(v), and Probe pass(Probe p), which returns p. Then predict every line of this driver before running it:

  1. Read v1 v2 v3 v4.
  2. Probe a = Probe(v1); — a prvalue initialiser: guaranteed elision, one construct.
  3. Probe b = make(v2); — a prvalue returned: still one construct.
  4. Probe c = pass(b); — b is copied into the parameter, the parameter is returned with an implicit move (never NRVO — it is a parameter), then the parameter is destroyed.
  5. c = Probe(v3); — a temporary is constructed, move-assigned, destroyed.
  6. const Probe k(v4); then Probe d = std::move(k); — a const object cannot be moved from: watch which event appears.
  7. Probe e = std::move(a);
  8. Print values a=<> b=<> c=<> d=<> e=<>.
  9. Everything dies in reverse order.

Input: four integers. Output: the trace, the values line, and six destroy lines.

1 2 3 7

prints

construct #1=1
construct #2=2
copy #3 from #2
move #4 from #3
destroy #3
construct #5=3
move-assign #4 from #5
destroy #5
construct #6=7
copy #7 from #6
move #8 from #1
values a=0 b=2 c=3 d=7 e=1
destroy #8
destroy #7
destroy #6
destroy #4
destroy #2
destroy #1

Sink parameters

Inbox stores Probe objects in a std::vector and takes new ones through a sink parameter — void add(Probe p) — which it moves into the vector with items_.push_back(std::move(p)). The constructor reserves room for every item up front, so no reallocation ever happens and every event is fixed by the standard. Complete the five-special-member Probe, add, drain (which pops items from the back until the vector is empty, so the destruction order is defined) and total.

Read n, then n lines of <form> <value>, and call add in the matching way:

  • copy v: in a block, Probe p(v); inbox.add(p); — the lvalue is copied into the parameter, the parameter is moved into the vector, the parameter dies, then p dies at the block's end;
  • move v: in a block, Probe p(v); inbox.add(std::move(p)); — moved into the parameter, moved into the vector;
  • temp v: inbox.add(Probe(v)); — the prvalue is built directly in the parameter (guaranteed elision), then moved into the vector.

After the loop print total=<sum of the stored values>, then inbox.drain().

Input: n, then n lines. Output: the trace, the total, and the n destroy lines from drain.

3
copy 5
move 6
temp 7

prints

construct #1=5
copy #2 from #1
move #3 from #2
destroy #2
destroy #1
construct #4=6
move #5 from #4
move #6 from #5
destroy #5
destroy #4
construct #7=7
move #8 from #7
destroy #7
total=18
destroy #8
destroy #6
destroy #3

In this module: Copies, moves and the rule of five

← The rule of five and the rule of zero · Checkpoint — Copies, moves and the rule of five →