WARNING!!!WARNING!!!WARNING!!!
After consulting with my C++ coach, this article was rewritten almost from scratch. I'm leaving it here for the documentation purposes, but please do not base your learning on it. Visit my blog for the most recent and valid version - link.
WARNING!!!WARNING!!!WARNING!!!
In my opinion, in Java we do not care that much about object creation and then assigning them to other variables. In general, due to GC inner-workings, we're more reluctant in this area, than C++ programmers. However, that's not the only reason why I've decided to write this post. Recently a survey result appeared in my social feed claiming, that move semantics and understanding object lifecycle is not a piece of cake. In order to cover all my bases, I've decided to dive into the topic a little deeper. Here we go.
DISCLAIMER! Obviously I'm not an experienced C++ programmer, therefore there's always a possibility, that my knowledge is not enough. I've put a lot of effort, to write this article and make it as good as possible. However, if you think that some concepts or explanations are wrong, please, don't hesitate to point them out!
Copy semantics
I'm mentioning copy semantics here, mostly for the sake of completeness, and to introduce some working code example. In fact, it works exactly the same in Java. Every time we assign an already existing object to the new one (or even existing one), a shallow copy is performed. Below code snippet in Java:
class MyObject {
int i = 5;
String s = "test";
}
MyObject obj1 = new MyObject();
MyObject obj2 = obj1;
obj1.i = 9;
System.out.println("Obj1: " + obj1.i);
System.out.println("Obj2: " + obj2.i);
Will print:
Obj1: 9 Obj2: 9
For the C++ equivalent we can use this code:
#include <iostream>
using namespace std;
class MyObject {
public:
MyObject()
{
i = 5;
myString[0] = 'a';
}
void setMyString(char newString)
{
myString[0] = newString;
}
char getMyString()
{
return myString[0];
}
private:
int i;
char* myString = new char[1]; // size is hardcoded, but we don't care here
};
int main()
{
MyObject obj1;
MyObject obj2 = obj1;
cout << "Obj1: " << obj1.getMyString() << endl;
cout << "Obj2: " << obj2.getMyString() << endl;
obj1.setMyString('b');
cout << "Obj1: " << obj1.getMyString() << endl;
cout << "Obj2: " << obj2.getMyString() << endl;
return 0;
}
Passing objects as params - copy it is
In works in the same way with C++, also when an object is passed to the function/method. As long as we don't operate on references or pointers, a shallow copy of the passed object is created, and passed as na argument. Take a look at the following code:
#include <iostream>
using namespace std;
class MyObject {
public:
MyObject()
{
i = 5;
myString[0] = 'a';
}
void setMyString(char newString)
{
myString[0] = newString;
}
char getMyString() const
{
return myString[0];
}
void setI(int newI)
{
i = newI;
}
int getI() const
{
return i;
}
private:
int i;
char* myString = new char[1]; // size is hardcoded, but we don't care here
};
void doSthWithObject(MyObject myObject)
{
myObject.setI(9);
}
int main()
{
MyObject obj1;
MyObject obj2 = obj1;
cout << "Obj1: " << obj1.getMyString() << endl;
cout << "Obj2: " << obj2.getMyString() << endl;
doSthWithObject(obj1);
cout << "Obj1: " << obj1.getI() << endl;
cout << "Obj2: " << obj2.getI() << endl;
obj1.setMyString('b');
cout << "Obj1: " << obj1.getMyString() << endl;
cout << "Obj2: " << obj2.getMyString() << endl;
return 0;
}
Which produces this output:
Obj1: a Obj2: a Obj1: 5 Obj2: 5 Obj1: b Obj2: b
Constructor for one?
So far we saw nothing new. In exactly the same way works a custom constructor (called copy constructor), that takes an object as a parameter. It's up to the programmer how this constructor will create a new object - it can be either simple shallow copy, or even a deep copy:
class MyObject {
public:
MyObject()
{
i = 5;
myString[0] = 'a';
}
MyObject(MyObject& obj)
{
i = obj.getI(); // shallow copy as it is a primitive/internal object
myString = new char[1]; // dynamic allocation of memory
strncpy(myString, new char[1]{obj.getMyString()}, 1); // copying the VALUE of the object, not the reference
}
// set/get methods omitted
private:
int i;
char* myString = new char[1]; // size is hardcoded, but we don't care here
};
int main()
{
MyObject obj1;
MyObject obj2 = obj1;
cout << "Obj1: " << obj1.getMyString() << endl;
cout << "Obj2: " << obj2.getMyString() << endl;
MyObject obj3 {obj1}; // Usage of COPY CONSTRUCTOR
cout << "Obj1: " << obj1.getI() << endl;
cout << "Obj3: " << obj3.getI() << endl;
obj1.setMyString('b');
cout << "Obj1: " << obj1.getMyString() << endl;
cout << "Obj3: " << obj3.getMyString() << endl;
return 0;
}
Which produces this output:
Obj1: a Obj2: a Obj1: 5 Obj3: 5 Obj1: b Obj3: a // Value is unaffected
Gimme some equal copy!
You may say - again - nothing new under the sun. Been there, done that. However, C++ has some more magic to offer than Java. In general, it is possible in C++ to override not only the class methods, but also operators used on it! That's something you can't see in Java (maybe I should add - yet). Overriding an operator is simple - technically it's like overriding a method. However! With a great power, comes great responsibility! Let's see how it may go wrong.
int main()
{
MyObject obj1;
MyObject obj2 = obj1;
cout << "Obj1: " << obj1.getMyString() << endl;
cout << "Obj2: " << obj2.getMyString() << endl;
cout << "-----" << endl;
MyObject obj3; // New object created
obj3.setMyString('b'); // New value assigned to the string
obj3 = obj1; // Here is the main part!!
cout << "Obj1: " << obj1.getMyString() << endl;
cout << "Obj2: " << obj2.getMyString() << endl;
cout << "Obj3: " << obj3.getMyString() << endl;
return 0;
}
The output is as predicted:
Obj1: a Obj2: a ----- Obj1: a Obj2: a Obj3: a
The question is - what happens under the hood? In our situation, every new object has its own instance (and therefore dynamically allocated memory) of myString. With the default behaviour of the copy assignment, we're making only a shallow copy of the string instance when assigning
obj1 to obj3! The memory allocated for the string instance in the obj3 leaked! That's something we have to pay attention to, when (re)assigning objects around! In Java, GC process will take care of the lost memory, in C++ - no way.
So how do we fix this? General rule of thumb is that we need to override the assignment operator, and before we make all the assignments of data, we have to make sure, that we free all the memory we've allocated in the object. The solution in our example should look like this:
MyObject& operator=(const MyObject& objToCopyFrom)
{
if( this == &objToCopyFrom) return *this; // that prevents unnecessary work if the same instance is passed
delete[] myString; // we free the memory allocated for the array
myString = new char[1]{objToCopyFrom.getMyString}; // Just use passed object value
i = objToCopyFrom.getI(); // Same with int - but it's internal type, so no need to think about memory
return *this; // Just return current instance
}
So what's the default behaviour?
In general, the logic standing behind automatic generation of copy operator and copy constructor is quite robust, and caution is advised while using them. Two objects after copying should not alter their states (as we saw above), and also should not leave any of the objects in the inconsistent state. To finish this topic it is worth mentioning, that there's a possibility to explicitly indicate, that we're fine (or not) with default implementations generated by the compiler. Here are the examples:
MyObject(const MyObject&) = default; // Yes, I'm happy with default implementation
MyObject(const MyObject&) = delete; // I'm not happy with defaults - remove the auto-generation and raise
compiler error when such situation occurs
Move semantics
With the previous chapter we've introduced a foundation for explaining more advanced concept, which is move semantics. To start with (and I was surprised to learn that), move semantics wasn't present in C++ before C++11! So pay attention to that fact. Second thing that we have to start with, is the difference between lvalue and rvalue
Lvalue vs Rvalue
Here, I would use a direct quote from 'C++ Crash Course':
We'll consider a very simplified view of value categories. For now, you'll just need a general understanding of
lvalues and rvalues. An lvalue is any value that has a name, and
an rvalue is anything that isn't an lvalue.
To make it easier - here are the usual things that are rvalues - temporary objects, literal constants, function return values (unless they're lvalues passed as params) and usually results of built-in operators.
I know that it might sound like explaining the unknown through unknown, however, I think that simple code example (taken from the same book) would explain the concept:
#include <cstdio>
void ref_type(int &x) {
printf("lvalue reference %d\n", x);
}
void ref_type(int &&x) {
printf("rvalue reference %d\n", x);
}
int main() {
auto x = 1;
ref_type(x);
ref_type(2);
ref_type(x + 2);
}
The output is:
lvalue reference 1 rvalue reference 2 rvalue reference 3
The idea behind move semantics
Why this concept is important? Let's go back to the example used in the previous subchapter. Our code there, had an array of chars inside. Imagine for a moment, that this array holds a large amount of chars, like millions or so. Copying all this data back and forth would be a tremendous waste of both - memory and time. What is more, quite often, when we assign an object to the new reference (or we pass it as a constructor parameter), we actually don't care about the source object anymore. We just need new reference to hold the data, and underlying memory does not bother us, as we want to discard the source object. That's the perfect situation when move semantics can be used.
Enough talk, show me the code. Let's revisit an example already shown above.
#include <iostream>
using namespace std;
class MyObject {
public:
MyObject()
{
i = 5;
myString[0] = 'a';
}
void setMyString(char newString)
{
myString[0] = newString;
}
char getMyString()
{
return myString[0];
}
private:
int i;
char* myString = new char[1]; // size is hardcoded, but we don't care here
};
int main()
{
MyObject obj1;
MyObject obj2 = obj1;
cout << "Obj1: " << obj1.getMyString() << endl;
cout << "Obj2: " << obj2.getMyString() << endl;
obj1.setMyString('b');
cout << "Obj1: " << obj1.getMyString() << endl;
cout << "Obj2: " << obj2.getMyString() << endl;
return 0;
}
The problem we had before was, that after we've assigned obj1 to obj2, we wanted to use both of them later. Or more precisely, we wanted to use the objects that were referenced by obj1 and obj2. As I've mentioned above, quite often we don't need that. We just want to perform a copy of one object (so we're interested in its values), but we don't care that much about the remaining reference. There are two ways to achieve that, similarly to the copying above - through move constructor or move assignment. What's the difference between them, and their copying counterparts? Just one - usage of rvalues. As usual - code speaks thousands words.
// I present only the new methods, fields of the class are public for readability
MyObject(MyObject&& obj) noexcept // Here's the change
{
i = obj.i;
myString = new char[1]{*obj.myString};
// Below we 'empty' the source object.
obj.i = 0;
obj.myString = nullptr;
}
MyObject& operator=(MyObject&& objToCopyFrom) noexcept // Here's the change - RVALUE present as a param
{
if( this == &objToCopyFrom) return *this; // that prevents unnecessary work if the same instance is passed
delete[] myString; // we free the memory allocated for the array
myString = new char[1]{*objToCopyFrom.myString};
i = objToCopyFrom.i;
// Below we 'empty' the source object.
objToCopyFrom.i = 0;
objToCopyFrom.myString = nullptr;
return *this;
}
We have a couple of changes here that we need to look at.
- noexcept - in general we don't expect these methods to throw any kind of exceptions. We cannibalize the source object, and all the operations are either simple copies of values or reassignment of memory addresses.
- no const - pay attention to that! As we're 'emptying' the source object, we must be able to actually change its state. Therefore we cannot use const in the parameters.
- emptying source object - already mentioned this one. In general, we leave the source object 'in statu nascendi', which means it is raw/virgin state. There's no problem with reusing existing reference to assign a new object to it, however the reference itself at this time is 'empty'.
The last point above can be a source of the problems. The programmer must always remember to clean up the source object, in order to avoid nasty runtime errors (like double-free ones). Therefore, when possible, try to reuse existing move constructors/operators, or use
cpp std::exchange
function, that performs move operation, but also nulls the source object. Here's the code:
// This thing
myString = new char[1]{*objToCopyFrom.myString};
objToCopyFrom.myString = nullptr;
// Can be done like this
myString = std::exchange(*objToCopyFrom.myString, nullptr);
Ok, but how I get rvalue?
That's the valid point. In general, if we create a new object, and at the same time we pass it to the constructor (or assign it) we should be fine. However, the examples above were showing assigning already existing object/reference (so - lvalue). To help us with move semantics C++ introduced a function in the STD called move (its definition can be found in header). Its purpose is to cast any lvalue to rvalue, and therefore to use existing references in the move semantics.
An example from 'C++ Crash Course':
#include <cstdio>
#include <utility>
void ref_type(int &x) {
printf("lvalue reference %d\n", x);
}
void ref_type(int &&x) {
printf("rvalue reference %d\n", x);
}
int main() {
auto x = 1;
ref_type(std::move(x));
ref_type(2);
ref_type(x + 2);
}
The output is:
rvalue reference 1 rvalue reference 2 rvalue reference 3
Summary
Copy and move semantics are crucial to understand, in order to properly interact with objects, and understand their lifecycle. Failing to do so, can result in nasty runtime bugs and possible memory leaks. This topic has its own section in the core guidelines, that author of the language - Bjarne Stroustrup - created and published for everyone to benefit from. So please, take a look at them too.
SOURCES:
- Josh Lospinoso 'C++ Crash Course' book
- Back to basics - Move semantics - CPPCon 2020
- Hidden secrets of - move semantics - CPPCon 2020
- Klaus Iglberger Back to Basics: Move Semantics part one and part two from CppCon 2019
Top comments (0)