Value Categories: lvalue, rvalue, xvalue
C++ value categories explained — lvalue, prvalue, xvalue, glvalue, rvalue, move semantics connection, decltype rules, and std::move vs std::forward.
Value Categoriessince C++11Every C++ expression belongs to exactly one of five value categories — lvalue, prvalue, xvalue, glvalue, or rvalue — which determine whether the expression has identity (a stable, addressable location) and whether it is eligible to be moved from.
Overview
Before C++11, C++ had a simple lvalue/rvalue split: lvalues could appear on the left of an assignment, rvalues could not. C++11 replaced this with a taxonomy derived from two orthogonal properties:
- Identity — does the expression refer to a specific, persistent object with a takeable address?
- Movability — is it safe to steal resources from this expression?
expression
/ \
glvalue rvalue
/ \ / \
lvalue xvalue xvalue prvalueThe three primary categories are mutually exclusive. The two composite categories are unions:
- glvalue = lvalue ∪ xvalue — expressions with identity
- rvalue = xvalue ∪ prvalue — expressions that are movable
| Category | Identity | Movable |
|---|---|---|
| lvalue | ✅ | ❌ |
| prvalue | ❌ | ✅ |
| xvalue | ✅ | ✅ |
The Three Primary Categories
lvalue — identity without movability
An lvalue refers to a specific, addressable object that persists beyond the current expression. The historical name ("left-hand side of assignment") is misleading — the real test is whether &expr is valid.
int x = 42;
&x; // valid — x is an lvalue
int& ref = x; // lvalue reference — binds to lvalues only
*ptr; // lvalue — indirection through a pointer
arr[2]; // lvalue — subscript of array lvalue
v.front(); // lvalue — std::vector::front() returns T&
// A function returning T& yields an lvalue — it can be assigned to:
std::map<int,int> m;
m[5] = 99; // operator[] returns int& — lvalue on the left of =Every named variable is an lvalue regardless of its declared type. A variable declared int&& is an lvalue inside its own scope because it has a name and therefore an address.
prvalue — movable, no identity
A prvalue ("pure rvalue") does not refer to an existing object. It is a computation result: a literal, an arithmetic expression, or a by-value function return.
42; // prvalue — integer literal
x + y; // prvalue — arithmetic expression
Widget{}; // prvalue — unnamed temporary
make_widget(); // prvalue — function returning T by value
std::string{"hello"}; // prvalue
// prvalues bind to rvalue references and to const lvalue references:
std::string&& r = std::string{"hi"}; // rvalue reference — lifetime extended
const int& cr = 42; // const lvalue ref — lifetime extendedIn C++17, a prvalue is not a concrete object — it is a "recipe for initialization." No storage is allocated, no constructor is called, until the prvalue is materialized into an actual object. This distinction is the foundation of guaranteed copy elision.
xvalue — identity with movability
An xvalue ("expiring value") refers to an existing object whose resources may be stolen. It has an address, but something has signaled that the value is no longer needed.
std::move(x); // xvalue — cast from lvalue to T&&
static_cast<int&&>(x); // xvalue — explicit cast, same effect
// Non-static data member of an xvalue is also an xvalue:
struct Box { std::string payload; };
Box b;
std::move(b).payload; // xvalue — member access on xvalue is xvalue
// A function declared to return T&& yields an xvalue at the call site:
Widget&& factory();
factory(); // xvaluestd::move does not move anything — it is a cast that converts an lvalue to an xvalue, enabling the compiler to select move overloads:
// C++11 implementation
template<typename T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
return static_cast<std::remove_reference_t<T>&&>(t);
}Reference Binding Rules
| Expression category | T& | const T& | T&& |
|---|---|---|---|
| lvalue | ✅ | ✅ | ❌ |
| const lvalue | ❌ | ✅ | ❌ |
| xvalue | ❌ | ✅ (extends lifetime) | ✅ |
| prvalue | ❌ | ✅ (extends lifetime) | ✅ |
When both const T& and T&& overloads exist, overload resolution selects T&& for xvalues and prvalues — it is the more specific match.
void process(Widget& w); // lvalues only
void process(const Widget& w); // lvalues and rvalues
void process(Widget&& w); // xvalues and prvalues (preferred over const&)
Widget w;
process(w); // process(Widget&)
process(Widget{}); // process(Widget&&)
process(std::move(w)); // process(Widget&&)decltype and Value Categories
decltype encodes value category into its result type:
- Named entity (not a parenthesized expression) → the declared type
- lvalue expression →
T& - xvalue expression →
T&& - prvalue expression →
T
int x = 0;
int* p = &x;
decltype(x) // int — name; just the declared type
decltype((x)) // int& — lvalue expression → reference added
decltype(42) // int — prvalue → no reference
decltype(*p) // int& — dereference is lvalue
decltype(std::move(x)) // int&& — xvalue → rvalue reference
decltype(x + 0) // int — arithmetic prvalueThe parenthesized-variable distinction matters critically with decltype(auto) (C++14):
decltype(auto) safe() { int x = 0; return x; } // deduces int
decltype(auto) dangling() { int x = 0; return (x); } // deduces int& — UB!return (x) is an lvalue expression, so decltype(auto) deduces int& — a dangling reference to a destroyed local. Clang and GCC warn at -Wall but will compile it.
Forwarding References
A T&& where T is a deduced template parameter (or auto&& in C++14) is a forwarding reference. Reference collapsing rules allow it to bind to any value category:
template<typename T>
void relay(T&& arg) {
// arg is ALWAYS an lvalue inside this function — it has a name.
// T deduces to:
// int& when called with an lvalue → T&& collapses to int&
// int when called with an rvalue → T&& stays int&&
downstream(std::forward<T>(arg)); // C++11 — preserves original category
}
int n = 5;
relay(n); // T = int&, forwards as lvalue
relay(42); // T = int, forwards as rvalue
relay(std::move(n)); // T = int, forwards as rvaluestd::forward<T> is a conditional cast: it produces T&&, which collapses to T& when T is already a reference type, and stays T&& when T is a plain type. Never substitute std::move inside a forwarding function — it unconditionally produces an xvalue, even when the argument was an lvalue.
Guaranteed Copy Elision (C++17)
C++17 changed the definition of prvalue: prvalues do not create objects. They create objects only when materialized — when bound to a reference, passed to a function expecting an object, or used in a context requiring a concrete value. This makes return-value copy elision mandatory rather than optional:
Widget make_widget() {
return Widget{42}; // prvalue — Widget{42} is a recipe, not an object
}
Widget w = make_widget(); // constructed directly in w's storage
// guaranteed in C++17+; no move constructor requiredBefore C++17, this elision was allowed but not required. Code that relied on move constructor side effects (counters, logging) might observe different behavior between C++14 and C++17.
Temporary materialization also occurs implicitly when & is applied to a prvalue or when a member is accessed on a class prvalue:
// C++17 — prvalue Widget{} materialized to allow member access
int n = Widget{42}.value; // object exists briefly, member read, then destroyedCommon Pitfalls
Named rvalue references are lvalues
void consume(Widget&& w) {
store(w); // lvalue — copies! w has a name, therefore identity
store(std::move(w)); // xvalue — moves; this is what you intended
}Naming anything freezes it as an lvalue inside that scope. This applies equally to Widget&& parameters, auto&& variables, and T&& template parameters.
std::move on const silently copies
const std::string s = "immutable";
std::string t = std::move(s); // copies — yields const string&&
// no move ctor matches (Widget&&); falls back to copy ctorThe move constructor is string(string&&), not string(const string&&). The type resolves to copy. No warning is emitted.
std::move in a return statement disables NRVO
Widget make() {
Widget w;
return w; // NRVO eligible — compiler constructs w in-place at call site
}
Widget bad_make() {
Widget w;
return std::move(w); // disables NRVO — forces a move constructor call
}Returning a named local variable already enables implicit move (C++11) and is NRVO-eligible. Writing std::move(w) in a return converts the expression to an xvalue, which makes it ineligible for copy elision and costs an extra move. C++23 extends implicit move to cover cases where the return type differs (converting moves), making explicit std::move even less necessary on return paths.
See Also
- Move Semantics — move constructors, move assignment, the rule of five
- Perfect Forwarding —
std::forwardpatterns and common mistakes - auto and Type Deduction —
decltype(auto)return types and reference deduction