Dive into C++: move semantics and rvalue

This article discusses the main advantages and nuances of move semantics in C++ 11 and later. Everything described in this article was tested on a Clang 6.0.1 compiler with libc++ library on x86, GNU / Linux.

Introduction to move

Move semantics allows you to move an object instead of copying it for increased performance. The easiest way to understand the semantics of moving is an example. The String class will be used as this example:

class String
{
public:
    explicit String(const char *const c_string)
    {
        std::cout << "String(const char *const c_string)\n";
        size = strlen(c_string) + 1;
        this->c_string = new char[size];
        strcpy(this->c_string, c_string);
    }

    String(const String& other)
    {
        std::cout << "String(const String& other)\n";
        c_string = new char[other.size];
        strcpy(c_string, other.c_string);
        size = other.size;
    }

    ~String() noexcept
    {
        std::cout << "~String()\n";
        delete[] c_string; // delete to nullptr has no effect
    }

private:
    char *c_string;
    size_t size;
};

When passing an object of this class to a function that accepts it by value – let’s call it by_value() – the following will occur:

auto string = String("Hello, C++11"); 
by_value(string); // copy string in by_value()

stdout:
String(const char *const c_string) // new[]
String(const String& other) // new[]
~String() // delete[]
~String() // delete[]

It turns out 4 calls to the allocator, which is quite expensive. But if the String object is no longer needed, and the function by_value() cannot be changed, then you can move the object, not copy it. To do this, you need to write a relocation constructor for the String class:

String(String &&other) noexcept
{
    std::cout << "String(String&& other)\n";
    c_string = other.c_string;
    size = other.size;
    other.c_string = nullptr;
    other.size = 0;
}

The displacement constructor parameter is other, firstly, non-constant, since the constructor changes it; secondly, it is an rvalue-reference (&&), and not an lvalue-reference (&). About their differences will be discussed below. The constructor itself transfers the C string from other to this, making the other empty.

The displacement constructor is generally not slower, and often even faster than the copy constructor, but nothing prevents the programmer from putting sleep(10'000) into the displacement constructor.

To call the move constructor, you can use std::move() instead of the copy constructor. Now the example looks like this:

auto string = String("Hello, C++11");
by_value(std::move(string)); // move string in by_value(), string is now empty

stdout:
String(const char *const c_string) // new[]
String(String&& other) // thanks to replacing it with a displacement constructor, new[] is missing
~String() // delete[]
~String() // delete[] - nullptr

The number of calls to the allocator has halved!

rvalue and lvalue

The main difference between rvalue and lvalue is that rvalue objects can be moved, whereas lvalue objects are always copied.
This “can be” is best demonstrated by the following two examples:

class TextView
{
public:
    explicit TextView(const String string)
            : text(std::move(string)) // stdout: String(const String& other)
    {}

private:
    String text;
};

This code does not work as expected. std::move() still converts lvalue to rvalue, but the conversion retains all modifiers, including const. Then the compiler chooses the most suitable among the two constructors of the String class. Because the compiler cannot send const rlvaue to where non-const rvalue is expected, it chooses the copy constructor, and const rvalue is converted back to const lvalue. Conclusion: watch for object modifiers, since they are taken into account when choosing one of the function overloads.

The second example demonstrates the rule: the arguments and the result of a function can be either rvalue or lvalue, but the parameters of functions can only be lvalue. The argument is what is passed to the function. It initializes a parameter that is directly accessible inside the function.

void f(String&& string)
{
    g(string); // Clang: no known conversion from 'String' to 'String &&' for 1st argument
}

void g(String&& string) {}

Although the string parameter of the f() function is of type rvalue-reference, it is an lvalue and requires explicit conversion to rvalue before passing to the g() function. This is what std::move() does.

Universal links

Universal references can be either an rvalue or lvalue link, depending on the arguments or the result of the function. Used in templates and in auto&&:

template <class T = String> 
void template_func(T&& string)
{
    by_value(std::forward<T>(string));
}

// template_func() accepts any Xvalue
template_func(string); // String(const String& other)
template_func(std::move(string)); String(String&& other)

The compiler, based on this template, generates 2 functions, one of which takes lvalue, the other – rvalue, if they will be used. If a programmer wants to use a move for an rvalue-link and a simple copy for an lvalue-link, then he can use std::forward(), which brings its argument to rvalue only when its type is an rvalue-link. std::forward() requires explicit specification of a template parameter even with automatic output of templates in C ++ 17.

The universal reference must be a template parameter (hence the strange definition of the function template in the example) in the T&& format. For example, std::vector&& is no longer universal, but an rvalue-reference.

Folding of links

In fact, there is no difference between a rvalue-link and a universal link. A universal link is just a convenient abstraction over an rvalue-link, which many people use. But how then does an rvalue-link turn into a lvalue-link with an lvalue argument? The thing is in the folding of links.

When calling template_func(string), the compiler generates the following function header:

void template_func(String& && string);

And it turns out the link to the link! You can’t do this manually, but templates can. Next, the compiler minimizes the link to the link. Minimizing is done according to the following rule: the result of minimization is an rvalue-link only when both links are rvalue-link.

It is because of this outrage that it is easier to use the abstraction of universal links.

Copy/move elision

Copy / move elision is an optimization in which the compiler can remove some calls to the copy constructor and destructor, but at the moment only when returning an object from a function, and only if the type of the returned object completely coincides with the type of function.

Therefore, when returning from a function, using std::move() can reduce performance by limiting the compiler in Copy elision optimization, because the absence of a constructor is faster than the displacement constructor.

String non_copy_elision()
{
    return std::move(String("")); 
}

Here std::move() only slows down the code by adding an extra call to String(String&& other) and ~String().

In C ++ 20, copy / move elision can be extended, due to which in some cases using std::move() will also reduce performance.