C++ For C# Developers: Part 19 – Dynamic Allocation
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
- Part 1: Introduction
- Part 2: Primitive Types and Literals
- Part 3: Variables and Initialization
- Part 4: Functions
- Part 5: Build Model
- Part 6: Control Flow
- Part 7: Pointers, Arrays, and Strings
- Part 8: References
- Part 9: Enumerations
- Part 10: Struct Basics
- Part 11: Struct Functions
- Part 12: Constructors and Destructors
- Part 13: Initialization
- Part 14: Inheritance
- Part 15: Struct and Class Permissions
- Part 16: Struct and Class Wrapup
- Part 17: Namespaces
- Part 18: Exceptions
- Part 19: Dynamic Allocation
- Part 20: Implicit Type Conversion
- Part 21: Casting and RTTI
- Part 22: Lambdas
- Part 23: Compile-Time Programming
- Part 24: Preprocessor
- Part 25: Intro to Templates
- Part 26: Template Parameters
- Part 27: Template Deduction and Specialization
- Part 28: Variadic Templates
- Part 29: Template Constraints
- Part 30: Type Aliases
- Part 31: Deconstructing and Attributes
- Part 32: Thread-Local Storage and Volatile
- Part 33: Alignment, Assembly, and Language Linkage
- Part 34: Fold Expressions and Elaborated Type Specifiers
- Part 35: Modules, The New Build Model
- Part 36: Coroutines
- Part 37: Missing Language Features
- Part 38: C Standard Library
- Part 39: Language Support Library
- Part 40: Utilities Library
- Part 41: System Integration Library
- Part 42: Numbers Library
- Part 43: Threading Library
- Part 44: Strings Library
- Part 45: Array Containers Library
- Part 46: Other Containers Library
- Part 47: Containers Library Wrapup
- Part 48: Algorithms Library
- Part 49: Ranges and Parallel Algorithms
- Part 50: I/O Library
- Part 51: Missing Library Features
- Part 52: Idioms and Best Practices
- Part 53: Conclusion
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); // 1
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: dereferencing 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.
#1 by Andrej Biasic on April 24th, 2022 ·
Minor typo: “derefrencing” should be “dereferencing.”
#2 by jackson on April 25th, 2022 ·
I’ve updated the article with a fix. Thanks!
#3 by Sascha on December 25th, 2022 ·
Hello Jackson,
Great to see the historic roots of C++’s memory management explained here. Exactly this C heritage also creates a rough edge in C++ inheritance:
In your example:
Here
is
virtual
, because it is a derived struct. You don’t pay virtual’s cost by default. If you use inheritance, which is obvious for the derived struct, it is the correct implementation and thus the default.
(see Compiler Explorer example.)
Another note: multiple inheritance is another tricky thing in C++. The safer design would be to only use “interfaces” without any member fields and no non-default constructors. This was the design for C# in the beginning. It can become a real headache with dreaded diamond dependencies, virtual inheritance, doubly allocated data, …
#4 by Kevin on December 31st, 2022 ·
Hey Jackson, thanks for making this series. I find myself re-reading these posts often.
I wanted to point out that under dynamically allocating an enum, the line
should print out “1”, and does not correspond to red as I believe is being indicated.
#5 by jackson on January 10th, 2023 ·
Thanks for pointing out the mistake. I’ve updated the post with a correction. I’m glad you’re enjoying the series!