Recommended resources
The freely available resources this lesson will direct you to.
- Back to Basics: Understanding Value Categories - Ben Saks - CppCon 2019www.youtube.com
- Back to Basics: Forwarding References - How to Forward Parameters in Modern C++ - Mateusz Pusz 2023www.youtube.com
- Rvalue reference declarator: && | Microsoft Learnlearn.microsoft.com
- 22.2 — R-value references – Learn C++www.learncpp.com
Hello! In the previous lesson, you implemented copy and move operations for a resource-owning buffer. The remaining question is how the compiler chooses between them. A move constructor may exist, yet an apparently “movable” argument can still select a copy constructor.
This lesson gives you a way to classify an expression and predict which reference overload it selects. That skill is useful when reviewing ownership code and when a performance-sensitive call unexpectedly copies an object. Allow about 40 minutes, including the video and reading.
Value category describes an expression, not an object
Consider a string named message. The object has a type, std::string; the expression message has a value category, lvalue. If you write std::move(message), you still refer to the same object, but the new expression has a different category: xvalue. No transfer has happened merely because you wrote std::move.
Modern C++ has three fundamental expression categories:
| Category | Useful interpretation | Examples |
|---|---|---|
| lvalue | Identifies an object or function without marking it as expiring | message, *ptr, a function call returning T& |
| xvalue | Identifies an object whose resources may be reused | std::move(message), a function call returning T&& |
| prvalue | Computes a value | 42, a + b for integers, a function call returning T by value |
The broader terms in the diagram group these categories. Glvalues comprise lvalues and xvalues: they identify something. Rvalues comprise xvalues and prvalues. Thus an xvalue belongs to both broader groups; “glvalue versus rvalue” is not an either-or choice.

The old mnemonic “an lvalue can go on the left of =” is insufficient. A const object is still an lvalue even though you cannot assign to it. Nor does “has an address” define the categories: a temporary can be materialized as an object when a reference binds to it. Classify the expression as written, rather than guessing where the compiler will store its result.
Watch Ben Saks’s Back to Basics: Understanding Value Categories on CppCon. The short first selection shows why const breaks the old assignment mnemonic. The later selection connects reference binding, moving, and the complete category hierarchy. The speaker develops some rules historically; use the diagram above as your final C++20 model.
Back to Basics: Understanding Value Categories - Ben Saks - CppCon 2019
Watch Ben Saks’s Back to Basics: Understanding Value Categories (CppCon) to connect the category names to copy and move behavior.
Watch const lvalues for an object that is an lvalue but cannot be assigned to. Then watch moves and categories: focus on the copy-versus-move assignment example, why a named rvalue-reference parameter acts as an lvalue, what std::move changes, and the hierarchy shown at the end.
A few classifications are worth keeping close at hand:
int n = 3;
const int fixed = 4;
int* ptr = &n;
n; // lvalue
fixed; // lvalue, despite const
*ptr; // lvalue, despite having no variable name of its own
n + fixed; // prvalue
std::move(n); // xvalue
"feed"; // lvalue: a string literal is an array
These comments classify each indicated expression itself. Using n as an operand of addition may involve an implicit lvalue-to-rvalue conversion to read its value; that does not make the original expression n an xvalue or prvalue.
Reference binding narrows the candidates
Before choosing the best overload, the compiler rules out overloads whose references cannot bind to the argument. For a type T, the core binding rules are:
| Parameter | Lvalue T |
Lvalue const T |
Xvalue or prvalue T |
Xvalue or prvalue const T |
|---|---|---|---|---|
T& |
Yes | No | No | No |
const T& |
Yes | Yes | Yes | Yes |
T&& |
No | No | Yes | No |
const T&& |
No | No | Yes | Yes |
Here the argument and parameter have the same underlying type. Conversions between different types can add temporary objects and more overload-resolution considerations. Notice particularly that const T& accepts rvalues, while T&& does not accept a const rvalue: a move operation generally needs permission to modify its source.
Read the worked overload example in LearnCpp’s R-value references. It also isolates a distinction that matters whenever a move operation passes its parameter to another function: the type of a reference variable does not determine the category of an expression naming it.
22.2 — R-value references – Learn C++
Read LearnCpp’s R-value references for a compact reference-binding example and the named-reference pitfall.
In “R-value references as function parameters,” start at the sentence “R-value references are more often used as function parameters.” Read the overload examples through “Rvalue reference variables are lvalues,” stopping at the sentence “The type of an object and its value category are independent.” Focus on why the literal selects int&& but a named int&& variable does not. You can stop before “Returning an r-value reference” and the quiz.
Binding is not itself copying or moving. Calling a function whose parameter is T&& merely binds a reference. A copy or move occurs only if code constructs or assigns an object using a suitable operation.
Predict the selected overload
Here is a small overload set. Each function reports which parameter received the argument:
#include <iostream>
#include <string>
#include <utility>
void accept(std::string&) { std::cout << "mutable lvalue\n"; }
void accept(const std::string&) { std::cout << "const lvalue\n"; }
void accept(std::string&&) { std::cout << "mutable rvalue\n"; }
int main() {
std::string live{"bid"};
const std::string frozen{"ask"};
std::string&& alias = std::string{"trade"};
accept(live); // mutable lvalue
accept(frozen); // const lvalue
accept(std::string{"fresh"}); // mutable rvalue
accept(std::move(live)); // mutable rvalue
accept(alias); // mutable lvalue
accept(std::move(frozen)); // const lvalue
}
A reliable prediction takes three passes:
- Classify the argument expression.
live,frozen, andaliasare lvalues. Constructingstd::string{"fresh"}produces a prvalue. Bothstd::move(...)expressions are xvalues. - Keep its
constqualification.std::move(frozen)is aconstxvalue. The cast changes the value category; it does not removeconst. - Find viable bindings, then the best match. For
live, bothstd::string&andconst std::string&can bind, but the non-constreference is the better match. For a mutable prvalue or xvalue,std::string&&is preferred overconst std::string&. Forstd::move(frozen),std::string&&cannot bind because the source isconst, soconst std::string&wins.
The alias call is the common surprise. The declaration gives alias the type std::string&&, but the expression alias is an lvalue: it names an object that code can continue to use. To present that object as an xvalue again, write std::move(alias).
This explains a detail of the previous lesson’s move constructor:
ByteBuffer(ByteBuffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr)),
size_(std::exchange(other.size_, 0)) {}
Inside the constructor, other is an lvalue expression. The code transfers its members explicitly with std::exchange; it does not rely on the parameter’s && spelling to transfer anything automatically.
Also distinguish selecting an rvalue-reference overload from performing a move. In the accept example, no overload transfers resources. Conversely, constructing a new string from the const object illustrates why std::move cannot force a move:
std::string copy = std::move(frozen); // Copies: std::string&& cannot bind to const.
For std::string, the copy constructor taking const std::string& remains available. A const T&& overload could distinguish const rvalues, but it usually cannot implement resource-stealing move semantics because it cannot modify the source.
Check return expressions and diagnose surprises
Function return types give another compact way to classify call expressions:
std::string make_value();
std::string& get_existing();
std::string&& get_expiring();
// make_value() is a prvalue.
// get_existing() is an lvalue.
// get_expiring() is an xvalue.
The function call’s category follows how the function returns, not whether the call expression has a name. Be careful with a real function returning a reference: the referenced object must still be alive. Returning an rvalue reference to a local object would dangle.
When inspecting unfamiliar code, decltype((expression)) can confirm the category at compile time. With the extra parentheses, it yields an lvalue-reference type for an lvalue expression, an rvalue-reference type for an xvalue, and a non-reference type for a prvalue:
#include <string>
#include <type_traits>
#include <utility>
std::string s;
static_assert(std::is_same_v<decltype((s)), std::string&>);
static_assert(std::is_same_v<decltype((std::move(s))), std::string&&>);
static_assert(std::is_same_v<decltype((std::string{"x"})), std::string>);
The parentheses matter: decltype(s) on an unparenthesized variable name reports its declared type, not this category-based result. This technique is useful for verifying a prediction, but the better code-review habit is to first ask what the expression identifies or computes, whether it is marked as expiring, and whether it is const.
Finally, do not infer a runtime copy solely from a prvalue’s ability to bind to T&&. For example, initializing an object directly from a same-type prvalue can use guaranteed copy elision in C++20. Overload selection, object construction, and actual resource transfer are related but distinct questions.
Conclusion
Value categories belong to expressions: lvalue, xvalue, or prvalue. For reference overloads, classify the argument, retain its const qualification, eliminate references that cannot bind, and then choose the best viable match. The two traps to remember are that a named T&& variable is an lvalue expression, and std::move produces an xvalue without transferring anything by itself.
The next lesson applies those rules to forwarding references and std::forward: a wrapper must pass an argument onward without accidentally turning every named parameter into an lvalue at the next call.