A first glimpse of memory ownership and lifetimes
Let's assume for a moment that we use RAII to wrap all dynamic memory allocations inside some well-named classesSpoiler: We will do just that in a moment. There are a lot of these classes available in both C++ and Rust (though in Rust they are not called classes), and they are really useful!, just as we did with our SillyVector
class. Does this solve all our problems with dynamic memory management?
The main problem that we were trying to solve with the RAII approach was that different pieces of heap memory will have different lifetimes. Some will live only a short time, others will live longer or even as long as the whole program runs. Through RAII, we are able to tie the lifetime of dynamic memory to the automated scope-system that C++ and Rust provide. But that really only shifted our problem from one place to another. Where before we had to memorize where to release our memory (e.g. by calling free
), we now have to make sure that we attach our RAII-objects (like SillyVector
) to the right scope. Remember back to the resource lifecycle that we learned about in chapter 2.4 and the associated concept of a resource owner. Through RAII, classes that manage dynamic memory, such as SillyVector
, become owners of this dynamic memory. By the transitive property, the scope that an object of such a class lives in becomes the de-facto owner of the dynamic memory.
Why is this distinction important? After all, scope management is an inherent property of the C++ and Rust programming languages, and the compiler enforces its rules for us. It would not be important, were it not for two facts: First, just as we used dynamic memory allocation to break the hierarchical lifetime structure of the stack, we now also need a way to make our RAII-classes life longer than a single scope. Otherwise, how can we ever write a function that creates an object that lives longer than this functionFunctions that are responsible for creating objects or obtaining resources are sometimes called factory functions and they are used quite frequently in many programming languages.? Second, up until now we silently assumed that every resource can only have exactly one owner, however in reality, this is not always the case. There are certain scenarios where it is benefitial for the simplicity of the code or even required for correctness to have multiple simultaneous owners of a single resource. How would we express this ownership model with our SillyVector
class?
How to leave a scope without dying
Let's start with the first situation: How do we write a function that creates a SillyVector
and hands it to its calling function? The intuitive way of course is to simply return the SillyVector
from the function. After all, this is what 'returning a value from a function' means, right? But think for a moment on how you might realize this notion of 'returning a value from a function', with all that we know about scope rules. After all, we said that at the end of a scope, every variable/object within that scope is automatically destroyed! Take a look at this next example and try to figure out what exactly is happening with the dummy
variable inside the foo
function, once foo
is exited. For convencience, we even print out whenever a constructor and destructor is called:
#include <iostream>
#include <string>
struct Dummy {
std::string val;
Dummy(std::string val) : val(val) {
std::cout << "Dummy is created" << std::endl;
}
~Dummy() {
std::cout << "Dummy is destroyed" << std::endl;
}
};
Dummy foo() {
Dummy dummy("hello");
return dummy;
}
int main() {
Dummy dummy = foo();
std::cout << dummy.val << std::endl;
return 0;
}
Interestingly enough, there is only one destructor called, at the end of the main
method. But inside foo
, dummy
is a local variable. Why did it not get destroyed? Sure, we wrote return dummy;
, but that does not explain a lot. A perfectly resonable way that this example could be translated to would be this:
int main() {
Dummy outer_dummy;
// We copy-pasted the function body of 'foo()' into main:
{ // <-- The scope of 'foo()'
Dummy dummy("hello");
outer_dummy = dummy;
} // <-- 'dummy' gets destroyed here
std::cout << outer_dummy.val << std::endl;
return 0;
}
Here we took the function body of the foo()
function and just copied it into the main
method. Our return statement is gone, instead we see an assignment from the local dummy
variable to the variable in the scope of main, now renamed to outer_dummy
. If we run this code, we see that two destructors are run, as we would expect, since first the dummy
variable gets destroyed at the end of the local scope, and then the outer_dummy
variable gets destroyed once we exit main
.
To solve this puzzle, we have to introduce the concept of return value optimization (RVO). It turns out that our manual expanded code from the previous example is actually how returning values from functions used to work, at least as far as the C++ standard was concerned. Remember, the C++ standard and the implementations of the C++ compilers are two different things. Compilers sometimes do stuff that yields better performance in practice, even though it does not agree with the standard fully. Returning values from functions is one of these areas where we really would like to have the best possible performance. Without any optimizations, returning a value from a function goes like this:
- Create an anonymous variable in the scope of the calling function (
main
in our example) - Enter the function, do the stuff, and at the end of the function, copy the function return value into the anonymous variable in the outer scope
- Clean up everything from the function's scope
So data is actually copied here, because otherwise our local variable would be destroyed before we could attach it to the variable in the outer scope. We will learn shortly what a copy actually is from the eyes of a systems programmer, but for now suffice it to say that copies can be costly and we want to prevent them, especially for something as trivial as returning a value from a function. So what most compilers do is they automatically rearrange the code in such a way that no copy is necessary, because the local variable within the functions scope (dummy
in our case) gets promoted to the outer scope. This is an optimization for return values, hence the name RVO, and since C++17, it is mandated by the standardAs always, rules are complicated and RVO is only mandated in simple cases. In reality, compilers are quite good at enabling RVO, but there are some scenarios where it is not possible., which is why we see just one destructor call in our initial example.
Great! Nothing to worry about, we can safely write functions that create these RAII-objects and return them to other functions. Now, what about this other thing with multiple owners?
The Clone Wars
In the previous section, we learned how to move from an inner scope to a greater scope, using return
. The opposite should also be possible: Taking an object from an outer scope and making it available to the scope of a function that we call. This is what function arguments are for, easy! Let's try this:
void dummification(Dummy dummy) {
std::cout << "Got a Dummy(" << dummy.val << ")" << std::endl;
}
int main() {
Dummy dummy("hello");
dummification(dummy);
std::cout << dummy.val << std::endl;
return 0;
}
If we run this example, we will find not one but two destructor calls. Of course, if you have done a little C++ in the past, you know why this is the case: The dummification
function takes its argument by value, and passing arguments by value copies them in C++. For our Dummy
class, this does not really matter, but see what happens when we run the same code with our SillyVector
class:
void dummification(SillyVector<int> dummy) {
std::cout << "Our vector has " << dummy.get_size() << " elements\n";
}
int main() {
SillyVector<int> vec;
vec.push(42);
vec.push(43);
dummification(vec);
std::cout << vec.get_size() << std::endl;
return 0;
}
Oops, a crash. free(): double free detected in tcache 2
. That doesn't look good... Of course we commited a cardinal sin! Our SillyVector
class was written under the assumption that the underlying dynamic memory has just one owner. And now we called dummification
and created a copy of our SillyVector
. The copy gets destroyed at the end of dummification
, freeing the dynamic memory, while there is still another object pointing to this memory! Once this object (vec
) gets destroyed, we are calling free()
with a memory region that has already been freed and get an error.
But wait: How actually was the copy of our SillyVector
created? In C++, for creating copies of objects, the copy constructor is used. But we never wrote a copy constructor! In this case, the C++ rules kicked in. The compiler figured out that we want to copy our object at the call to dummification
, and it was nice enough to generate a copy constructor for us! How nice of it! The copy constructor it generated might look something like this:
SillyVector(const SillyVector& other) :
data(other.data),
size(other.size),
capacity(other.capacity) {}
This copy constructor simply calls the copy constructor for all the members of our SillyVector
class. All our members are primitive types (a pointer and two integers), so they can be copied using a simple bit-wise copy. However given the semantics of our SillyVector
class, this is wrong! SillyVector
stores a pointer to a dynamic memory region, and this pointer owns the memory region. It is the responsibility of SillyVector
to clean up this memory region once it is done with it. We can't just copy such an owning pointer, because then we move from a model with just one unique owner to a model with two shared owners. Clearly, if two people own something, one person can't destroy the owned object while the other person is still using it.
Now comes the grand reveal: Rust enters the picture!