Managing Memory Manually

Learn the techniques and pitfalls of manual memory management in C++

Ryan McCombe
Updated

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, how they get copied, 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++.

Using the new and delete Operators

In 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.

Memory Leaks

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, we can see what's going on in memory using the diagnostic tools. Our previous program continuously creates 42s and deletes none of them, causing its memory footprint to steadily increase over time:

Eventually, the system we're running on will have no more memory available. This means our program can't create any more objects, which likely means it can no longer function correctly.

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 and widely used, at least some of those users are likely to create the circumstances where the leak happens.

Therefore, we should proactively take steps to ensure all the components of our program - the classes and functions - are memory-safe. The best option for this is to simply avoiding new and delete entirely, and instead rely on containers that take care of the memory management for us. Examples include std::unique_ptr for single items, and std::vector for collections.

When that isn't possible, we should consider adopting design patterns that make memory management easier, such as RAII.

Resource Acquisition Is Initialization (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, it can be difficult to understand 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 an object needs in order for that object to work correctly. 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 might 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 we know we don't need to delete its subresources directly. The Monster class implements RAII, so it will take care of that for us:

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()} {}

  ~Dungeon() {
    // 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};
}

Polymorphism and Virtual Destructors

Let's introduce another common way attempting to manage memory manually can trip us up. We'll create a Character class, and make it polymorphic by giving it a virtual function:

class Character {
public:
  virtual void Render() {}
};

We covered earlier in the course if you need a refresher:

We'll update our Monster class to inherit from this polymorphic base type. We'll also add some logging to our Monster's constructor and destructor, so we can see when they're called:

// CharacterTypes.h
#pragma once
#include <iostream>

class Character {/*...*/}; class Monster : public Character { public: Monster() : Art{new Image{256, 256}} { std::cout << "Creating Monster\n"; } ~Monster() { std::cout << "Deleting Monster\n"; delete Art; } Image* Art{nullptr}; };

Let's use this polymorphism to create a Monster, and store it in a Character pointer. Some time later, when we're done with our Character, we'll dutifully delete it:

// main.cpp
#include "CharacterTypes.h"

int main() {
  Character* C{new Monster{}};

  // ...

  delete C;
}

Running our program, we can see that the Monster constructor was called, but annoyingly, its destructor wasn't:

Creating Monster

This Monster destructor deletes the Image asset that the constructor allocated, so the fact that it's not being called means we have a memory leak.

But why isn't the destructor being called?

Like with any C++ function, a destructor is statically bound by default as a performance optimization. The delete operator sees we're passing it a Character pointer, and the Character destructor is not virtual. Therefore, it calls the Character version of the destructor rather than searching for the more derived Monster version.

In general, if a type has any virtual function, then it should also have a virtual destructor. If we don't need any custom behaviour in the destructor, we can implement a virtual default destructor like this:

// CharacterTypes.h
#include <iostream>
#pragma once

class Character {
public:
  virtual void Render() {}
  virtual ~Character() = default; 
};

class Monster : public Character {/*...*/};

With no further changes, our delete operator now calls the Monster destructor, fixing the memory leak:

Creating Monster
Deleting Monster

Use After Free and Double Free Errors

The previous lesson on copy constructors and operators may have prompted you to notice a further problem with our Monster class.

Let's create a copy of a Monster object by passing it by value to a function. After the function ends, we'll try to use its Art resource:

#include <iostream>

struct Image {/*...*/};
class Monster {/*...*/}; 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 has some issues. The problem is that it 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 yet another 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.

Copy Constructor

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
    }} {}
  // ...
};

Copy Assignment Operator

In the copy assignment operator, we have two approaches we can use. Either approach is valid - the best option will depend on our specific requirements.

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 Rule of Three

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 destructor
  • The copy constructor
  • The copy assignment operator

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 properly. As such, the compiler-provided defaults for the other two functions probably aren't managing memory properly 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 consider whether we need to customise the copy constructor and operator too.

Interactions with Smart Pointers

In modern C++, we want to avoid manual memory management as much as possible. To this end, a common goal is to minimise the occurrances of the new and delete operators in our code.

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.

The get() Method

The get() method of standard library smart pointers returns the raw memory address of the object it is managing. This is useful when we need to provide that pointer to some function.

Such functions typically have no interest in assuming ownership over the object - they just need access to it. As such, they tend to receive the object as a raw pointer, and should never delete the object:

void Render(Image* Art) {
  // Render() can use Art
  // ...
  
  // But it doesn't own it, so shouldn't delete it
}

int main() {
  Monster A;
  Render(A.Art.get());
}

Constructor from Raw Pointer

Sometimes, we'll work with functions that dynamically allocate some memory, then return that memory address:

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 that it returns is deleted.

Note that, in the examples in this section, we're imagining that GetArt() is a function that we cannot easily change. Perhaps it is coming from some external library. If we could change this function, we'd update it to create and return a std::unique_ptr, making the memory ownership intent clear.

But, if we can't update GetArt() to return a smart pointer, the next best thing is to immediately store its return value in a smart pointer. To help with this, std::unique_ptr has a constructor that accepts a raw, dynamically-allocated memory address as an argument:

class Monster {
 public:
  Monster() : Art{GetArt()} {}
  
  // ...

  std::unique_ptr<Image> Art;
};

As always, the smart pointer is then assumed to be the owner of that resource, and will automatically handle deletion.

The reset() Method

The reset() method is how we tell a smart pointer it is now 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. Additionally, if the smart pointer was previously managing a different resource, it will delete that resource:

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());
}

The release() Method

Finally, 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;
};

Having a function receive an argument by a raw pointer which it eventually deletes is rarely a good design. But, in this hypothetical example, we're imagining HandleArt() is a function that we cannot easily change. Therefore, we need to use the smart pointer's release() function to adapt to HandleArt()'s unusual behaviour.

If we could change HandleArt(), we'd update it to accept its argument as a std::unique_ptr. This makes it clear to callers that HandleArt() is going to assume unique ownership of the resource they provide, and that it will ultimately delete it.

Summary

In this lesson, we covered the following topics:

  • We explored manual memory management using the new and delete operators.
  • We discussed potential issues like memory leaks, use-after-free, and double-free errors.
  • We introduced the RAII pattern to simplify resource management
  • The Rule of Three was explained to guide the proper implementation of class functions that manage memory
  • Finally, we examined how to combine smart pointers with raw pointers for effective resource management
Next Lesson
Lesson 48 of 60

Automatic Type Deduction using auto

This lesson covers how we can ask the compiler to infer what types we are using through the auto keyword

Questions & Answers

Answers are generated by AI models and may not have been reviewed. Be mindful when running any code on your device.

Avoiding Raw Pointers
Why should we avoid using raw pointers when possible in modern C++ programming?
Smart Pointer Pitfalls
What are some common pitfalls when using the get() method with smart pointers, and how can they be avoided?
Managing Resources with std::unique_ptr
How can you effectively use std::unique_ptr to manage resources in a class that also needs to support copying and assignment?
Using reset() vs Assignment
When should you use reset() method on smart pointers versus directly assigning a new smart pointer?
Custom Deleters with std::unique_ptr
What role does the custom deleter in std::unique_ptr play, and when would you need to use it?
Custom Deleters in Smart Pointers
Can you provide an example of using a custom deleter with a std::unique_ptr and explain its use?
Detecting Memory Leaks in Complex Applications
What are some strategies for detecting memory leaks in a large, complex application?
Rule of Three and std::unique_ptr
How does the Rule of Three apply when using std::unique_ptr for resource management?
Or Ask your Own Question
Get an immediate answer to your specific question using our AI assistant