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.

See Also