Appearance
L-values and R-values
Video: Back to Basics: Move Semantics (part 1 of 2) - Klaus Iglberger - CppCon 2019
Understanding l-values and r-values is fundamental to C++ programming, especially when working with references, move semantics, and templates.
What are Value Categories?
C++ categorizes expressions into different value categories based on their properties. The two most important categories are l-values and r-values.
L-values (Left Values)
An l-value is an expression that refers to a memory location and can appear on the left side of an assignment operator.
Characteristics of L-values:
- Has an address in memory
- Can be assigned to
- Can be referenced (can bind to l-value references)
- Persists beyond the expression
Examples of L-values:
cpp
int x = 42; // x is an l-value
int& ref = x; // ref is an l-value
int* ptr = &x; // ptr is an l-value
int arr[5]; // arr is an l-value
arr[0] = 10; // arr[0] is an l-valueR-values (Right Values)
An r-value is an expression that does not refer to a memory location and cannot appear on the left side of an assignment operator.
Characteristics of R-values:
- No persistent address in memory
- Cannot be assigned to
- Temporary objects
- Expires at the end of the expression
Examples of R-values:
cpp
42; // Literal is an r-value
5 + 3; // Result of expression is an r-value
std::string("hello"); // Temporary object is an r-value
get_value(); // Function returning by value is an r-valueVisual Understanding
L-value Examples:
cpp
int x = 10; // x is an l-value
x = 20; // x can be assigned to
int& ref = x; // x can be referenced
int* ptr = &x; // x has an addressR-value Examples:
cpp
int y = 42; // 42 is an r-value
y = 5 + 3; // 5 + 3 is an r-value
std::string s = "hello"; // "hello" is an r-valueReferences and Value Categories
L-value References:
cpp
int x = 42;
int& ref = x; // OK: l-value reference binds to l-value
// int& ref = 42; // Error: cannot bind l-value reference to r-valueConst L-value References:
cpp
int x = 42;
const int& ref1 = x; // OK: const l-value reference binds to l-value
const int& ref2 = 42; // OK: const l-value reference can bind to r-valueR-value References:
cpp
int x = 42;
// int&& ref1 = x; // Error: cannot bind r-value reference to l-value
int&& ref2 = 42; // OK: r-value reference binds to r-value
int&& ref3 = std::move(x); // OK: std::move converts l-value to r-valueMove Semantics and Value Categories
Move semantics allows you to "move" resources from one object to another instead of copying them, which is much more efficient for expensive-to-copy objects.
The Problem: Expensive Copies
Before C++11, all operations were copy-based:
cpp
std::vector<int> create_large_vector() {
std::vector<int> v(1000000, 42);
return v; // Expensive copy!
}
std::vector<int> v = create_large_vector(); // Another copy!Result: 2 million integers copied unnecessarily.
The Solution: Move Semantics
Move semantics transfers ownership of resources:
cpp
std::vector<int> create_large_vector() {
std::vector<int> v(1000000, 42);
return std::move(v); // Move instead of copy
}
std::vector<int> v = create_large_vector(); // Move constructorResult: Only pointer and size information transferred.
Copy Semantics
cpp
class String {
char* data;
size_t size;
public:
// Copy constructor
String(const String& other) {
size = other.size;
data = new char[size];
std::copy(other.data, other.data + size, data);
}
// Copy assignment
String& operator=(const String& other) {
if (this != &other) {
deletedata;
size = other.size;
data = new char[size];
std::copy(other.data, other.data + size, data);
}
return *this;
}
};Move Semantics
cpp
class String {
char* data;
size_t size;
public:
// Move constructor
String(String&& other) noexcept {
data = other.data; // Steal the pointer
size = other.size;
other.data = nullptr; // Leave other in valid state
other.size = 0;
}
// Move assignment
String& operator=(String&& other) noexcept {
if (this != &other) {
deletedata;
data = other.data; // Steal the pointer
size = other.size;
other.data = nullptr;
other.size = 0;
}
return *this;
}
};std::move
std::move is a utility function that converts an l-value to an r-value reference:
cpp
std::string str = "Hello";
std::string str2 = std::move(str); // str is now emptyImportant: std::move doesn't move anything - it just enables move semantics.
Why R-value References Matter:
cpp
class String {
public:
// Copy constructor (expensive)
String(const String& other) {
// Deep copy
}
// Move constructor (cheap)
String(String&& other) noexcept {
// Transfer ownership
}
};
String s1("hello");
String s2 = s1; // Calls copy constructor (l-value)
String s3 = std::move(s1); // Calls move constructor (r-value)In the above example, once s1 is converted to an r-value and moved to s3, s1 will no longer own the "hello" string. While constructing s3, the move constructor is called and the string "hello" is transferred from s1 to s3.
Best Practices
1. Use Const References for Expensive Copies:
cpp
void process(const std::string& str) { // Avoid copying
// Process str
}2. Use R-value References for Move Semantics:
You will typically use r-values for your function parameters when you want the user to transfer the ownership of the object to the function.
cpp
void process(std::string&& str) { // Move from temporary
// Process and move str
}Once anyone calls the process function, they will no longer be able to use the string object after the function returns. One class that uses this aggressively is std::unique_ptr.
3. Make Move Operations noexcept:
What happens when a move operation throws an exception? This could actually make both the source and the destination objects invalid! Therefore, we should try to implement move operations as noexcept.
In fact, standard library containers disable the move constructor if the element type does not have a noexcept move constructor.
cpp
class String {
public:
// Move constructor should be noexcept
String(String&& other) noexcept;
// Move assignment should be noexcept
String& operator=(String&& other) noexcept;
};4. Leave Moved-From Objects in Valid State:
cpp
String(String&& other) noexcept {
data = other.data;
size = other.size;
other.data = nullptr; // Leave in valid state
other.size = 0;
}std::unique_ptr as an example of move semantics
std::unique_ptr is a smart pointer that owns the object it points to. Since ownership of a pointer can only belong to one instance, the class explicitly disallows copying and only allows moving. When you move a std::unique_ptr, the ownership of the object is transferred to the new std::unique_ptr.
cpp
std::unique_ptr<int> ptr1(std::make_unique<int>(42));
std::unique_ptr<int> ptr2 = std::move(ptr1);
std::unique_ptr<int> ptr3 = ptr2; // Error: cannot copy a unique_ptr!In the above example, once ptr1 is moved to ptr2, ptr1 will no longer own the integer 42. While constructing ptr2, the move constructor is called and the integer 42 is transferred from ptr1 to ptr2.
Questions
Q: What is the primary characteristic of an l-value?
An l-value is an expression that refers to a memory location and can be assigned to. It has an address and can appear on the left side of an assignment operator.
Q: Which of the following is an r-value?
A literal value like 42 is an r-value because it doesn't refer to a memory location and cannot be assigned to. It's a temporary value.
Q: What happens when you try to assign to an r-value?
You cannot assign to an r-value because it doesn't refer to a memory location. This will cause a compilation error.
Q: Which of the following expressions is an l-value?
A named variable like 'x' is an l-value because it refers to a memory location and can be assigned to.
Q: What is the value category of the result of a function call that returns by value?
A function call that returns by value produces an r-value because the result is a temporary object that doesn't refer to a memory location.
Q: Which of the following is NOT an l-value?
A function call returning by value produces an r-value, not an l-value. The other options are all l-values.
Q: What is the purpose of r-value references (&&)?
R-value references (&&) are used to enable move semantics, allowing efficient transfer of resources from temporary objects.
Q: Which value category is used by default for function parameters?
By default, function parameters are l-values, even if they were passed as r-values. This is why we need perfect forwarding.