So far, all of the memory our C++ code has allocated has either been global or on the stack. For the many times when the amount of memory isn’t known at compile time, we’ll need dynamic allocation. Today we’ll go over both the basics of new and delete, but also dive into some advanced C++ features such as overloading new and “placement” new.

Table of Contents

History and Strategy

Let’s start by looking at a bit of a history which is still very relevant to C++ programming today. In C, not C++, memory is dynamically allocated using a family of functions in the C Standard Library whose names end in alloc:

// Dynamically allocate 4 bytes
void* memory = malloc(4);
 
// Check for allocation failure
// Not necessary for small (e.g. 4 byte) allocations
// Needed for large (e.g. array) allocations
if (memory != NULL)
{
    // Cast to treat it as a pointer to an int
    int* pInt = (int*)memory;
 
    // Read the memory
    // This is undefined behavior: the memory hasn't been initialized!
    DebugLog(*pInt);
 
    // Release the memory
    free(memory);
 
    // Write the memory
    // This is undefined behavior: the memory has been released!
    *pInt = 123;
 
    // Release the memory again
    // This is undefined behavior: the memory has already been released!
    free(memory);
}

“Raw” use of malloc and free like this is still common in C++ codebases. It’s a pretty low-level way of working though, and generally discouraged in most C++ codebases. That’s because it’s quite easy to accidentally trigger undefined behavior. The three mistakes in the above code are very common bugs.

Higher-level dynamic allocation approaches make these mistakes either harder to make or impossible. For example, in C# there’s no way to get memory that hasn’t been initialized since everything is set to zero, no way to have a reference to released memory since it’s only released after the last reference is relinquished, and no way to double-release memory since that’s handled by the GC.

C++ doesn’t take such a high-level approach as C# since the above C code is also legal C++. It does, however, provide many higher-level facilities for the majority of cases where safety is preferable to total control.

Allocation

The new operator in C++ is conceptually similar to using the new operator with classes in C#. It dynamically allocates memory, initializes it, and evaluates a pointer:

struct Vector2
{
    float X;
    float Y;
 
    Vector2()
        : X(0), Y(0)
    {
    }
 
    Vector2(float x, float y)
        : X(x), Y(y)
    {
    }
};
 
// 1) Allocate enough memory for a Vector2: sizeof(Vector2)
// 2) Call the constructor
//    * "this" is the allocated memory
//    * Pass 2 and 4 as arguments
// 3) Evaluate to a Vector2*
Vector2* pVec = new Vector2{2, 4};
 
DebugLog(pVec->X, pVec->Y); // 2, 4

The new operator combines several of the manual steps from the C code so we can’t forget to do them or accidentally do them wrong. As a result, safety is increased in numerous ways:

  • The amount of memory allocated is computed by the compiler, so it’s always correct
  • The allocated memory is always initialized (i.e. by the constructor), so we can’t use it before it’s initialized
  • The initialization code is always passed the right pointer to the allocated memory
  • The allocated memory is always cast to the correct type of pointer
  • Allocation failures are always handled (more below)

C# allows us to use new with classes and structs. In C++, we can use new with any type:

// Dynamically allocate a primitive
int* pInt = new int{123};
DebugLog(*pInt); // 123
 
// Dynamically allocate an enum
enum class Color { Red, Green, Blue };
Color* pColor = new Color{Color::Green};
DebugLog((int)*pColor); // Red

We can also allocate arrays of objects with new. Just like other arrays, each element is initialized:

// Dynamically allocate an array of three Vector2
// The default constructor is called on each of them
Vector2* vectors = new Vector2[3]();
 
DebugLog(vectors[0].X, vectors[0].Y); // 0, 0
DebugLog(vectors[1].X, vectors[1].Y); // 0, 0
DebugLog(vectors[2].X, vectors[2].Y); // 0, 0

This is different from an array in C# in two ways. First, it’s not a managed object as C++ doesn’t have a garbage collector. Second, it’s an array of Vector2 objects, not references to Vector2 objects. It’s more like an array of C# structs than an array of C# classes: the objects are laid out sequentially in memory.

Initialization

Regardless of the type, new always takes the same steps: allocate, initialize, then evaluate to a pointer. Initialization is controlled by what we put after the name of the type: nothing, parentheses, or curly braces. If we put nothing, the object or array of objects is default-initialized:

// Calls default constructor for classes
Vector2* pVec1 = new Vector2;
DebugLog(pVec1->X, pVec1->Y); // 0, 0
 
// Does nothing for primitives
int* pInt1 = new int;
DebugLog(*pInt1); // Undefined behavior: int not initialized
 
// Calls default constructor for classes
Vector2* vectors1 = new Vector2[1];
DebugLog(vectors1[0].X, vectors1[0].Y); // 0, 0
 
// Does nothing for primitives
int* ints1 = new int[1];
DebugLog(ints1[0]); // Undefined behavior: ints not initialized

If we put parentheses, a single object is direct-initialized:

// Calls (float, float) constructor
Vector2* pVec2 = new Vector2(2, 4);
DebugLog(pVec2->X, pVec2->Y); // 2, 4
 
// Sets to 123
int* pInt2 = new int(123);
DebugLog(*pInt2); // 123

Parentheses with an array must be empty. This aggregate-initializes the array:

// Calls default constructor for classes
Vector2* vectors2 = new Vector2[1]();
DebugLog(vectors2[0].X, vectors2[0].Y); // 0, 0
 
// Sets to zero
int* ints2 = new int[1]();
DebugLog(ints2[0]); // 0

Curly braces list-initialize single objects:

// Calls (float, float) constructor
Vector2* pVec3 = new Vector2{2, 4};
DebugLog(pVec3->X, pVec3->Y); // 2, 4
 
// Sets to 123
int* pInt3 = new int{123};
DebugLog(*pInt3); // 123

They aggregate-initialize arrays and can be non-empty, such as to pass arguments to a constructor or set primitives to a value. This is generally the recommended form for both arrays and single objects:

// Calls (float, float) constructor for each element
Vector2* vectors3 = new Vector2[1]{2, 4};
DebugLog(vectors3[0].X, vectors3[0].Y); // 2, 4
 
// Sets each element to 123
int* ints3 = new int[1]{123};
DebugLog(ints3[0]); // 123

When memory allocation fails, such as when there’s not enough , new will throw a std::bad_alloc exception:

try
{
    // Attempt a 1 TB allocation
    // Throws an exception if the allocation fails
    char* big = new char[1024*1024*1024*1024];
 
    // Never executed if the allocation fails
    big[0] = 123;
}
catch (std::bad_alloc)
{
    // This gets printed if the allocation fails
    DebugLog("Failed to allocate big array");
}

Some codebases, especially in games, prefer to avoid exceptions. Compilers often provide an option to call std::abort to crash the program instead even though this is technically a violation of the C++ standard:

// Attempt a 1 TB allocation
// Calls abort() if the allocation fails
char* big = new char[1024*1024*1024*1024];
 
// Never executed if the allocation fails
big[0] = 123;
Deallocation

All of the above examples create memory leaks. That’s because C++ has no garbage collector to automatically release memory that’s no longer referenced. Instead, we must release the memory when we’re done with it. We do that with the delete operator:

Vector2* pVec = new Vector2{2, 4};
DebugLog(pVec->X, pVec->Y); // 2, 4
 
// 1) Call the Vector2 destructor
// 2) Release the allocated memory pointed to by pVec
delete pVec;
 
DebugLog(pVec->X, pVec->Y); // Undefined behavior: the memory has been released
 
delete pVec; // Undefined behavior: the memory has already been released

The delete operator takes one more step toward safety by combining two steps together: de-initialization of the memory’s contents followed by deallocating it. It doesn’t, however, prevent the two errors at the end of the example: “use after release” and “double-release.”

One way to address these issues is to set all pointers to the memory to null after releasing them:

delete pVec;
pVec = nullptr;
 
// Undefined behavior: derefrencing null
DebugLog(pVec->X, pVec->Y);
 
delete pVec; // OK

In the “use after release” case, our dereferencing of a null pointer is still undefined behavior. If the compiler can determine this, it can produce whatever machine code it wants. It may simply dereference null and crash, or it may do something strange like remove the DebugLog line completely.

Most of the time, such as when using the null pointer in some far-flung part of the codebase, the compiler can’t determine that it’s null and will assume a non-null pointer. In that case, dereferencing null will crash the program. So this is only a moderate improvement as we may only potentially get a crash instead of data corruption from reading or writing the released memory.

In the “double-release” case, it’s OK to delete null so this simply isn’t a problem anymore.

Because a Vector2* might be a pointer to a single Vector2 or an array of Vector2 objects, a second form of delete exists to call the destructors of all the elements in an array:

Vector2* pVectors1 = new Vector2[3]{Vector2{2, 4}};
// Correct:
// 1) Call the Vector2 destructor on all three vectors
// 2) Release the allocated memory pointed to by pVectors1
delete [] pVectors1;
 
Vector2* pVectors2 = new Vector2[3]{Vector2{2, 4}};
// Bug:
// 1) Call the Vector2 destructor on THE FIRST vector
// 2) Release the allocated memory pointed to by pVectors2
delete pVectors2;

Note that the correct destructor needs to be called, which can be problematic in the case of inheritance:

struct HasId
{
    int32_t Id;
 
    // Non-virtual destructor
    ~HasId()
    {
    }
};
 
struct Combatant
{
    // Non-virtual destructor
    ~Combatant()
    {
    }
};
 
struct Enemy : HasId, Combatant
{
    // Non-virtual destructor
    ~Enemy()
    {
    }
};
 
// Allocate an Enemy
Enemy* pEnemy = new Enemy();
 
// Polymorphism is allowed because Enemy "is a" Combatant due to inheritance
Combatant* pCombatant = pEnemy;
 
// Deallocate a Combatant
// 1) Call the Combatant, not Enemy, destructor
// 2) Release the allocated memory pointed to by pCombatant
delete pCombatant;

This is undefined behavior since the sub-object pointed to by pCombatant might not be the same as the pointer that was allocated. To fix this, use a virtual destructor:

struct HasId
{
    int32_t Id;
 
    virtual ~HasId()
    {
    }
};
 
struct Combatant
{
    virtual ~Combatant()
    {
    }
};
 
struct Enemy : HasId, Combatant
{
    virtual ~Enemy()
    {
    }
};
 
Enemy* pEnemy = new Enemy();
Combatant* pCombatant = pEnemy;
 
// Deallocate a Combatant
// 1) Call the Enemy destructor
// 2) Release the allocated memory pointed to by pEnemy
delete pCombatant;
Overloading New and Delete

So far we’ve been using the default new and delete operators. These are fine for most purposes, but sometimes we want to take more control over memory allocation and deallocation. For example, we might want to use an alternative allocator for improved performance as Unity’s Allocator.Temp does in C#. To do this, we can overload the new and delete operators.

There are several forms the overloaded operators can take, but they should always be overloaded in pairs. Here’s the simplest form:

// We need the std::size_t type
#include <cstddef>
 
struct Vector2
{
    float X;
    float Y;
 
    void* operator new(std::size_t count)
    {
        return malloc(sizeof(Vector2));
    }
 
    void operator delete(void* ptr)
    {
        free(ptr);
    }
};
 
// Calls overloaded new operator in Vector2
Vector2* pVec = new Vector2{2, 4};
 
DebugLog(pVec->X, pVec->Y); // 2, 4
 
// Calls overloaded delete operator in Vector2
delete pVec;

The array versions are overloaded separately:

struct Vector2
{
    float X;
    float Y;
 
    void* operator new[](std::size_t count)
    {
        return malloc(sizeof(Vector2)*count);
    }
 
    void operator delete[](void* ptr)
    {
        free(ptr);
    }
};
 
Vector2* pVecs = new Vector2[1];
delete [] pVecs;

Overloaded operators, including new, can take any arguments. We put them between the new keyword and the type to allocate:

struct Vector2
{
    float X;
    float Y;
 
    // Overload the new operator that takes (float, float) arguments
    void* operator new(std::size_t count, float x, float y)
    {
        // Note: for demonstration purposes only
        // Normal code would just use a constructor
        Vector2* pVec = (Vector2*)malloc(sizeof(Vector2)*count);
        pVec->X = x;
        pVec->Y = y;
        return pVec;
    }
 
    // Overload the normal delete operator
    void operator delete(void* memory, std::size_t count)
    {
        free(memory);
    }
 
    // Overload a delete operator corresponding with the new operator
    // that takes (float, float) arguments
    void operator delete(void* memory, std::size_t count, float x, float y)
    {
        // Forward the call to the normal delete operator
        Vector2::operator delete(memory, count);
    }
};
 
// Call the overloaded (float, float) new operator
Vector2* pVec = new (2, 4) Vector2;
 
DebugLog(pVec->X, pVec->Y); // 2, 4
 
// Call the normal delete operator
delete pVec;

One convention that’s arisen is to take a void* as the second argument to indicate “placement new.” In this case, no memory is allocated and the object simply uses the memory pointed to by that void*:

struct Vector2
{
    float X;
    float Y;
 
    // Overload the "placement new" operator
    // Mark "noexcept" because there's no way this can throw
    void* operator new(std::size_t count, void* place) noexcept
    {
        // Don't allocate. Just return the given memory address.
        return place;
    }
};
 
// Allocate our own memory to hold the Vector2
// We can use global variables, the stack, or anything else
char buf[sizeof(Vector2)];
 
// Call the "placement new" operator
// The Vector2 is put in buf
Vector2* pVec = new (buf) Vector2{2, 4};
DebugLog(pVec->X, pVec->Y); // 2, 4
 
// Note: no "delete" since we didn't actually allocate memory

Like other overloaded operators, we can also overload outside the class to handle more than that one type. For example, here’s a “placement new” for all types:

struct Vector2
{
    float X;
    float Y;
};
 
void* operator new(std::size_t count, void* place) noexcept
{
    return place;
}
 
char buf[sizeof(Vector2)];
Vector2* pVec = new (buf) Vector2{2, 4};
DebugLog(pVec->X, pVec->Y); // 2, 4
 
float* pFloat = new (buf) float{3.14f};
DebugLog(*pFloat); // 3.14
Owning Types

So far we’ve overcome a lot of possible mistakes that could have been made with low-level dynamic allocation functions like malloc and free. Even so, “naked” use of new and delete is often frowned upon in “Modern C++” (i.e. C++11 and newer) codebases. This is because we are still susceptible to common bugs:

  • Forgetting to call delete, resulting in a memory leak
  • Calling delete twice, which is undefined behavior and likely a crash
  • Using allocated memory after calling delete, which is undefined behavior and likely causes corruption

To alleviate these issues, new and delete operators are typically wrapped in a class referred to as an “owning type.” This gives us access to constructors and destructors to allocate and deallocate memory much more safely. The C++ Standard Library has several generic types for this purpose which we’ll cover later in the series. For now, let’s build a simple “owning type” that owns an array of float:

class FloatArray
{
    int32_t length;
    float* floats;
 
public:
 
    FloatArray(int32_t length)
        : length{length}
        , floats{new float[length]{0}}
    {
    }
 
    float& operator[](int32_t index)
    {
        if (index < 0 || index >= length)
        {
            throw IndexOutOfBounds{};
        }
        return floats[index];
    }
 
    virtual ~FloatArray()
    {
        delete [] floats;
        floats = nullptr;
    }
 
    struct IndexOutOfBounds {};
};
 
try
{
    FloatArray floats{3};
    floats[0] = 3.14f;
 
    // Index out of bounds
    // Throws exception
    // FloatArray destructor called
    DebugLog(floats[-1]); // 3.14
}
catch (FloatArray::IndexOutOfBounds)
{
    DebugLog("whoops"); // Gets printed
}

Here we see that we’ve encapsulated the new or delete operators into the FloatArray class. The bulk of the codebase is simply a user of this class and it doesn’t ever need to write a new or delete operator. Despite that, it’s solved all three of the above problems:

  • We can’t forget to call delete because the destructor does, even if an exception is thrown
  • We can’t call delete twice because the destructor does this for us
  • We can’t use the memory after calling delete because we wouldn’t have the variable to call member functions on

By using a class, we can also prevent other common errors:

  • The constructor always initializes the elements of the array to avoid undefined behavior when reading them before writing them
  • The overloaded array subscript ([]) operator performs bounds checks to avoid memory corruption

Still, this is a poor implementation of an “owning type” as it’s vulnerable to a variety of other problems. For example, the compiler generates a copy constructor which copies the floats pointer leading to a double-release:

void Foo()
{
    FloatArray f1{3};
    FloatArray f2{f1}; // Copies floats and length
 
    // 1) Call f1's destructor which deletes the allocated memory
    // 2) Call f2's destructor which deletes the allocated memory again: crash
}

Instead of creating custom owning types like FloatArray, it’s much more common to use a platform library class like std::vector in the C++ Standard Library or TArray in Unreal. The same goes for other owning types like std::unique_ptr and std::shared_ptr, the C++ Standard Library’s “smart pointers” to a single object.

Conclusion

C# provides very high-level memory management by requiring garbage collection. To avoid it, we’re forced into “unsafe” code and must give up many language features including classes, interfaces, and delegates. Such is the case with Unity’s Burst compiler, which impose the HPC# subset.

C++ provides a whole spectrum of options. We can take low-level control with malloc and free, create our own allocation functions, use raw new and delete, overload new and delete globally or on a per-type basis, pass extra arguments to new and delete, use “placement new” to allocate at a particular address, or even write “owning types” to avoid almost all of the manual allocation and deallocation code.

There’s a ton of power, and a fair bit of complexity, here, but at no point must we give up any language features in order to move to higher-level or lower-level memory management strategies. We’ll see some of those (very commonly-used) higher-level techniques later in the series when we cover the C++ Standard Library.