Skip to content
C++
Language
since C++11
Intermediate

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++11

Every 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?
cpp
         expression
        /           \
    glvalue         rvalue
    /     \        /     \
lvalue   xvalue  xvalue  prvalue

The 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
CategoryIdentityMovable
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.

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

cpp
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 extended

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

cpp
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();                 // xvalue

std::move does not move anything — it is a cast that converts an lvalue to an xvalue, enabling the compiler to select move overloads:

cpp
// 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 categoryT&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.

cpp
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
cpp
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 prvalue

The parenthesized-variable distinction matters critically with decltype(auto) (C++14):

cpp
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:

cpp
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 rvalue

std::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:

cpp
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 required

Before 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:

cpp
// C++17 — prvalue Widget{} materialized to allow member access
int n = Widget{42}.value;  // object exists briefly, member read, then destroyed

Common Pitfalls

Named rvalue references are lvalues

cpp
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

cpp
const std::string s = "immutable";
std::string t = std::move(s);  // copies — yields const string&&
                                // no move ctor matches (Widget&&); falls back to copy ctor

The 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

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

The Five Value Categories: lvalue, rvalue, glvalue, prvalue, xvalue

The full taxonomy exists because move semantics and copy elision require distinguishing identity from movability independently. A single lvalue/rvalue split cannot express "this object has an address but is safe to steal from" — that is exactly what xvalue captures.

The three primary categories are disjoint; the two composite categories are unions:

  • glvalue ("generalized lvalue") = lvalue ∪ xvalue — the expression refers to an existing object or function
  • rvalue = xvalue ∪ prvalue — the expression denotes something that can be moved from
cpp
// C++11
int x = 10;
int* ptr = &x;

// --- lvalue: has identity, not movable ---
x;              // lvalue — named variable
*ptr;           // lvalue — indirection through pointer; &(*ptr) is valid
"hello";        // lvalue — string literal; lives in static storage

// --- prvalue: no identity, movable ---
42;             // prvalue — integer literal
x + 1;          // prvalue — arithmetic result
std::string{};  // prvalue — unnamed temporary (C++17: no object yet)

// --- xvalue: has identity AND movable ---
std::move(x);              // xvalue — static_cast<int&&>(x) under the hood
static_cast<int&&>(x);     // xvalue — explicit equivalent
std::move(x) + 0;          // prvalue — arithmetic on an xvalue produces prvalue

// function returning T&& yields an xvalue at the call site:
int&& rref_fn();
rref_fn();      // xvalue — has an address, but caller shouldn't depend on it

T{} is a prvalue — in C++17 it carries no storage until materialized. std::move(x) is an xvalue — storage exists (x's), resources may be stolen. *ptr is always an lvalue regardless of what ptr points to.

Reference Collapsing Rules

When two reference declarators are stacked — which happens in template instantiation and typedef expansion — the language collapses them to a single reference type. The rule has one simple case: two rvalue references stay T&&; any other combination yields T&.

Stacked formCollapses to
T& &T&
T& &&T&
T&& &T&
T&& &&T&&
cpp
// C++11
using Lref = int&;
using Rref = int&&;

Lref&  a;  // int& &  → int&
Lref&& b;  // int& && → int&
Rref&  c;  // int&& & → int&
Rref&& d;  // int&&   → int&&

Reference collapsing is what makes forwarding references work. When relay(T&& arg) is called with an lvalue int, the compiler deduces T = int&, producing int& && which collapses to int&. When called with an rvalue int, T = int, producing int&& which stays int&&.

cpp
// C++11 — forwarding reference deduction
template<typename T>
void relay(T&& arg) {
    // T deduces to int&  for lvalue argument → T&& = int& && → int&
    // T deduces to int   for rvalue argument → T&& = int&&   → int&&
    downstream(std::forward<T>(arg));
}

// std::forward uses reference collapsing internally:
template<typename T>
T&& forward(std::remove_reference_t<T>& t) noexcept {
    return static_cast<T&&>(t);
    // if T = int&  → cast to int& && → collapses to int&  (lvalue pass-through)
    // if T = int   → cast to int&&              stays int&& (rvalue pass-through)
}

The result: std::forward<T> preserves the original value category of the argument with zero runtime cost.

Guaranteed Copy Elision (C++17)

C++17 redefined prvalue: a prvalue is not an object, it is a recipe for constructing one. No storage is allocated and no constructor is called until the prvalue is materialized — bound to a reference, used to initialize a variable, or passed to a function expecting a concrete object.

cpp
// C++17 — guaranteed copy/move elision on prvalue return
std::string make() {
    return std::string{"hello"};  // prvalue — the recipe travels back to the caller
}
std::string s = make();  // constructed once, directly in s's storage
                         // no copy ctor, no move ctor — guaranteed even if deleted

Before C++17, NRVO (Named Return Value Optimization) achieved similar results, but it was optional. The std::string{"hello"} case is a prvalue return, not NRVO — and it is unconditionally elided under C++17 semantics.

cpp
// C++17 — works even when the type is non-movable
struct NonMovable {
    NonMovable() = default;
    NonMovable(NonMovable&&) = delete;
    NonMovable(const NonMovable&) = delete;
};

NonMovable make_nm() {
    return NonMovable{};  // prvalue — zero copies or moves; legal in C++17
}
std::optional<NonMovable> opt = NonMovable{};  // also fine in C++17

Temporary materialization — the implicit conversion of a prvalue to an xvalue — occurs when a member is accessed on a class prvalue, when & is applied to a prvalue, or when a prvalue is bound to a reference. The resulting xvalue has a lifetime equal to the full expression.

cpp
// C++17 — prvalue materialized to allow .size() call
std::size_t n = std::string{"hello"}.size();

decltype and Value Categories

decltype(expr) preserves value category: the result type encodes whether the expression is an lvalue, xvalue, or prvalue. This makes it the right tool for perfect-forwarding return types.

Expression kinddecltype result
Named entity (no extra parens)declared type T
lvalue expressionT&
xvalue expressionT&&
prvalue expressionT
cpp
// C++11
int x = 0;
int* p = &x;

static_assert(std::is_same_v<decltype(x),            int  >);  // name → T
static_assert(std::is_same_v<decltype((x)),           int& >);  // lvalue expr → T&
static_assert(std::is_same_v<decltype(42),            int  >);  // prvalue → T
static_assert(std::is_same_v<decltype(*p),            int& >);  // lvalue expr → T&
static_assert(std::is_same_v<decltype(std::move(x)), int&&>);  // xvalue → T&&
static_assert(std::is_same_v<decltype(x + 0),        int  >);  // prvalue → T

The extra-parentheses rule is the most common source of surprises. decltype(x) sees a name and returns int; decltype((x)) sees an lvalue expression and returns int&. This matters with decltype(auto).

cpp
// C++11/14 — decltype(auto) deduces the full value category
decltype(auto) good()    { int x = 0; return x;   }   // deduces int    — safe
decltype(auto) danger()  { int x = 0; return (x); }   // deduces int&   — UB: dangling ref

// C++11 — preserving category in a perfect-forwarding wrapper
template<typename Container, typename Index>
decltype(auto) at(Container&& c, Index i) {
    return std::forward<Container>(c)[i];
    // returns T&  when c is lvalue container (subscript yields lvalue)
    // returns T&& when c is rvalue container (subscript yields xvalue)
}

std::move, std::forward, and std::move_if_noexcept

std::move is a cast, not an operation. It converts an lvalue to an xvalue, enabling move overload selection. It does not move, copy, or touch any data.

cpp
// C++11 — std::move is exactly this:
template<typename T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
    return static_cast<std::remove_reference_t<T>&&>(t);
}

std::string s = "hello";
std::string t = std::move(s);   // s cast to string&&; string(string&&) selected
// s is now in a valid but unspecified state

std::forward<T> is a conditional cast: it produces an rvalue only if T is a non-reference type. Its job is to preserve the value category that was deduced at the call site of a forwarding reference.

cpp
// C++11 — std::forward implementation (simplified)
template<typename T>
T&& forward(std::remove_reference_t<T>& arg) noexcept {
    return static_cast<T&&>(arg);
}

// Correct: relay preserves lvalue/rvalue distinction
template<typename T>
void relay(T&& arg) { sink(std::forward<T>(arg)); }

// Wrong: std::move makes everything an rvalue
template<typename T>
void relay_bad(T&& arg) { sink(std::move(arg)); }  // silently moves lvalue args!

std::move_if_noexcept(x) moves only when the move constructor is noexcept; otherwise it copies. This preserves the strong exception guarantee: if a move would throw partway through reallocation, the original data would be lost.

cpp
// C++11 — used internally by std::vector reallocation
std::vector<Widget> v;
v.reserve(100);

// When vector reallocates, each element is transferred like this:
new_storage[i] = std::move_if_noexcept(old_storage[i]);
// If Widget::Widget(Widget&&) is noexcept → move (fast path)
// If it might throw               → copy  (safe path — original untouched)

Mark move constructors noexcept to opt in to the fast path. The standard library's std::vector, std::deque, and std::swap all respect this guarantee.

cpp
// C++11 — declaring noexcept move enables move_if_noexcept fast path
struct Widget {
    Widget(Widget&&) noexcept;   // vector will move, not copy, during realloc
    Widget& operator=(Widget&&) noexcept;
};

Value Categories in Lambda Captures

A captured variable is always an lvalue inside the lambda body, regardless of how it was captured or what its original value category was. The capture creates a named data member; that member has identity and therefore behaves as an lvalue.

cpp
// C++11 — by-copy capture: x inside lambda is lvalue
int x = 42;
auto f = [x]() { return x; };  // x is a copy; accessing it is an lvalue expression

// C++11 — by-reference capture: x inside lambda is lvalue
auto g = [&x]() { x = 99; };   // modifies the original

To capture an rvalue or move-only type, use an init capture (C++14). The captured member is still an lvalue inside the body, but its constructor received an rvalue.

cpp
// C++14 — init capture: move a unique_ptr into the lambda
auto ptr = std::make_unique<Widget>(42);
auto task = [p = std::move(ptr)]() {
    p->run();  // p is an lvalue inside the body — it has a name
};
// ptr is null here; ownership transferred to task

To use the captured member as an rvalue inside the body — for example, to move it out — the lambda must be declared mutable (so the member is non-const) and you must call std::move explicitly.

cpp
// C++14 — mutable lambda: treat captured member as rvalue
auto heavy = std::make_unique<HeavyResource>();
auto once = [r = std::move(heavy)]() mutable {
    consume(std::move(r));  // r is lvalue in scope; std::move makes it xvalue
    // r is now null — this lambda should only be called once
};

Without mutable, the implicit operator() is const, so r is a const member and std::move(r) yields const HeavyResource&&, which does not bind to the move constructor and silently copies (or fails to compile if the type is non-copyable).

See Also