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
Examples of lvalue expressions
-
Named variables: in
int x;, the expressionxis an lvalue. -
Dereferenced pointers:
*pis 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 expressionf()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 bothaandbare 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
intvariable. -
Non-modifiable lvalue: has identity but cannot be changed through that expression — e.g. a
constvariable, 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
Two caveats on the "left-hand side" heuristic:
- A non-modifiable lvalue can never be on the left of
=. - 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
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
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 tostatic_cast<T&&>(x)). -
Member access on an rvalue object:
a.m, whereais an rvalue (either an xvalue or a prvalue) andmis a non-static data member of non-reference type. -
Pointer-to-member operations:
a.*pm, whereais an rvalue andpmpoints 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
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:
&xis 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 likeFoo{}. -
Built-in post-increment and post-decrement:
x++andx--(they compute and return the old value as a prvalue, unlike++xwhich 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();
}
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
}
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)