Value Categories (L-Values and R-Values)

A straightforward guide to l-values and r-values, aimed at helping you understand the fundamentals
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
Illustration representing computer hardware
Ryan McCombe
Ryan McCombe
Updated

In the previous lesson on move semantics, we introduced a new type of reference, which uses the && syntax:

#include <iostream>

struct Resource {
  // Copy Constructor
  Resource(const Resource& Source) {}

  // Move Constructor
  Resource(Resource&& Source) {}
};

In this lesson, we’ll explore what this means in more detail, by introducing value categories.

L-Values and R-Values

We’ve seen plenty of examples of expressions so far. Expressions are blocks of syntax that produce some value. Examples include:

  • 42 - a literal value
  • SomeVariable - a variable
  • SomeFunction() - the value returned by a function call
  • SomeVariable + 42 - the value returned by an operator

We can broadly consider these values as belonging to one of two categories - left values (l-values) and right values (r-values). We’ll cover the meaning of the "left" and "right" a little later in this section.

L-Values

An l-value is an expression that identifies a specific object or function. If we can identify the address of an expression using the & operator, then it is an l-value.

In the previous list, SomeVariable is an l-value, as we can get its address using &SomeVariable. For this reason, the "l" in l-values is sometimes considered to refer to locator values.

R-Values

An r-value is anything that is not an l-value - that is, anything that is not identifiable. In the previous example, 42 is an r-value - attempting to get its address using &42 will result in a compilation error.

The other examples in the list - SomeFunction() and SomeVariable + 42 - could be either l-values or r-values. It depends on what the function/operator returns. We’ll discuss the value categories of functions and operators later in this section.

Binding R-Values to L-Values

A simplification that helps to explain the difference is to imagine l-values are containers, and r-values are the contents of the container. In the following example, x is an l-value, and 5 is an r-value:

int x { 5 };

Without a container, an r-value doesn’t last long. Typically, it is lost right after the expression it is used in is evaluated. In the following example, 1 and 5 are r-values:

int main() {
  1 + 5;
}

The combined expression 1 + 5 is also an r-value. The result of that expression (6) is discarded the moment it is generated unless we store it in an identifiable place, represented by an l-value:

int main() {
  int Result{1 + 5};
}

L-Value to R-Value Conversions

The reason the value categories are called "left values" and "right values" is based on where they occur in relation to the equality operator =. An l-value like MyVariable appears on the left, whilst an r-value like 42 appears on the right:

int main() {
  int MyVariable = 42;
}

This is true, but can be immediately confusing, as we’ve seen countless examples where we have an l-value on the right side of the equality operator:

int main() {
  int Number{42};
  int MyVariable = Number;
}

This works because the compiler can implicitly convert an l-value to an r-value when it is used in an r-value context, such as on the right side of the assignment operator.

Just like we can use a float in a scenario where an int is expected, we can use an l-value in a situation where an r-value is required.

Behind the scenes, the compiler takes care of it for us. The opposite is not true - we cannot use an r-value in a place where an l-value is expected:

int main() {
  int MyVariable{42};

  42 = MyVariable;
}
error C2106: '=': left operand must be l-value

Functions and Operators

Function names are l-values, as we can get their memory address using the & operator:

#include <iostream>

void SomeFunction(){};

int main() {
  std::cout << &SomeFunction;
}
00007FF7871819D8

This creates a function pointer - we discuss the applications of function pointers later in the course:

Whilst a function name is an l-value, the result of a function call such as SomeFunction() could be an l-value or an r-value. It depends on what the function returns. In most cases, a function will be returning an r-value, so an expression like SomeFunction() is also an r-value.

As such, it doesn’t have an identifiable memory address, so using the & operator will generate a compiler error:

#include <iostream>

int GetNumber(){ return 42; };

int main() {
  // GetNumber is an l-value, so this is valid
  std::cout << &GetNumber;

  // GetNumber() is an r-value, so this is invalid
  std::cout << &GetNumber();
}
error C2102: '&' requires l-value

However, a function invocation is not always an r-value. Functions can return l-values, so an expression like SomeFunction() could be an l-value. For example, a function can return a reference to some object in memory, which is an l-value.

Below, our GetNumber() function returns a reference to an l-value in our global scope, which has a memory address we can retrieve using the & operator:

#include <iostream>

int Number{42};

int& GetNumber(){ return Number; };

int main() {
  // GetNumber is an l-value
  std::cout << &GetNumber << '\n';

  // GetNumber() is an l-value
  std::cout << &GetNumber();
}
00007FF6E9A819E2
00007FF6E9A9F018

Operators are also functions, so the same logic applies. Below, our + operator returns an r-value - an instance of SomeType. Like any r-value, this will be lost as soon as our expression ends, unless we bind it to an l-value.

However, the ++ operator returns an l-value - specifically, it returns the object the ++ was called on. In the following example, that will be the l-value we called Object:

#include <iostream>

struct SomeType {
  SomeType operator+(int x) {
    return SomeType {Value + x};
  }

  SomeType& operator++() {
    ++Value;
    return *this;
  }

  int Value;
};

int main() {
  SomeType Object;
  // Object + 42 is an r-value
  std::cout << &(Object + 42);

  // ++Object is an l-value
  std::cout << &(++Object);
}
error C2102: '&' requires l-value

L-Value References

Within our functions parameters, we previously saw how we could denote references using the & character:

void SomeFunction(int &x) {}

Whilst we didn’t draw attention to this nuance in the past, what we’re creating here is specifically an l-value reference. If we attempt to pass an r-value into this parameter, the compiler would have thrown an error:

void SomeFunction(int &x) {}

int main() {
  SomeFunction(5);
}
no matching function for call to 'SomeFunction'
candidate function not viable: expects an lvalue for 1st argument

This makes conceptual sense - by our function accepting a non-const reference, we are expressing a desire to modify an argument. But that doesn’t make sense when our argument is an r-value. The r-value will be lost as soon as our SomeFunction(5) expression ends, so any modifications we make would be pointless

If we don’t intend to modify the object and are instead passing by reference for performance reasons, we can specify our parameter as being const and our code will compile successfully:

void SomeFunction(const int &x) {}

int main() {
  SomeFunction(5);
}

R-Value References

To create an r-value reference, we annotate our type with an additional &. For example:

  • int& is an l-value reference to an int
  • int&& is an r-value reference to an int

This allows us to provide different implementations of a function, depending on whether an argument is an l-value reference or an r-value reference:

#include <iostream>

void SomeFunction(int& x) {
  std::cout << "That was an l-value\n";
}

void SomeFunction(int&& x) {
  std::cout << "That was an r-value\n";
}

int main() {
  int x{2};
  SomeFunction(x);
  SomeFunction(5);
}
That was an l-value
That was an r-value

Our previous lesson on move semantics introduced the main practical use for this. A copy constructor accepts an l-value reference, whilst a move constructor accepts an r-value reference:

#include <iostream>

struct Resource {
  // Default constructor
  Resource() {}

  // l-value reference
  Resource(const Resource& Source) {
    std::cout << "Copying resource\n";
  }

  // r-value reference
  Resource(Resource&& Source) {
    std::cout << "Moving resource\n";
  }
};

int main() {
  Resource Original;
  Resource A{Original};
  Resource B{std::move(Original)};
}
Copying resource
Moving resource

The properties of l-value and r-value expressions we covered at the beginning of this lesson link up to how we treat source objects within copy and move semantics:

  • An l-value is considered more "important" than an r-value, because it has a longer lifespan. It survives after the expression in which it is used. Copy sementics therefore receive their source object as an l-value reference (MyType& Source) indicating that it needs to respect it and preserve its subresources.
  • An r-value is considered less important because it has a shorter lifespan. Move semantics receive their source objects as r-value references (eg MyType&& Source). This indicates we are free to just take control of its subresources, because the object is expiring soon anyway.

What std::move() really does

With an understanding of l-values and r-values under our belt, we can go a little deeper into what std::move() actually does. At this point, it’s perhaps clear that std::move() doesn’t actually move anything.

Instead, it indicates that the object may be moved from, enabling move semantics if available. In other words, it casts its argument to an r-value reference, which can influence what function is selected when that reference appears in the argument list:

#include <iostream>

void SomeFunction(int& x) {
  std::cout << "That was an l-value\n";
}

void SomeFunction(int&& x) {
  std::cout << "That was an r-value\n";
}

int main() {
  int x{2};
  
  // This calls the l-value variation
  SomeFunction(x);
  
  // This calls the r-value variation
  SomeFunction(std::move(x));
}
That was an l-value
That was an r-value

Within our move semantics example, we could get the exact same behaviour using static_cast instead of std::move(), which we demonstrate below:

#include <iostream>

struct Resource {/*...*/}; int main() { Resource Original; Resource A{Original}; Resource B{static_cast<Resource&&>(Original)}; }
Copying resource
Moving resource

If our intent for casting to an r-value reference is related to move semantics (which it typically is), we should prefer to use the std::move() technique. It reduces the amount of syntax, making our code easier to read, and it also makes our reason for performing the cast more obvious to those readers.

Summary

In this lesson, we delved into l-values and r-values, exploring their definitions, distinctions, and how they interact with C++'s type system. The key takeaways are:

  • The difference between l-values and r-values.
  • How to identify l-values and r-values, with l-values being addressable expressions and r-values being non-addressable or temporary values.
  • The concept of binding r-values to l-values and the transient nature of r-values unless they are assigned to an identifiable l-value.
  • Functions and operators can return either l-values or r-values.
  • Introduction to l-value and r-value references, highlighting their interactions with function overloading and move semantics.
  • The std::move() function casts its argument to an r-value reference. It is simply a friendly and more more descriptive way of implementing an equivalent static_cast expression.

Was this lesson useful?

Next Lesson

Type Aliases

Learn how to use type aliases, using statements, and typedef to simplify or rename complex C++ types.
Illustration representing computer hardware
Ryan McCombe
Ryan McCombe
Updated
Lesson Contents

Value Categories (L-Values and R-Values)

A straightforward guide to l-values and r-values, aimed at helping you understand the fundamentals

A computer programmer
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
Memory Management
A computer programmer
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, unlimited access

This course includes:

  • 124 Lessons
  • 550+ Code Samples
  • 96% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Type Aliases

Learn how to use type aliases, using statements, and typedef to simplify or rename complex C++ types.
Illustration representing computer hardware
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved