- "Good style" as our guide
- What is "good style"
- Setting up the example
- Rule 1: destructor
- Rule 2: copy constructor
- Rule 3: copy assignment operator
- Rule 4: move constructor
- Rule 5: move assignment operator
- Now we (mostly) follow best practices
- Rule of 5
- The rule of "all or nothing"
If you ever wanted to embrace a teenager within you, to deal with the world in absolute categories then this topic is for you. Because when it comes to destructors, custom copy and move constructors and operators of a class it is really about having "all or nothing"!
Jokes aside, in the previous video about move semantics we had an example struct
that made use of copy and move assignment operators. Turns out there are rules to follow when implementing these operators and their "friends". As a result the struct in that video did not follow a good style.
A logical question then seems to be "what is the good style?". Long story short:
Good style is a style that helps us avoid mistakes and makes things easier to implement.
There are many rules about good style when it comes to writing classes but I don't want to postulate any rules out of context. Rather, in the spirit of this course, let's build up to these good practices by formulating various "rules of good style" and summarize them as a single easy-to-remember rule afterwards.
So, in the previous lecture we had a struct HugeObject
that owned some big chunk of memory. Today, we're going to make it a class to ensure encapsulation but other than that it does the same things as before. It allocates a chunk of memory in its constructor through some magic function AllocateMemory
and frees this memory in its destructor through some other magic function FreeMemory
.
#include <cstddef>
std::byte *AllocateMemory(std::size_t length) { return new std::byte[length]; }
void FreeMemory(std::byte *ptr) { delete[] ptr; }
// 😱 Note that this class does not follow best style.
class HugeObject {
public:
HugeObject() = default;
explicit HugeObject(std::size_t data_length)
: length_{data_length}, ptr_{AllocateMemory(length_)} {}
~HugeObject() { FreeMemory(ptr_); }
private:
std::size_t length_{};
std::byte *ptr_{};
};
To help us down the line we want to be able to get the address of the allocated memory, so let's add a function that will give it to us.
As covered in the lecture about object lifecycle, we can provide a simple getter function that returns a pointer to our const data.
std::byte const *ptr() const { return ptr_; }
🎨 Note that such a simple getter function usually has a name of the variable it returns without the trailing underscore [Google style].
💡 Oh, and if you are confused about the return type of this function give a lecture about the raw pointers a go.
One final preparatory touch, let's also introduce a simple main
function that creates a HugeObject
instance and prints the address of the memory allocated for it:
int main() {
const HugeObject object{42};
std::cout << "Data address: " << object.ptr() << std::endl;
return 0;
}
💡 Note that the
const
in theptr()
function allows us to call it on a constantobject
variable.
If anything here confuses you, then do refresh your knowledge on object lifecycle in one of the previous lectures.
Now that we're done with the preparations, I would like to focus on the destructor here! What happens if it is missing?
Right now when an object is created it allocates memory and when it gets destroyed it frees this memory. If we miss the destructor, FreeMemory
will not be called and the memory will stay behind, causing a memory leak.
This already gives us a glimpse into our first "rule":
Rule 1: If we acquire resources manually in the constructor we must have a destructor that releases these resources.
Note that even with this destructor in place we still must explicitly state here that this class does not follow the best practices. We'll find out why pretty soon.
Hopefully you did not learn anything really new by now. We touched upon this topic in the object lifecycle lecture before. So now it is about time we focus on why does the comment above our class still hold?
Let's illustrate an issue with our class by changing our main
function a little bit. If we introduce another object of the HugeObject
type and initialize it as a copy of our existing object the code will compile but will crash when we run it!
int main() {
const HugeObject object{42};
std::cout << "object data address: " << object.ptr() << std::endl;
const HugeObject other_object{object};
std::cout << "other_object data address: " << other_object.ptr() << std::endl;
return 0;
}
Let's unpack this. First of all, why does it even compile in the first place? There is no constructor for HugeObject
class that takes another instance of HugeObject
class, and yet it still compiles! What is going on here?
The reason is that the compiler is trying to be helpful. A constructor that takes a constant reference to the current type is called a copy constructor and the compiler generates a trivial copy constructor for our class if none is provided by the user. What do we mean by trivial? Means that it just copies all the variables from one object to another without giving it a second thought.
We could even write one ourselves! Essentially, in our case a trivial copy constructor would take a constant HugeObject
reference and will copy the length and the pointer to our new object:
// 😱 Not a good idea in our case, just showing what a trivial constructor is.
HugeObject(const HugeObject &object)
: length_{object.length_}, ptr_{object.ptr_} {}
Note how we can use private members of another object here as we are still within the same
HugeObject
class even though we're dealing with a different instance of this class.
Those of you who watched the video on move semantics carefully might already notice the issue with such a trivial constructor. 😉
Really, try to figure this one out before watching further! Do re-watch the move semantics video if needed. I'll wait!
Hope you got it by now! The issue is that the trivial constructor just copies over the pointer to a different object, not the data!
So now we have two objects pointing to the same data. And both of these have destructors that will try to remove these data! So in our case the destructor of the other_object
will succeed at freeing the memory but the destructor of the object
will try to free the memory that has already been freed, causing a runtime error that mentions something along the lines of freeing the memory twice:
a.out(78797,0x1e21a6500) malloc: Double free of object 0x155e06ac0
a.out(78797,0x1e21a6500) malloc: *** set a breakpoint in malloc_error_break to debug
Let's dig a little into why this happened. The reason for this error is that there is a number of functions that we use to actively manage the resources that a certain object owns. In our case, we have a constructor that allocates memory and a destructor that frees this memory. What we missed here is that we also need to actively manage memory when copying our object. A trivial copy constructor does not do it - it just copies the pointer. So, here is our new rule:
Rule 2: If we manage resources manually in our class, we need a custom copy constructor.
For completeness, let's add the missing proper copy constructor to our class.
It needs to copy the length of the allocated memory, allocate the needed amount of memory and copy the content of the incoming object's data into its newly allocated memory:
HugeObject(const HugeObject &object)
: length_{object.length_}, ptr_{AllocateMemory(length_)} {
std::copy(object.ptr_, object.ptr_ + length_, ptr_);
}
Can we remove the annoying comment now, I hear you ask? Unfortunately not yet 🤷
Let me illustrate by changing our main
function again. Instead of creating another_object
by copying object
directly, we will first create a new object as empty and only then assign object
to it:
int main() {
const HugeObject object{42};
std::cout << "object data address: " << object.ptr() << std::endl;
HugeObject other_object{23};
other_object = object;
std::cout << "other_object data address: " << other_object.ptr() << std::endl;
return 0;
}
If we now compile and run this code we will get exactly the same runtime error as before. I know that at this moment it is very tempting to just flip the table and never return to C++ again but actually, nothing too magical happens here. It's just that the helpful compiler generates more than just a trivial copy constructor. It also generates a trivial copy assignment operator which we actually have already seen in the previous video!
And, the situation here is even worse than with the copy constructor - not only we have a runtime error when our objects get destroyed but we also have a memory leak from the moment we perform the assignment! The memory allocated for the
other_object
is never freed as nothing points to it!
Fixing this is as easy as it was for the copy constructor. We just need to write our own custom copy assignment operator.
It is very similar to the copy constructor with just a couple of differences. It returns a reference to HugeObject
and performs two additional steps: it needs to check if we are trying to perform a self-assignment, meaning that we're trying to assign the object to itself, and it needs to free the memory if we had any allocated from before, fixing the memory leak that we've just talked about. Other than that it copies the length, allocates new memory and copies the memory of the incoming object into this newly allocated memory.
HugeObject &operator=(const HugeObject &object) {
if (this == &object) { return *this; } // Do not self-assign.
FreeMemory(ptr_); // In case we already owned some memory from before.
length_ = object.length_;
ptr_ = AllocateMemory(length_);
std::copy(object.ptr_, object.ptr_ + length_, ptr_);
return *this;
}
This actually brings us to our third rule:
Rule 3: If we manage resources manually in our class, we need a custom copy assignment operator.
If you live in a world where you use only C++ versions before 11 then you could stop here but in a modern world we are missing a big chunk from this topic. You might have already guessed what it is - the move semantics!
Just as compiler generates implicit copy constructor and assignment operator it also sometimes generates implicit move constructor and assignment operator although in slightly different circumstances - it only generates them if the user has defined no explicit destructor or copy constructor and assignment operator. But it still might cause problems to us so let's dig into this.
Let's return back to our main function that used a copy constructor and modify it again by adding std::move
to our object
when passing it to the other_object
to make sure that we are using a move constructor of our HugeObject
class. And while we're at it let's also print the address of object
after move:
int main() {
HugeObject object{42};
std::cout << "object data address: " << object.ptr() << std::endl;
const HugeObject other_object{std::move(object)};
std::cout << "object data address: " << object.ptr() << std::endl;
std::cout << "other_object data address: " << other_object.ptr() << std::endl;
return 0;
}
When we run it, we see that the other_object.ptr()
points to an address different from where the object.ptr()
points to, even after the move! If you remember the lecture about the move semantics this is not what we want! We want the other_object
to steal the data from object
. Right now it sure looks like the data is just copied over. And this is indeed the case which you can verify by adding a printout to our copy constructor and see that it is indeed called.
The reason for this is that the compiler will only generate a move constructor if there is no custom destructor and no custom copy constructor and assignment operator. By now our class has all of these! So the compiler will not generate a move constructor for us and the rvalue reference will just bind to the normal lvalue reference in our existing copy constructor, so we will perform an unnecessary copy! So, how do we make our class moveable?
By now we already know what to do! We know that we just need to write a custom move constructor and that is it!
We write it in a very similar way to the copy constructor with the slight difference that we take an rvalue reference to HugeObject
as input, don't copy the data and set the other object's ptr_
field to nullptr
(again we cover this in-depth in the move semantics video):
HugeObject(HugeObject &&object) : length_{object.length_}, ptr_{object.ptr_} {
object.ptr_ = nullptr;
}
It is clear that we must also have another rule which probably also connects to having a custom destructor just like it does for the copy constructor:
Rule 4: If we manage resources manually in our class, we need a custom move constructor.
Remember how once we had a copy constructor we also needed a copy assignment operator? Would you be surprized if I told you that the same story repeats here?
I'd like to leave the implementation of a move assignment operator to you as a small homework. I'm sure you are going to be able to piece it together from this and the move semantics videos. If you get stuck the full code is in the script to this video, as always.
Anyway, once you're done you will know that there is one last rule that we need:
Rule 5: If we manage resources manually in our class, we need a custom move assignment operator.
Don't forget that after you're done implementing the move assignment operator, while there are still some things to improve about our class, we can remove the annoying comment at the top of it as the rest of the improvements are pretty minor!
It's time we summarize our findings somewhat. We might notice here that all of these custom functions rely on the fact that we have to manage some resource of an object manually. In summary we can reformulate all of the previous rules as a single one:
If the class manages resources manually, it must explicitly implement the following:
- A custom destructor
- A custom copy constructor
- A custom copy assignment operator
- A custom move constructor
- A custom move assignment operator
The rule above is also known under the name of a "rule of 5" as there are 5 special functions here which was a "rule of 3" before move semantics was introduced.
An alternative way to think about this rule is to think that if we find that we need to implement just one of the special functions that we just discussed then we most likely need to implement all the rest of them.
That being said, I want to stress that we actually nearly never manage our resources manually! And if we don't manage them manually there is no reason to implement any of the special functions we've just discussed!
So instead of the "rule of 5" I prefer talking about the rule of "all or nothing":
Don't define custom destructor, copy or move constructor or copy or move assignment operators. If just one of them needs to be defined, explicitly define the rest of those operations. [ref] [ref]
This is a simple rule to follow and I hope that you now also understand why it is needed. We will touch more upon it when we start talking about polymorphism in the context of object oriented programming but for now thanks for following along!
You can find the full code in this file: all_or_nothing.cpp