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.
We’ve seen plenty of examples of expressions so far. Expressions are blocks of syntax that produce some value. Examples include:
42
- a literal valueSomeVariable
- a variableSomeFunction()
- the value returned by a function callSomeVariable + 42
- the value returned by an operatorWe 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.
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.
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.
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};
}
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
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
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);
}
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:
MyType& Source
) indicating that it needs to respect it and preserve its subresources.MyType&& Source
). This indicates we are free to just take control of its subresources, because the object is expiring soon anyway.std::move()
really doesWith 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.
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:
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.A straightforward guide to l-values and r-values, aimed at helping you understand the fundamentals
Comprehensive course covering advanced concepts, and how to use them on large-scale projects.