Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 15

Dynamic Object Arrays

Recommended resources

Hello! Welcome to our final lesson in the "Modern C++ and Manual Memory Management" module.

In our last session, you built a fully functional LinearAllocator. We saw how to allocate a raw memory block and use placement new to construct a single object within it. We also established the critical rule: if you construct an object manually with placement new, you must also destroy it manually by calling its destructor.

Today, we'll scale that concept up. What happens when you don't need just one object, but a whole array of them? This is a fundamental challenge in creating data structures like vectors, and it's essential for the ring buffer we'll build for our telemetry engine. The learning outcome for this lesson is to manage memory for arrays of objects with proper construction and destruction.

Why Not Just new T[N]?

Your first instinct for creating an array on the heap might be new MyObject[100]. This seems simple, but it has a crucial side effect that's often undesirable in high-performance systems.

Let's watch a segment from a familiar video to understand what new does for arrays.

The NEW Keyword in C++

The Cherno explains how new is used to create arrays. Pay close attention to the distinction he makes between allocating a single object and an array of objects.

Watch from 01:26 to 04:20. Notice that when you write new Entity[50], you are creating 50 Entity objects immediately.

As the video explains, new T[N] does two things:

  1. Allocates enough memory for N objects.
  2. Calls the default constructor for all N objects immediately.

This is problematic for our use case. Imagine our telemetry profiler's ring buffer has a capacity for 1 million events. If we used new Event[1000000], we would be forced to construct one million Event objects upfront, even if the buffer is empty. This is inefficient and inflexible.

We need to separate capacity from size. We want to allocate memory for 1 million events (capacity) but construct them one by one as they are generated, tracking the actual number of live objects (size).

This means we cannot use the standard new[]/delete[] mechanism. As we learned previously, new[] must be paired with delete[]. Trying to use delete[] on a memory block from our LinearAllocator would be undefined behavior. We need a manual approach.


The Manual Lifecycle of an Object Array

Managing an array of objects manually involves four distinct steps, which cleanly separate allocation, construction, destruction, and deallocation.

1. Allocate Raw Memory

First, we need a contiguous block of uninitialized memory. This block should be large enough to hold the maximum number of objects we intend to store (the capacity).

You already know one great way to do this: our LinearAllocator! But for generality, you can also use malloc or the global ::operator new.

// Using malloc (C-style, but common and effective)
size_t capacity = 100;
T* buffer = static_cast<T*>(malloc(capacity * sizeof(T)));

// Using global operator new (C++ style)
// T* buffer = static_cast<T*>(::operator new(capacity * sizeof(T)));

// Using our LinearAllocator from the last lesson
// LinearAllocator my_allocator(capacity * sizeof(T));
// T* buffer = static_cast<T*>(my_allocator.allocate(capacity * sizeof(T), alignof(T)));

At this point, buffer points to raw, uninitialized bytes. No objects of type T exist yet.

Memory Representation of Array
This image shows how an array is laid out in memory: a single, contiguous block. Our `buffer` pointer points to the start of such a block, ready for us to place objects into it.

2. Construct Objects with Placement New

Now, we can construct objects in our buffer as needed. We use placement new inside a loop to call the constructor for each new object at the correct memory address.

// Assume 'buffer' is our allocated raw memory.
// Let's construct 3 objects in the buffer.
size_t size = 3;
for (size_t i = 0; i < size; ++i) {
    // Construct a T object at the address buffer + i
    new (buffer + i) T(); // Using default constructor
}

After this loop, we have three fully-formed T objects living in the first part of our buffer.

3. Destroy Objects with Explicit Destructor Calls

This is the most critical and often forgotten step. Before we free the raw memory, we must call the destructor for every object we constructed. If we fail to do this, any resources held by those objects (like memory, file handles, or network sockets) will be leaked.

The syntax for calling a destructor is pointer->~T(). We loop through our live objects and destroy them.

// Destroy the 3 objects we created.
for (size_t i = 0; i < size; ++i) {
    (buffer + i)->~T();
}

Notice we iterate up to size (the number of constructed objects), not capacity. After this loop, the objects are gone, but the memory they occupied is still allocated.

4. Deallocate Raw Memory

Finally, once all objects are properly destroyed, we can release the raw memory block back to the system.

// If we used malloc:
free(buffer);

// If we used global operator new:
// ::operator delete(buffer);

// If we used our LinearAllocator:
// my_allocator.reset(); // Or let the allocator's destructor handle it.

The following Stack Overflow answer brilliantly summarizes this entire process by fixing a user's incorrect myVector implementation.

c++ placement new in a home made vector container

Read this accepted answer on Stack Overflow. It diagnoses the classic error of mixing new T[] with placement new and provides the correct code for a simple vector's constructor, push_back, and destructor.

Read the sections titled 'Why Placement New is Used...', the corrections for the constructor and push_back, and the corrections for erase and the destructor. This will walk you through the entire manual lifecycle.

Test your understanding!

You have a raw memory buffer with a capacity of 10. You have constructed 5 objects in it, so size is 5. When you deallocate this array, how many times will you call a destructor, and how many times will you call free() (or ::operator delete)?

Show answer

You will call the destructor 5 times (once for each constructed object). You will call free() only once (to deallocate the entire raw memory block).


Advanced Topic 1: Exception Safety

The manual approach is powerful but introduces a new failure mode. What if the constructor of the 50th object in an array of 100 throws an exception? If we don't handle this, the 49 objects that were successfully created become "zombies"—they exist, but our code loses track of them, and their destructors are never called. This is a resource leak.

Proper array management requires us to handle this. If a constructor throws, we must catch the exception, destroy all objects created so far in the current operation, and then re-throw the exception to signal the failure.

Custom C++ Vector for Game Dev: A Deep Dive

This article on creating a custom vector demonstrates a robust, exception-safe way to construct an array of objects. This is a key technique for writing professional-grade systems code.

Read the section 'Exception-Safe Construction in a Custom Vector'. Focus on the try-catch block within the _construct_fill_elements example. Understand how it uses a counter to clean up only the objects constructed in that call before re-throwing.

The core pattern is:

  1. Initialize a successfully_constructed_count to 0.
  2. Start a try block.
  3. Inside a loop, call placement new and increment the counter.
  4. If an exception occurs, the catch block is triggered.
  5. Inside catch, loop from 0 to successfully_constructed_count, destroying each object.
  6. Finally, throw; to re-throw the original exception.

Advanced Topic 2: Optimization for Trivial Destructors

Calling a destructor is necessary for complex objects that manage resources. But what about simple types like int, float, or a struct with just plain data members? Their destructors do nothing. Calling a function that does nothing thousands of times in a loop is pure overhead.

We can use a C++ feature called type traits to check at compile time whether a type has a "trivial" destructor. If it does, we can skip the destruction loop entirely.

// In a Clear() or destructor function of a container
if constexpr (!std::is_trivially_destructible_v<T>) {
    for (size_t i = 0; i < size; ++i) {
        (buffer + i)->~T();
    }
}
size = 0;

The if constexpr statement is evaluated at compile time.

  • If T is std::string (which has a non-trivial destructor), the for loop is compiled into the program.
  • If T is int (which has a trivial destructor), the entire if block, including the for loop, is completely removed from the final executable. There is zero runtime cost.

This is a powerful optimization that you will see in high-performance libraries. The same article we just read has a great example of this.

Custom C++ Vector for Game Dev: A Deep Dive

Let's look at the same article again to see this optimization in practice.

Read the section 'Type-Aware Destruction with std::is_trivially_destructible_v'. Note how if constexpr is used in the PopBack function to conditionally call the destructor.


Conclusion

You've now mastered one of the most important low-level C++ memory management techniques. This knowledge is not just academic; it's the foundation upon which std::vector, std::string, and our future lock-free ring buffer are built.

Key Takeaways:

  • To create dynamic arrays efficiently, separate memory allocation from object construction.
  • The lifecycle is:
    1. Allocate: Get a raw memory block (malloc, ::operator new, or a custom allocator).
    2. Construct: Use placement new in a loop to create objects as needed.
    3. Destroy: Explicitly call destructors (->~T()) in a loop for all constructed objects.
    4. Deallocate: Free the single raw memory block.
  • For robust code, use try-catch blocks during construction to prevent resource leaks if a constructor throws an exception.
  • For high-performance code, use if constexpr with std::is_trivially_destructible_v to avoid calling empty destructors for simple types.

This lesson concludes our deep dive into modern C++ memory management. You now have all the tools needed to build the data pipeline for our telemetry engine.

In our next lesson, we will begin Module 3: Concurrency Fundamentals with Locks. We'll shift gears from memory to threads, starting with the basics of how to create and manage threads using std::thread. This is the first step on our path to building a multithreaded application where one thread can observe another without blocking it.