Sandrino's WEBSITE

   ABOUT  BLOG  


Rvalue Reference and Move Semantic

In this article, we will explore the concept of Rvalue References and how Move Semantics can significantly enhance performance in C++. Before delving into these advanced topics, let's revisit the fundamental concepts of lvalues and rvalues.

int32_t lvalue = 123;          // (1)
int32_t res    = lvalue + 1;   // (2)

int32_t num = getNumber();   // (3)


int32_t&       ref       = 123;   // (4)
const int32_t& ref_valid = 123;   // (5)

In the code above you can see some examples of rvalues. On Line (1) the literal constant 123 is an rvalue, after the assignment it will live inside the variable lvalue which has a specific address in memory. Line (2) the compiler has to create a temporary object which holds the result of the operation and then assignes it to the variable res. Line (3) the function getNumber() returns a number. Line (4) is not valid code and will not compile, we can not have a modifiable reference to a rvalue(Because the rvalue has no address), on the other hand Line (5) is valid code and will compile because the compiler would create a temporary variable to hold the rvalue 123 and with const we promis, that we will not try to modify the value itself.

Now that we've clarified the distinction between lvalues and rvalues, we can proceed to explore the power of rvalue references and move semantics in C++.

Rvalue Reference

So, what exactly are rvalue references? We've already discussed the difference between lvalues and rvalues, and we've seen an example of a const reference to an rvalue (const int32_t& ref_valid = 123;). Now, let's explore rvalue references, which allow us to have modifiable references to rvalues, granting us the power to modify temporary objects. In C++, this capability is denoted by the double ampersand (&&).

std::string s1 = "Hello ";
std::string s2 = "world!";

std::string&& s_rvalue_reference = s1 + s2; // Result is a rvalue
s_rvalue_reference += " Howdy!";    // Proof, we can indeed change the rvalue
std::cout << s_rvalue_reference << '\n';    // Prints Hello world! Howdy!

In the code snippet above, the + operator combines s1 and s2, producing an rvalue. This rvalue is then assigned to an rvalue reference s_rvalue_reference, which means s_rvalue_reference now references a temporary object. Surprisingly, we can modify this temporary object through the rvalue reference. With this newfound ability to manipulate rvalues using rvalue references, we open the door to leveraging move semantics, a powerful technique for improving performance in C++.

In the following sections, we'll delve deeper into move semantics.

Move semantic

So, what exactly does Move Semantics enable us to do? It empowers us to avoid unnecessary copies of temporary objects, allowing us to work directly with these temporary objects in an efficient manner.

Example code:

class Object
{
  public:
    Object( int32_t size )
    {
        data = new int[ size ];
        size = size;
    }

    ~Object() { delete[] data; }

  private:
    int*   data;
    size_t size;
};

In the example we have simple class which works with dynamic memory. We have a constructor which allocates memory for an array and a destructor with the responsibility to delete the allocated memory. The class looks fine at first. But we should know about the rule of three (Check sources). The rule of three states that if a class defines any of the following then it should probably explicitly define all three:

Although the compiler can generate these functions automatically, we can't always rely on the compiler, especially when working with dynamically allocated memory. So, let's examine when each of these constructors is called:

Object o1(1000);    // Normal constructor
Object o2 = o1;     // copy constructor
Object o3(o1);      // copy constructor

Object o4(10000);
Object o5(20000);
o4 = o5;            // assignment operator

The copy constructor is called when we create a new object based on an existing one. To implement a copy constructor, we can do the following:

Object(const Object& other)
{
    data = new int[other.size];     // (1)
    std::copy(other.data, other.data + other.size, data);   // (2)
    size = other.size;
}

We see that first of all we create a new array on the heap with the same size as the other object (1) after that we copy all values from other object to the newly allocated array (2). I guess we could also use memcpy instead of std::copy but there are differences in those two functions(Check resources).

Now lets check the copy assignment operator, this type of constructor as we can see is called when we replace an existing object with another existing object.

Object& operator=(const Object& other)
{
    if(this == &other) return *this;    // (1)
    delete[] data;  // (2)
    data = new int[other.size];
    std::copy(other.data, other.data + other.size, data);
    size = other.size;
    return *this;
}

The only real difference the the copy constructor is that we first check for self-assignment(1) and delete the current data held by the object because we replace it with the other object(2). At the end we return a reference. The return of the reference if a C++ convention and it also enables us chaining for example o1 = o2 = o3;. Chaining would also be possible if we would return by value but then o1 = o2 = o3; would call the assignment operator twice, the copy constructor twice and also the destructor would be called twice to delete the temporary objects.

Challenges in Our Current Class Implementation

As previously mentioned, our class faces a potential issue of inadvertently invoking constructors and destructors multiple times, leading to a performance loss that may go unnoticed. Let's illustrate this problem with an example:

Object createObject( int32_t size )
{
    return Object( size );
}

This function would create a object by value. So when we call this function it would create a temporary Object which has to be also copied somewhere and after that also it must be deleted (many constructors/destructors)

int main()
{
    Object o = createObject(123123);
}

In this example createObject first creates a temporary object which then has to be passed to the copy constructor (Because we want to create a new object o in this case). So we allocate a new array on the heap again and copy the values from the temporary object given by createObject. After that the temporary object also has to call the destructor to clean after itself. Also we call two times the new while creating the temporary object and when calling the copy constructor.

The same would happen with the copy assignment operator. The issue with this is that we already have a temporary object (rvalue) which is waiting to be used and not only to be deleted immediately after. So how can we solve this?

Improvements with move semantic and rvalues

The move semantic can help us exactly with the problem above. It gives us the opertunity to use rvalues directly and not to be forced to copy/destruct them always. We will grab the values from the temporary objects. So what does it mean to grab(steal) the values from the temporary object. It means we have to modify the temporary object itself which suggests it must be modifyable. Now we remember our rvalue references :)

For this we can take the Rule of Five as guidlines (Check sources). The rule of five says that if we want to grab/steal data from temporary objects we need to extend our constructor pool a bit. We need to add the move constructor and the move assignment operator.

Implemenation of move constructor:

Object(Object&& other)  // (1)
{
    data = other.data;
    size = other.size;
    other.data = nullptr;   // (2)
    other.size = 0;
}

What is new? First we can see in line (1) that we no longer take a const object& but we get a modifiable rvalue reference. Additionally we see that we leave the other object from which we steal the data in an empty state. We can modify the other object now only because we work with rvalue references. As we can see compared to the other constructors we no longer have deep copies we simply switch pointers. We use the already constructed temporary object and just steal it or point to it and act like it is ours now. The important thing is to set the other object to an empty state other.data = nullptr; this is important because when the destructor is called it executes delete [] data.

Implementation of move assignment operator:

Object& operator=(Object&& other)
{
    if( this == &other ) return *this;

    delete[] data;      // (1)

    data = other.data;  // (2)
    size = other.size;

    other.data = nullptr;   // (3)
    other.size = 0;

    return *this;
}

Whats new here? First we need to delete the content of the current object with delete (1) after that we again simply steal the data from the temporary object and set the temporary object again to an empty state.

We talked now added the two new constructors but we need to see when is which called.

int main()
{
    Object o1(123);     // normal constructor
    Object o2(o1);      // copy constructor
    Object o3 = createObject(123123); // move constructor because rvalue is input

    o2 = o3;            // assignment operator lvalue input
    o2 = createObject(5);   // move assignment operator rvalue input
}

With the move semantic we can drastically improve the performance of our code by avoiding unnecessary copies and calls do constructors. Because the already created temporary object already exist and we just steal their data and use it for our new/existing objects. We basically get ownerships over resources but it is important to mention because above i mentioned pointers. When we change ownership we do not share the same resources as we would do if we just simply point another pointer to a ressources. When we Take ownership the old owner will be put into an empty state, there is always only one owner.

Use move on lvalues

Is is possible to move lvalues with std::move the name is a bit misleading in my opinion because it does not move anything. The only thing it does is a cast. It casts a lvalue to an rvalue (I am sure there were reasons I havent checked).

How to move a lvalue:

int main()
{
    Object o1(123);
    Object o2(std::move(o1));   // (1)
    // o1 is in empty state
}

In this example we create a lvalue o1 and in line (1) we cast it to a rvalue this way we do no call the copy constructor but we call the move constructor based on the rvalue input. Is is very important to mention that after we create o2 then the object o1 is no longer valid it is in an empty state because we said that other.data = nullptr in the move constructor.

Sources