An introduction to memory management within C++, starting with stack-allocated memory.
In the beginner course, we introduced the concept of the call stack, which is generated by the function calls in our program.
We can see the call stack in action using a class with a constructor and destructor:
#include <iostream>
class Character {
public:
Character(){
std::cout << "Creating Character\n";
}
~Character(){
std::cout << "Destroying Character\n";
}
};
void SelectCharacter() {
// A new stack frame is created for
// SelectCharacter. Local variable Frodo is
// allocated on the stack
Character Frodo;
// When SelectCharacter ends, Frodo is
// deallocated. Destructor is called as the
// stack frame is removed
}
int main() {
std::cout << "Program Starting\n";
// Call SelectCharacter, creating and
// destroying Frodo
SelectCharacter();
// After SelectCharacter returns, its stack
// frame is removed. Memory used by Frodo is freed
std::cout << "Program Ending\n";
}
Program Starting
Creating Character
Destroying Character
Program Ending
If the concept of constructors and destructors are unfamiliar, I’d recommend reviewing this lesson:
We’re already familiar that we can create objects within the local scope of our functions. An example of this is the Frodo
object, created within the SelectCharacter()
function of our previous example.
Given we can create objects in our functions, we may have predicted, therefore, that the stack has memory available to store these objects.
This is indeed the case. When we create variables in our functions, we are given the appropriate amount of memory from the stack to store those variables.
When the function ends, the stack frame is removed, local variables are deleted, and the memory is freed up for other uses.
However, while the stack is incredibly efficient for managing memory on a function-by-function basis, it is not without its limitations.
Typically, the size of the stack in a C++ program is determined by the operating system and the settings of the compiler used. For instance, on many systems, the default stack size might be around 1 MB to 2 MB.
This is generally sufficient for most routine operations and function calls. However, it's often not sufficient to store large objects, or large collections of objects.
Attempting to allocate large data structures on the stack can lead to a stack overflow, where the stack's limit is exceeded, potentially causing the program to crash or behave unpredictably.
This form of memory management where objects are deleted automatically is often useful, but it does restrict our options
For example, consider a scenario where we want our SelectCharacter
function to return a pointer to the character:
#include <iostream>
class Character {
public:
~Character() {
std::cout << "Destroying Character\n";
}
string Name { "Frodo" };
};
Character* SelectCharacter() {
Character Frodo;
return &Frodo;
}
int main() {
Character* SelectedCharacter {
SelectCharacter()
};
std::cout << "Getting Character Name:\n";
std::cout << SelectedCharacter->Name;
}
The output of this program could be something like the following:
Destroying Character
Getting Character Name:
1�I�^H�H�PTI�#@H�`#@H�@�* �D�f.�@�@
The fact that line 3 was garbage is perhaps predictable given the proceeding output. The Character
has already been destroyed by the time we come to log its name. This is because it was allocated within the SelectCharacter
function’s stack frame.
So, the Frodo
pointer within our main function is pointing at memory no longer allocated to our program.
Hopefully, our compiler will have warned us of this:
warning: reference to stack memory associated
with local variable 'Frodo' returned
Updating the previous example to return Frodo
by value rather than by reference would have worked as expected:
Character SelectCharacter() {
Character Frodo;
return Frodo;
}
int main() {
Character SelectedCharacter {
SelectCharacter()
};
std::cout << "Getting Character Name:\n";
std::cout << SelectedCharacter.Name << '\n';
}
Getting Character Name:
Frodo
Destroying Character
When we return something from a function, that data is moved to the stack frame that called our function. In this example, it’s the main
function that receiving Frodo
When we return Frodo
by value, we’re moving the Frodo
object. Now, the Frodo
object is not destroyed when SelectCharacter
ends. It is instead moved to main
, and only destroyed once main
ends.
Previously, when we returned a pointer, we moved just the pointer to the main
function. The Frodo
object is still within the SelectCharacter
stack frame. Therefore, it gets destroyed with the stack frame, and the pointer that was moved to main
is no longer useful.
When an object has been deleted and a pointer that pointed to it has not been updated to reflect this, it is sometimes referred to as a dangling pointer.
So far, we've delved into the workings of stack memory in C++ and its limitations. In the next lesson, we’ll explore the Free Store, often referred to as the Heap.
The Free Store is a region of memory that programs use to allocate objects whose lifetime is not tied to the scope of a function. Unlike stack memory, where objects are automatically managed and limited in size, the Free Store allows for dynamic memory allocation.
This means we can allocate memory at runtime, and it's your responsibility to free it when it's no longer needed.
Understanding the Free Store is crucial because:
In our upcoming lesson, we'll dive into:
Learn about stack allocation, limitations, and transitioning to the Free Store
Comprehensive course covering advanced concepts, and how to use them on large-scale projects.