cpp-lvalues-rvalues

What are L-values, R-values, and R-value References in C++

  • 5 min

The L-values and R-values are categories that describe how an expression behaves in C++.

If you’ve tried to learn Modern C++ (C++11 and later), you’ve surely come across strange terms like Move Semantics, std::move, or those mysterious double ampersands &&.

To understand all of that, we first have to get down to the nitty-gritty and understand how C++ classifies expressions. Not all variables are created equal.

Historically (in C), the distinction was simple:

  • L-value (Left value): Could be on the left side of an assignment.
  • R-value (Right value): Could only be on the right side.

But in C++, this has evolved. Today, the distinction is less about “left or right” and more about Identity and Lifetime.

This distinction is critical because it allows us to detect temporary objects that are about to die, and leverage that information to optimize code drastically.

What is an L-value?

An L-value is something that has a permanent memory address and a name. It is an object that “exists” beyond the current expression.

Think of an L-value as a container.

  • It has a name.
  • You can take its address with &.
  • It persists after the line of code where it is used.
int x = 10; // 'x' is an L-value
x = 20;     // Correct: 'x' is on the left

int* p = &x; // Correct: we can take the address of an L-value
Copied!

What is an R-value?

An R-value is a temporary value. It is something that has no name, or is about to be destroyed. Generally, these are literals (standalone numbers) or intermediate results of operations.

Think of an R-value as the content (the water, not the glass).

  • It has no name.
  • Generally, you cannot take its address (it’s safe to assume it has no stable address).
  • It disappears at the end of the statement (the semicolon).
int x = 10; // '10' is an R-value (literal)
int y = x + 5; // '(x + 5)' is an R-value (temporary result)

// 10 = x; // ERROR: You cannot assign something to an R-value
// int* p = &10; // ERROR: You cannot take the address of a literal
Copied!

The litmus test

A quick rule to distinguish them:

“If it has a name, it is (almost always) an L-value. If it has no name, it is an R-value.”

Why does this matter to us?

Here comes the interesting part.

In classic C++ (C++98), we only had normal references (which we now call L-value references), denoted with &.

void function(int& x) { ... } // Accepts only modifiable L-values
void function(const int& x) { ... } // Accepts EVERYTHING (L-values and R-values)
Copied!

The problem is that if we receive a const int&, we don’t know if we have been passed an important variable (x) or a temporary (10). And if it is a temporary… it’s a shame to copy it! If it’s going to die anyway, why not steal its resources?

To solve this, C++11 introduced R-value References.

R-value References (&&)

An R-value reference is denoted by two ampersands (&&).

This is a new “leg” of the type system that says: “I only accept pointing to temporary objects (R-values) that are about to be destroyed.”

void process(int& x) {
    std::cout << "Processing L-value (normal variable)\n";
}

void process(int&& x) {
    std::cout << "Processing R-value (temporary)\n";
}

int main() {
    int a = 5;

    process(a);      // Calls process(int&) -> L-value
    process(10);     // Calls process(int&&) -> R-value
    process(a + 10); // Calls process(int&&) -> R-value
}
Copied!

The power of &&

Thanks to this overloading, we can distinguish when we are being passed something that “needs to be preserved” (L-value) and when we are passed something we can “destroy” (R-value).

This is the foundation of Move Semantics.

Imagine a String class that manages dynamic memory.

  1. If we pass it an L-value, we have to do a Deep Copy (slow, new, copy bytes).
  2. If we pass it an R-value (a temporary), we can do a Move (fast, just copy the pointer and leave the temporary empty).

An R-value reference (Type&&) acts like a “tag” that tells us: “This object is disposable. Do whatever you want with it; no one will miss it.”

A confusing detail: The name of the reference

Be careful with this, because it’s where everyone trips up at first.

If a variable has a name, it is an L-value. Even if its type is an R-value reference.

void example(int&& ref) {
    // 'ref' is the name of the variable.
    // Therefore, inside this function, 'ref' acts as an L-value.

    // If we want to pass it to another function as an R-value,
    // we have to convert it again.
}
Copied!

This seems counterintuitive, but it makes sense: inside the function example, ref has an address and we can use it multiple times. If it were destroyed on the first line, we couldn’t use it on the second.

To treat it as an R-value again and pass it to someone else, we need to convert it. And that’s where std::move comes in.