Throughout this chapter, we’ve started to be more proactive in managing the memory used in our programs. This has allowed us to gain more control over the lifecycle of our objects, managing when they're created and when they're destroyed.
We’ve used standard library utilities like std::unique_ptr
and std::vector
to help with this. In this lesson, we’ll learn how to do this manually, without help from the standard library. We’ll also see how this can be difficult, dangerous, and something to avoid where possible.
However, avoiding it is not always an option. In larger projects, we’ll interact with libraries or other systems that implement manual memory management. In general, understanding the techniques of memory management and the problems that can be caused when it's done improperly are core skills in programming, particularly in C++.
new
and delete
OperatorsIn principle, manually controlling the lifecycles of our objects is incredibly simple. We use the new
keyword to create an object, and use the delete
keyword to get rid of it when it’s no longer needed.
The new
operator allocates memory for the type of object we want to create, and forwards any provided arguments to its constructor.
new int(42);
It creates our object in dynamic memory, and returns a pointer to it:
#include <iostream>
int main() {
int* Number{new int(42)};
std::cout << "Number: " << *Number;
}
Number: 42
Below, we use new
to create an object from a custom type:
#include <iostream>
struct Player {
std::string Name;
int Level;
};
int main() {
Player* PlayerOne{
new Player("Anna", 42)
};
std::cout << "Name: " << PlayerOne->Name;
}
When we no longer need an object created from new
, we pass its pointer to the delete
operator:
#include <iostream>
int main() {
int* Number{new int(42)};
std::cout << "Number: " << *Number;
delete Number;
}
Whilst using new
and delete
seems simple in theory, as we’ll see in this lesson, this is surprisingly difficult to do well. Additionally, the consequences of not doing it well can lead to serious bugs that are hard to detect and have unpredicable consequences.
The most obvious issue that we’ll encounter when manually managing memory is not deleting every object we create. The effect of such mistakes is rarely noticable in the simple programs we create in an introductory course.
However, in the real world, C++ programs typically run for much longer periods of time. Such programs are designed as a loop, which continues until some signal is received that the program should end:
int main() {
bool continueRunning{true};
while (continueRunning) {
// Do stuff...
if (userWantsToQuit()) {
continueRunning = false;
}
}
}
Throughout a long-running program, we’ll typically need to create many new objects. These might be new transactions to process in a banking application or new monsters to fight in a video game.
If we don’t delete every object we create, the memory usage of our program increases over time. This is known as a memory leak:
int main() {
bool continueRunning{true};
while (continueRunning) {
new int(42);
// Memory leaks here
}
}
Profiling tools can show the memory usage of our program. IDEs typically include tools that allow us to analyse what resources our programs are using, or we can use standalone tools such as Valgrind.
When debugging in Visual Studio, the diagnostic tools show our program is using more and more memory the longer it runs:
Eventually, the system we’re running on will simply have no more memory available, so our program can’t create any more new objects.
Profiling tools are useful, but they can’t catch everything. In complex programs with a lot of moving parts, memory leaks often aren’t obvious. They don’t always happen when our program is left idle. Rather, they happen in specific cases, or in response to specific user actions.
If we don’t perform those actions whilst debugging, those memory leaks won’t happen, so they won’t show up in our tools. However, once our program is released, users are likely to create the circumstances where the leak happens.
Therefore, we should proactively take steps to ensure our individual classes and functions are memory safe. The best option is simply avoiding new
and delete
entirely, and instead rely on techniques such as smart pointers.
When that isn’t possible, we should consider adopting design patterns that make memory management easier, such as RAII.
As our programs get more complex, scattering new
and delete
expressions throughout our code quickly gets difficult to keep track of. At any point in our program, we’re never quite sure which objects have been deleted and which are still alive.
We should instead strive to establish some standard convention to describe when objects get created and destroyed.
The Resource Acquisition Is Initialization (RAII) pattern is one such example. In this context, the word resource refers to something that our object needs to function. These subresources are other objects.
For example, we can imagine having a Monster
type, where each Monster
object has some artwork that it uses to render itself to the screen. Therefore, each Monster
would have a resource in the form of an Image
object held as a pointer:
struct Image {
int Width;
int Height;
// ...
};
class Monster {
public:
Image* Art{nullptr};
};
The RAII pattern ensures that the lifecycle of resources is tied to the lifecycle of the objects that manage them. In practice, this means resources are initialized in the object’s constructor and released in the destructor, simplifying memory management:
struct Image {
int Width;
int Height;
// ...
};
class Monster {
public:
Monster() : Art{new Image(256, 256)} {}
~Monster() {
delete Art;
}
Image* Art{nullptr};
};
This pattern simplifies things in a few ways. First, it establishes the convention that if an object is alive, we can assume that the resources it depends on are also alive.
Secondly, it establishes a clear understanding of which code is responsible for deleting resources. If we delete
a Monster
(or any other type that uses RAII) we know we don’t need to delete its subresources directly - the Monster
class itself will handle that:
class Encounter {
public:
Encounter()
: A{new Monster()},
B{new Monster()},
C{new Monster()} {}
~Encounter() {
// The Monster type uses RAII so they'll delete their
// Image resources - we don't need to worry about it here
delete A;
delete B;
delete C;
}
Monster* A{nullptr};
Monster* B{nullptr};
Monster* C{nullptr};
}
RAII is also inherently recursive, so scales for arbitrarily deep dependency trees. This Encounter
class also uses RAII, so can be managed in the same way from some object that holds an Encounter
as a resource, such as a Dungeon
:
class Dungeon {
public:
Dungeon()
: A{new Encounter()},
B{new Encounter()} {}
~Encounter() {
// We don't need to worry about the Monsters
// as they're deleted by the Encounters
delete A;
delete B;
}
Encounter* A{nullptr};
Encounter* B{nullptr};
}
The previous lesson on copy constructors and operators may have prompted you to notice a problem with our previous classes.
Let’s create a copy of a Monster
object by passing it by value to a function, then try to use its Art
resource:
#include <iostream>
struct Image {
int Width;
int Height;
};
class Monster {
public:
Monster() : Art{new Image(256, 256)} {}
~Monster() {
delete Art;
}
Image* Art;
};
void Render(Monster EnemyCopy) {
// ...
}
int main() {
Monster* Enemy{new Monster()};
Render(*Enemy);
std::cout << "Art Width: "
<< Enemy->Art->Width;
}
Art Width: -572662307
As the terminal output would indicate, our Image
subresource is being deleted too early. The highlighted line in the main
function is then attempting to use a resource that has already been deleted. This is called a Use After Free (UAF) error, and it is a fairly common pitfall we’ll encounter when trying to manage memory manually.
Additionally, this simple program has a second memory issue, called a double free error. Specifically, we’re calling new
once (when we construct Enemy
) but delete
twice (in the destructor of both Enemy
and EnemyCopy
).
Calling delete
on a location that has already been freed results in memory corruption. In this case, the error happens in Enemy
's destructor, which is called at the end of main
just before our program closes.
But, if we create two copies of a Monster
instance, the corruption can happen when our program is still running. The following program is likely to crash before it logs anything:
int main() {
Monster* Enemy{new Monster()};
Render(*Enemy);
Render(*Enemy);
std::cout << "Art Width: "
<< Enemy->Art->Width;
}
.\out\build\x64-release\example.exe (process 11576) exited with code -1.
As we might expect, to solve these problems, we have to intervene in the copying process of Monster
objects by defining the copy constructor and copy assignment operators.
Let’s implement the copy constructor. This follows the same principles we covered in the previous lesson, but we now apply it to raw pointers. When a Monster
object is copied, the copy needs to create its own Art
resource, which we can do using the new
operator:
class Monster {
public:
// ...
Monster(const Monster& Other)
: Art{new Image{
Other.Art->Width, Other.Art->Height}} {}
// ...
};
In the copy assignment operator, we have two approaches we can use. Either approach is valid - the best option will depend on the specific context we’re working in.
The first option is to update the existing Art
resource, using values from the Monster
we’re trying to copy. Typically, this involves calling the subresource’s copy assignment operator.
In this case, we haven’t implemented a copy assignment operator for the Image
class, so we’d use the default provided by the compiler:
class Monster {
public:
// ...
Monster& operator=(const Monster& Other) {
if (&Other == this) return *this;
*Art = *Other.Art;
return *this;
}
// ...
};
The second option is to delete the existing resource and create a new one from scratch. This typically means calling the copy constructor of the resource type.
Again, we haven’t provided a copy constructor for the the Image
type, so this code will use the compiler-provided default:
class Monster {
public:
// ...
Monster& operator=(const Monster& Other) {
if (&Other == this) return *this;
delete Art;
Art = new Image(*Other.Art);
return *this;
}
// ...
};
The problem we encountered in the previous section is so ubiquituous that a convention exists to deal with it: the rule of three. It relates to the following three functions of a class:
The rule states that if we need to provide an implementation for any of these functions, we’d typically need to implement all of them.
This is because the primary scenario we’d need to provide a custom implementation for one of these functions is that the compiler-provided default isn’t managing memory appropriately. Accordingly, the compiler-provided default for the other two functions are also unlikely to be appropriate either.
In modern C++, the rule of three is not a strict requirement. For example, we’ve seen how smart pointers like std::unique_ptr
can automate the management of a resource, removing the need to create a destructor.
However, it still remains a useful rule of thumb. If we find ourselves writing a destructor, for example, we should remind ourselves to consider whether we need to customise the copy constructor and operator too.
In modern C++, we want to avoid manual memory management as much as possible. A common goal is to minimise the occurrances of the new
and delete
operators as much as possible.
Smart pointers, such as std::unique_ptr
, are the main way we can achieve this. When our object is managing its resource through a std::unique_ptr
, we replace the new
operator with std::make_unique()
, and we don’t need to use the delete
operator at all:
class Monster {
public:
Monster()
: Art{std::make_unique<Image>(256, 256)} {}
Monster(const Monster& Other)
: Art{std::make_unique<Image>(*Other.Art)} {}
Monster& operator=(const Monster& Other) {
if (&Other == this) return *this;
*Art = *Other.Art;
return *this;
}
std::unique_ptr<Image> Art;
};
However, we can’t use smart pointers everywhere. In this section, we’ll cover some of the main techniques that are used to combine smart pointers with "raw" pointers, such as those returned by the new
operator.
get()
MethodThe get()
method of standard library smart pointers returns the raw memory address of the object it is managing. This is useful when we hand that object off to some function.
Such functions typically have no interest in assuming ownership over the object. As such, they tend to receive the object as a raw pointer, and should never delete
the object:
void Render(Image* Art) {
// ...
}
int main() {
Monster A;
Render(A.Art.get());
}
Sometimes, we’ll work with functions that dynamically allocate some memory, then return that memory:
Image* GetArt() {
return new Image{256, 256};
}
We can imagine these functions transferring ownership of the object they create, and therefore the code that calls this function is responsible for ensuring the object is deleted.
We can immediately give that responsibility to a smart pointer. Smart pointers have a constructor that accepts a raw, dynamically-allocated memory address as an argument:
class Monster {
public:
Monster() : Art{GetArt()} {}
// ...
std::unique_ptr<Image> Art;
};
The smart pointer is then assumed to be the owner of that resource, and will automatically handle deletion.
reset()
MethodThe reset()
method is how we tell a smart pointer it is responsible for a different resource from what it is currently managing. We pass the dynamically allocated memory address to the reset()
method.
The smart pointer will assume responsibility for this new resource and, if it was previously managing a different resource, it will delete it:
Image* GetArt() {
return new Image{256, 256};
}
class Monster {
public:
// ...
void UpdateArt(Image* NewArt) {
Art.reset(NewArt);
}
// ...
std::unique_ptr<Image> Art;
};
int main() {
Monster A;
A.UpdateArt(GetArt());
}
release()
MethodFinally, the release()
method will cause the smart pointer to give up ownership over its resource, without deleting it. It returns a raw pointer to the resource, so that some other object or function can take ownership, and ultimately delete it:
void HandleArt(Image* Art) {
// ...
delete Art;
}
class Monster {
public:
// ...
~Monster() {
HandleArt(Art.release());
}
std::unique_ptr<Image> Art;
};
Remember, smart pointers are intended to manage dynamically allocated memory. Stack allocated memory, such as global or local function variables, is already managed automatically.
There’s no use case where we’d want to have a smart pointer manage such a memory address. Doing so result in some of the memory corruption issues we introduced in this lesson, so it is something we should avoid:
int main() {
Image Art;
// A stack allocated memory address managed
// by a smart pointer is undefined behavior
std::unique_ptr<Image> Ptr{&Art};
}
In this lesson, we covered the following topics:
new
and delete
operators.In our upcoming chapter, we'll explore the principles of creating robust, maintainable code. We'll delve into topics like:
Learn the techniques and pitfalls of manual memory management in C++
Become a software engineer with C++. Starting from the basics, we guide you step by step along the way