Understanding L-Values, R-Values & Move Semantics in Modern C++
Understanding L-Values, R-Values & Move Semantics in Modern C++
0. Preface
Before we dive in, let’s quickly refresh our understanding of references (&) and pointers (*).
References (&) store the address of an object located somewhere in memory, just like pointers (*). However, once a reference is initialized, it cannot be changed to reference another object or be set to null.
Values can be classified into two types: l-values and r-values. The definitions of l-values and r-values (left-hand values and right-hand values) come from the C language.
Left-hand values are expressions that can appear on either the left or right side of an assignment, while right-hand values are expressions that can only appear on the right side of an assignment.
int a = 0;
a; // l-value
0; // r-value
Player player;
player; // l-value
Player(); // r-value
This definition has evolved somewhat differently in C++.
L-values refer to some memory location and can be referenced with the & operator. R-values are anything that’s not an l-value. L-values persist beyond the expression they appear in R-values don’t persist beyond the expression they appear in L-values reference named variables (&) R-values reference temporary objects (&&)
Honestly, even this definition is quite ambiguous, which confuses many people.
Let’s take a closer look at l-values and r-values step by step…
1. L-Values
L-values are relatively straightforward.
Here’s what qualifies as an l-value:
- Objects that persist beyond a single expression!
- Has an address
- Named variables
- const variables
- Array variables
- Bit-fields
- Unions
- Class members
- Function calls that return an l-value reference (&)
- String literals
So how do l-value assignment and referencing work?
Let’s look at the code and comments below.
// All are l-values
int a = 1; // global variable a -> l-value
int& function(){ // l-value function is initialized with the address of returned a
a = 3; // a-> l-value, can be assigned
return a;
}
int main()
{
int i = 3; // i is l-value
i = 4; // i is l-value, can be assigned
// i -> 4
int *ptr = &i; // i is l-value, & operator can reference it, ptr points to i's address
// *ptr is i's value -> 4
ptr = &a; // ptr is an int * type pointer, so it can change what it references
// *ptr is a's value -> 1
int & r = i; // r is initialized with l-value i's address, so r is an l-value assigned with l-value
r = 5; // writes value to the address r points to, changing i's value (both r and i are 5)
// both r and i point to the same address -> 5
int c = function(); // l-value reference stores its address only during initialization
// c -> function's return value after internal stack operation, which is changed a value 3
function() = 50; // function is l-value, r-value 50 can be assigned
// a's value changes to 50.
// and ptr points to a's address, so *ptr -> 50 is assigned.
int d = a; // l-value d is assigned a's value 50
int *ptr_2 = &function(); // int * type ptr_2 can use &function (function is l-value)
// function's return value after internal stack operation, which is changed a value 3
// * ptr_2 -> changed a's value -> 3
// c's value also changes -> 3
// * ptr's value also changes -> 3
return 0;
}
L-value assignment is relatively clear and easy to understand as shown above.
2. R-Values
But r-values are somewhat harder to define…
Here’s what we can say about them:
- Objects that are not l-values… (contrapositive statement haha..)
- Temporary values that don’t persist beyond the single expression they’re used in!
- Objects without addresses
- Literals (except string literals)
- Function calls that don’t return by reference (e.g. int function())
- i++ and i— (but ++i and —i operators are l-values)
- Basic arithmetic, logical, comparison expressions (+,-,*,=, <,> etc, can vary with operator overloads)
- Enumerations (enum)
- Lambdas (the compiler treats lambdas/anonymous functions as temporary)
Let’s look at some examples.
R-value example 1:
int num1 = 10;
int num2 = 15;
if (num1 < num2) // (num1 < num2) is r-value
{
...
}
R-value example 2:
int function(); // function doesn't persist after call -> r-value
function(); // r-value
int i = 0;
i = function(); // i is l-value assigned with r-value function's return value
int *ptr = &function(); // ERROR!!! Can't reference address of r-value
The original C/C++ designers defined r-values as temporary objects, so by default, r-values cannot be assigned to.
But this changed with the introduction of r-value “move semantics” in Modern C++ (C++11).
3. Move Semantics
Consider the following scenario:
//Math.cpp
std::vector<float> Math::ConvertToPercentage(const std::vector<float>& scores){
std:vector<float> percentages;
for (auto& score : scores){
//...
}
return percentages;
}
// main.cpp
#include "Math.h"
int main(){
std::vector<float> scores;
//...
scores = ConvertToPercentage(scores);
//...
}
When ConvertToPercentage(scores)
is called in main,
this function in the Math class creates an r-value that’s a temporary value of std::vector<int>
type called percentages.
Then it gets assigned to scores by the assignment operator ”=”.
How would this work in pre-C++11 (before Modern C++)?
When percentages’ temporary value gets assigned to the memory area created during scores’ initialization, it gets copied. Then the temporary r-value disappears as we exit the Math::ConvertToPercentage function stack.
There’s a quite unnecessary process here - the moment when percentages temporary value gets copied.
Think about it logically.
If we simply swap the memory area storing the temporary percentages result (r-value) with the memory area created during scores’ initialization, since the temporarily created percentages memory area will disappear anyway when we exit the stack, the “copying” process becomes unnecessary.
This method of preventing unnecessary copying is the core of Modern C++‘s r-value references and Move Semantics.
Actually, modern compilers are pretty smart about this nowadays… Sometimes overusing r-value references can actually slow down the system. 😅
4. R-Value References (&&) and std::move
R-value references were first introduced in Modern C++ (C++11). They function similarly to the & operator.
The example below shows referencing basic types using move semantics:
#include <memory>
#include <utility>
float CalculateAverage(){
float average=3;
// ...
return average;
}
int main(){
int num = 10; // num -> l-value
//int && rNum = num; // Error!!! num is l-value -> int & lnum is possible
int && rNum = std::move(num); // move allows referencing l-value num
num = 30; // changing num also changes rNum (because it's a reference value)
int && rNum1 = 10; // OK, 10 is r-value
float && rAverage = CalculateAverage(); // OK CalculateAverage is r-value
float tmp_1 = std::move(rAverage); // tmp_1 is l-value, so it's assigned
float tmp_2 = rAverage; // tmp_2 is l-value, so it's assigned
float && tmp_3 = std::move(rAverage); // tmp_3 is l-value, but it's an r-value reference value
rAverage = 1.0f; // changing rAverage only changes tmp_3, tmp_1 and tmp_2 are assigned values
}
Appendix.A References
Share this article
Found this helpful? Share it with your network
관련 글
About l-r-value & Move Semantic of Modern C++
CV-qualifiers Explained - Understanding const and volatile
Understanding constexpr - Compile-time vs Runtime Evaluation
Join the Discussion
Share your thoughts and connect with other readers
댓글
GitHub 계정으로 로그인하여 댓글을 남겨보세요. 건설적인 의견과 질문을 환영합니다!
댓글을 불러오는 중...
댓글을 불러올 수 없습니다.