DEV Community

llzzz
llzzz

Posted on

Deep dive into C++ value categories

Knowing value categories in modern C++ is essential. Only when you know value categories, you can truly understand move semantics, and then you know how to write program efficiently.

Let get straight into it!

There are three primary categories. They are lvalue, xvalue and prvalue respectively, and two mixed categories (rvalue and glvalue). The relationship can be expressed in the following diagram:

graph TD
    E["Expression"] --> G["glvalue (Generalized lvalue)"]
    E --> R["rvalue"]

    G --> L["lvalue"]
    G --> X["xvalue (eXpiring value)"]

    R --> X
    R --> P["prvalue (Pure rvalue)"]

any expression in C++ can be categorized into one of the primary category. Here is the detail explanation

1. lvalue expression

Roughly speaking, an lvalue expression is an expression that has an identity — it refers to a specific object, function, or bit-field that lives at a stable location in memory, and that persists beyond the expression that names it.

The name "lvalue" is historical: it comes from "left value", because lvalues are what you traditionally find on the left-hand side of an assignment. Keep this as a mental heuristic, but don't trust it too far — it's only a rule of thumb, and modern C++ breaks it in both directions (see the caveats below).

The identity test

The most practical way to check whether an expression is an lvalue is to ask: can I take its address? If the built-in address-of operator & accepts the expression, it is (almost certainly) an lvalue. Taking an address only makes sense if the thing has a location — and having a location is exactly what "having identity" means physically.

int i = 10;
int* p = &i;      // OK: i is an lvalue, it has an address
Enter fullscreen mode Exit fullscreen mode

Examples of lvalue expressions

  • Named variables: in int x;, the expression x is an lvalue.
  • Dereferenced pointers: *p is an lvalue — it names the object the pointer points to.
  • Array subscripting (for built-in types): arr[0] is an lvalue.
  • Functions returning lvalue references: given int& f();, the call expression f() is an lvalue.
  • String literals: "hello" is an lvalue — it has an address in static storage.
  • The conditional operator, sometimes: (cond ? a : b) is an lvalue if both a and b are lvalues of the same type.

Modifiable vs. non-modifiable lvalues

Not every lvalue can be assigned to. This distinction matters, because only modifiable lvalues can appear on the left of =:

  • Modifiable lvalue: can be assigned to, e.g. a non-const int variable.
  • Non-modifiable lvalue: has identity but cannot be changed through that expression — e.g. a const variable, or an array type.

int& f();
int i = 10;
i = 20;           // OK: i is a modifiable lvalue
f() = 10; // f() is lvalue (let assume f() return a valid reference)

const int ci = 5;
// ci = 6;        // ERROR: ci is a non-modifiable lvalue
Enter fullscreen mode Exit fullscreen mode

Two caveats on the "left-hand side" heuristic:

  1. A non-modifiable lvalue can never be on the left of =.
  2. With class types, an rvalue can appear on the left of = if the assignment operator is overloaded — so "left of =" alone doesn't prove lvalueness.

Also note the reverse direction: a plain literal such as 20 is not an lvalue, so it can never be assigned to:

// 20 = i;        // ERROR: 20 is a prvalue, not an lvalue
Enter fullscreen mode Exit fullscreen mode

Why this matters for move semantics

This is the key point for everything that follows.

An lvalue is persistent: it has a name, it has an address, and it survives past the current expression. Because of that, when the compiler sees a named lvalue being used to initialize or assign to another object, it must assume you might use that lvalue again later. It therefore cannot safely steal its resources — doing so would leave a named object in a moved-from state that you might still read.

Consequently, the compiler will not automatically invoke a move constructor or move assignment on a named lvalue; it falls back to copying:

std::string a = "hello";
std::string b = a;          // a is an lvalue -> copy, not move
// std::string&& r = a;     // ERROR: rvalue reference cannot bind to lvalue
Enter fullscreen mode Exit fullscreen mode

To move from a named object you must explicitly say "I'm done with this one's identity" — which is exactly what std::move does by casting the lvalue into an xvalue (an e*X*piring value). That cast is the bridge to the next section: lvalues are too "sticky" to move from automatically, and xvalues are how we opt in.

2. xvalue (eXpiring value)

If lvalues are "too sticky to move from automatically," xvalues are the mechanism that lets you opt in. An xvalue is an expression that has identity and can be moved from. It still refers to a specific object (making it a glvalue), but it carries a signal: this object is expiring—its resources may be reused.

Where xvalues come from

In C++, an expression is an xvalue if it is one of the following:

  • A function call or cast returning an rvalue reference to an object: std::move(x) is the canonical example (which internally evaluates to static_cast<T&&>(x)).
  • Member access on an rvalue object: a.m, where a is an rvalue (either an xvalue or a prvalue) and m is a non-static data member of non-reference type.
  • Pointer-to-member operations: a.*pm, where a is an rvalue and pm points to a data member.
  • Array subscripting on an rvalue array: std::move(arr)[i].

For example:

int a = 10;
std::move(a); // xvalue (via function returning T&&)

struct Foo { int m; };
int&& x = Foo{}.m; // xvalue: member access on prvalue

struct RefFoo { int& r; };
int val = 42;
int& y = RefFoo{val}.r; // lvalue: reference members never decay into xvalues
Enter fullscreen mode Exit fullscreen mode

3. prvalue (Pure Rvalue)

If an lvalue represents an identifiable, long-lived object and an xvalue represents an existing object marked for expiration, a prvalue represents the computation itself.

A prvalue does not have an identity. Instead of referring to an object sitting somewhere in memory, it is an expression whose evaluation initializes an object or computes a value. Because it has no identity, nobody else can refer to it, making it inherently safe to move from or consume.

The C++17 Paradigm Shift: Deferred Materialization

Prior to C++17, prvalues were often thought of as "temporary objects." Since C++17, that definition is obsolete:

A prvalue is not an object; it is a recipe to initialize an object.

An actual temporary object is only created when the language has no choice but to give the prvalue a physical location in memory (such as binding it to a reference or accessing a member). This process is called temporary materialization, and it turns a prvalue into an xvalue on demand.

Because prvalues are recipes rather than objects, returning a prvalue by value or passing one as an argument does not invoke a copy or move constructor—not even as an optimization. It is guaranteed by the language standard (Guaranteed Copy Elision).

Where prvalues come from

The majority of everyday expressions produce prvalues:

  • Literals: Numeric, boolean, and character literals like 42, 3.14, true, and 'a' (note: string literals like "hello" are an exception; they are lvalues pointing to static memory).
  • Function calls returning non-references: Any call or overloaded operator returning a value type (e.g., std::string("hello"), make_widget(), a + b).
  • Arithmetic, logical, and relational expressions: Built-in operations like x + y, !flag, a < b, or bitwise operations.
  • The address-of operator: &x is a prvalue representing the pointer value itself.
  • Lambda expressions: [](int a) { return a * 2; } evaluates to a prvalue closure object.
  • Explicit conversions/casts to non-reference types: static_cast<double>(my_int) or functional forms like Foo{}.
  • Built-in post-increment and post-decrement: x++ and x-- (they compute and return the old value as a prvalue, unlike ++x which returns an lvalue reference).

Practical Example

struct LargeBuffer {
    LargeBuffer() = default;
    LargeBuffer(const LargeBuffer&) = delete; // Non-copyable
    LargeBuffer(LargeBuffer&&) = delete;      // Non-movable
};

LargeBuffer create_buffer() {
    return LargeBuffer{}; // prvalue: no temporary object created here yet
}

int main() {
    // 1. Guaranteed Copy Elision (C++17)
    // Works even though LargeBuffer is neither copyable nor movable!
    // The prvalue directly initializes `buf` in-place.
    LargeBuffer buf = create_buffer(); 

    // 2. Literals and computation
    int n = 5 + 3; // `5`, `3`, and `5 + 3` are prvalues

    // 3. Temporary Materialization
    // `create_buffer()` is a prvalue. Binding it to an rvalue reference forces 
    // a temporary object to materialize in memory, converting it into an xvalue.
    // const LargeBuffer& ref = create_buffer(); 
}

Enter fullscreen mode Exit fullscreen mode

The Big Picture (Taxonomy Recap)

To see how all three fit together:

Category Has Identity? (glvalue) Can be moved from? (rvalue) What it represents
lvalue Yes No Persistent entity/location in memory
xvalue Yes Yes Existing entity expiring or marked for reuse
prvalue No Yes Pure computation / blueprint for initialization
  • glvalue (generalized lvalue) = lvalue + xvalue (anything with an identity)
  • rvalue = xvalue + prvalue (anything safe to move from or consume)

One thing worth mentioning:

The most common trap in C++ is conflating the type of a variable (T&&) with the value category of the expression naming it.

A named rvalue reference is itself an lvalue.

void process(std::string&& r) {
    // `r` is a variable of type rvalue reference (std::string&&).
    // BUT the expression `r` is an lvalue!
    // It has a name, identity, and persists throughout this function scope.

    // std::string target = r;             // ERROR/Surprise: invokes copy constructor!
    std::string target = std::move(r);     // OK: casts the lvalue `r` back into an xvalue
}

Enter fullscreen mode Exit fullscreen mode

Rule of thumb: "If it has a name, it's an lvalue." It doesn't matter if its type was declared as T&&.

After reading this article, I hope you understand that value categories aren't trivia. They are the underlying grammar that makes modern move semantics and zero-cost abstractions possible. Master the grammar, and writing expressive, high-performance C++ becomes second nature.

Top comments (0)