Move Semantics - Resource Management in Modern C++
Part I - Basics and Background
Photo by Edu Lauton on Unsplash
Table of contents
Hi there, Welcome to another episode of C++.
Today, we’ll lay the groundwork for understanding one of C++'s key concepts: Move Semantics.
Move Semantics in C++ has always puzzled me due to its complexity and subtle differences from traditional copy semantics. Understanding when and why objects are moved instead of copied is challenging, but this very complexity sparks my curiosity. I am eager to grasp this concept because it promises significant improvements in software performance and efficiency.
Background
The philosophy of C++ revolves around efficiency and performance, providing low-level access to memory and hardware while maintaining as much abstraction as possible to simplify the developer's life. Central to this efficiency are the concepts of lvalues, rvalues, and references. It is where the story begins.
There are two properties that matter for an object when it comes to addressing, copying, and moving:
Has Identity: The program has the name of, pointer to, or reference to the object so that it is possible to determine if two objects are the same, whether the value of the object has changed, etc.
Is movable: The object may be moved from(i.e., we can move it's value to another location and leave the object in a valid but unspecified state, rather than copy).
Lvalues (locator values) represent objects with identifiable memory locations, allowing them to be modified. They are fundamental in resource management because they enable direct access and manipulation of variables and data structures. This capability is crucial for writing efficient code that can take full advantage of hardware capabilities.
Rvalues (right values) are temporary values that do not persist beyond the expression in which they appear. They typically represent data that is about to be discarded or used immediately, such as the result of an arithmetic operation or a temporary object returned from a function. Understanding rvalues is important because they enable optimizations like Move Semantics, where resources can be transferred instead of copied.
In summary, a classical lvalue is something that has identity and cannot be moved, and a classical rvalue is anything that we are allowed to move from.
The distinction between lvalues and rvalues allows C++ to implement references in a powerful and flexible manner.
Lvalue references bind to lvalues, providing a way to alias a variable without copying it. This is useful for functions that need to modify their arguments or return large objects efficiently.
Rvalue references, introduced in C++11, bind to rvalues, enabling Move Semantics. This allows developers to write more efficient code by transferring ownership of resources from temporary objects rather than copying them.
For example, when returning large objects from functions or when dealing with dynamic memory, Move Semantics can significantly reduce overhead and improve performance.
Lvalue references can bind to Lvalues and Rvalues. Rvalue references can only bind to Rvalues.
Moving Containers
Let's see a scenario where Copy Semantics might not an ideal way of doing stuff in C++.
A container is an object holding a collection of elements, so we call Vector a container because it is the type of objects that are containers. Let's say we have a simple Vector class similar to std::vector
class Vector {
private:
double *elem; // elem points to an array of sz doubles
int sz;
public:
Vector(int s) : elem{new double[s]}, sz{s} { // Constructor
for(int i=0; i!=s; ++i) {
elem[i] = 0; // Initialize elements
}
}
~Vector() { // Destructor
delete[] elem;
}
double& operator[](int i);
int size() const;
};
We can control copying by defining a copy constructor and a copy assignment, but copying can be costly for large containers. Consider:
Vector operator+(const Vector& a, const Vector& b) {
if(a.size() != b.size())
throw Vector_size_mismatch{};
Vector res(a.size());
for(int i=0; i!=a.size(); ++i)
res[i] = a[i]+b[i];
return res;
}
Returning from a + involves copying the result out of the local variable res and into some place where the caller can access it. We might use this + like this:
void fun(const Vector& x, const Vector& y, const Vector& z) {
Vector r;
//...
r = x+y+z;
//...
}
That would be copying a Vector at least twice (one for each use of the + operator). If a Vector is a large, say, 10,000 doubles, that could be embarrassing. The most embarrassing part is that res in operator+() is never used again after the copy. We didn't really want a copy; we just wanted to get the result out of a function: we wanted to move a Vector rather than to copy it.
However, we can state the same intent:
class Vector {
//...
Vector(const Vector& a); // copy constructor
Vector& operator=(const Vector& a); // copy assignment
Vector(Vector&& a); // move constructor
Vector& operator=(Vector&& a); // move assignment
};
Given that definition, the compiler will choose the move constructor to implement the transfer of the return value out of the function. This means that r=x+y+z will involve no copying of Vectors. Instead, Vectors are just moved.
As is typical, Vector's move constructor is trivial to define:
Vector::Vector(Vector&& a) : elem{a.elem}, sz{a.sz} {
a.elem = nullptr;
a.sz = 0;
}
The && means "rvalue reference" and is a reference to which we can bind an rvalue. The word "rvalue" is intended to complement "lvalue", which roughly means "something that can appear on the left-hand side of an assignment". So an rvalue is a value that you can't assign to, such as an integer returned by a function call. Thus, an rvalue reference is a reference to something that nobody else can assign to, so that we can safely "steal" its value. The res local variable in operator+() for Vectors is an example.
A move constructor does not take a const argument : after all, a move constructor is supposed to remove the value from its argument. A move assignment is defined similarly.
A move operation is applied when an rvalue reference is used as an initializer or as the right hand side of an assignment. After a move, a moved-from object should be in a state that allows a destructor to be run.
Typically, we should also allow assignment to a moved-from object. For example, in the above code snippet, Object a's elem assigned a nullptr to indicate a valid state of the moved-from object.
What's Next
In this article, we have laid the groundwork by exploring the basic concepts and background necessary for understanding Move Semantics. However, there's much more to uncover. To truly grasp Move Semantics, we need to delve deeper into the fascinating and intricate concepts of C++.
This journey promises to be both challenging and rewarding, especially for those with a curious and adventurous mindset in Computer Science.
I am currently learning and exploring, I'll come up with the next one in a while.
Happy learning!!