February 27, 2022

C++ move semantics

By leonardo

In this post, I’ll bring some information about the move semantics in C++. Move semantics is a highly important feature because it solves some problems of temporary object management, object assignment and constructions redundancies. For example, every time you write a code that makes a sum of two lvalues and pass it as an argument of a constructor of some object, you are creating (constructing using new) a temporary object of the same type using its binary operator (+) and returning its result through the operator overloaded function, so as the last action, the copy constructor will be executed and it will allocate the data on the heap using the new operator again. Well, there’s no necessity to create two instance of the same object since we already have a temporary object (rvalue) created when the binary operator was executed, so we could just move the temporary data inside our new object. Let’s talk about it…

If we already defined the copy constructor and the move assignment operator we will notice that for the third line of the code below, the copy constructor will be executed because the construction needs to be resolved before the assignment, so after the construction was finished, the move assignment operator will be invoked because although we have a copy construction on the right side of the full expression, this one still is an rvalue (xvalue) expression.

Check the entire Foo class code before read the code below
class Foo
{

private:

	int* pValue = nullptr;

public:

	Foo() = delete;

	Foo(int value) : pValue(new int(value)) {}

	Foo(const Foo& _Foo)
	{
		pValue = new int(*_Foo.pValue);
	}

	Foo(Foo&& _Foo) noexcept
	{
		if (_Foo.pValue != nullptr)
		{
			delete pValue;
			pValue = _Foo.pValue;
			_Foo.pValue = nullptr;
		}
	}

	Foo& operator=(const Foo& _Foo)
	{
		pValue = _Foo.pValue;
		return *this;
	}

	Foo& operator=(Foo&& _Foo) noexcept
	{
		if (_Foo.pValue != nullptr)
		{
			pValue = _Foo.pValue;
			_Foo.pValue = nullptr;
		}
		return *this;
	}

        Foo& operator+(const Foo& _Foo)
	{
		Foo newFoo = Foo(*pValue + *_Foo.pValue);

		return newFoo;
	}

	~Foo()
	{
		delete pValue;
	}
};
Foo a = Foo(10);
Foo b = Foo(20);
a = Foo(b);

Another simple example of how to use the std::move is when we want explicitly tell to the compiler that our expression is an rvalue, I mean, we will tell it that our expression is an expiring value (xvalue) so after its use, we can discard it. Look at the expression below, if we pass a sum of two integers inside the allIntegers.push_back argument, obviously we will invoke the move constructor because our expression (a + b) is an rvalue.

std::vector<int> allIntegers;

int a = 1;
int b = 2;

allIntegers.push_back(a + b);

But, as mentioned above, we want to explicitly tell the compiler that we know what we are doing even if we don’t pass an rvalue as argument, so we just need to use the std::move. See the code:

std::vector<int> allIntegers;

int a = 1;
int b = 2;
int c = a + b

allIntegers.push_back(std::move(c));

Now c is an lvalue, or better, it’s a value that is stored in the memory but we don’t have the intention to use it again in the code post the push_back, so we can move it and let the C++ do the work of conversion from an lvalue to an rvalue, this way it will call the push_back(_Ty&& _Val) instead push_back(const _Ty& _Val). This is just an example of how things work, but in fact, primitive types like int, float, bool have neither special move constructors nor move assignment operators, so moving a primitive type is the same as copying it.

Let’s do a real test with this simple code:

void vectorMove()
{
     std::vector<int> allIntegers(10000);
     std::vector<int> newVectorIntegers = std::move(allIntegers);
}

The code above is moving the entire vector “allIntegers” to another one, this is a really quick operation because all the objects inside the vector (in this case, the vector is containing integers values) won’t be reconstructed on the heap again, so, we will keep our heap allocations immutable and just do some reappointments. This is really cheaper for the CPU than invoking the copy constructor like that:

void vectorCopy()
{
     std::vector<int> allIntegers(10000);
     std::vector<int> newVectorIntegers = allIntegers;
}

We can see the comparison between these two function below, the values represented in the chart is the time that took to execute the function in comparison with the simplest CPU instruction (noop – no operation). Now we see the performance difference between moving and copying a vector with 10000 allocated integers.

Comparison between vectorMove and vectorCopy

How does the move work for vectors?

The vector class of C++ has 3 pointers inside it that refer to begin, end, and the end of the allocated memory, so when we perform a move operation we are just getting all the data from these 3 pointers from vec1 and telling to the vec2 appoint to these addresses, below you can see a gif representing that movement.

Extra information: It’s not recommended to use move on a return statement because you would surpass the RVO (Return Value Optimization).

In C++ computer programming, copy elision refers to a compiler optimization technique that eliminates unnecessary copying of objects. Return value optimization (RVO) is a compiler optimization that involves eliminating the temporary object created to hold a function’s return value.

from Wikipedia
std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return std::move(tmp);
}

std::vector<int> &&rval_ref = return_vector();
std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return tmp;
}

std::vector<int> rval_ref = return_vector();

Both the above code will be treated as an rvalue in the return statement.


I’ll try to talk about move semantics deeper in the next post, but until here we’ve made a general overview of this topic and we could see the importance of it.