Search⌘ K
AI Features

Managing Dynamic Memory

Explore how to handle dynamic memory in C++ through manual allocation with new and delete, and gain practical knowledge of modern smart pointers such as unique_ptr and shared_ptr. Understand ownership models, lifetime management, and how to prevent common memory errors like leaks, dangling pointers, and double frees. This lesson equips you with safe practices for dynamic memory management in complex programs.

The variables we have used so far live on the stack. The stack is fast and managed automatically, but it has strict limitations: variables must have a known size at compile time, and they vanish the moment their function ends. Real-world applications, however, often need data to survive across function calls or grow dynamically based on user input.

To handle this, C++ provides the heap (also called the free store). The heap offers immense flexibility, but it comes with a catch: unlike the stack, the heap does not clean up after itself. If we take memory, we must give it back, or our program will eventually crash. In this lesson, we will learn how to wield this power responsibly using modern tools that automate the cleanup for us.

Manual allocation with new and delete

When we need memory that persists beyond the current scope, we use the new operator. This operator dynamically allocates memory from the free store (commonly backed by the heap) and returns a pointer to that memory.

Because this memory is not on the stack, it is not reclaimed when the function returns. We must explicitly release it using the delete operator.

  • new T: Allocates a single object of type T and returns T*.

  • delete ptr: Destroys the object and frees the memory.

  • new T[n]: Allocates an array of n objects of type T, and returns a pointer to the first element of the array.

  • delete[] ptr: Destroys the array and frees the memory. Note the brackets []; using the wrong deleterA deleter specifies how allocated memory or resources are destroyed and released, such as using delete for single objects or delete[] for arrays. causes undefined behavior.

C++ 23
#include <iostream>
int main() {
// 1. Allocate a single integer on the heap
int* numPtr = new int(42);
std::cout << "Value: " << *numPtr << "\n";
// 2. Allocate an array of 5 integers
int* arrPtr = new int[5]{1, 2, 3, 4, 5};
std::cout << "Array element 2: " << arrPtr[2] << "\n";
// 3. Clean up manual memory
delete numPtr; // Deletes single object
delete[] arrPtr; // Deletes array
// Pointers now hold invalid addresses (dangling pointers).
// It is good practice to set them to nullptr immediately if they persist.
numPtr = nullptr;
arrPtr = nullptr;
return 0;
}

Let’s understand this step by step:

  • Line 5: new int(42) allocates memory for one integer on the heap and initializes it to 42. numPtr points to that integer.

  • Line 10: new int[5] allocates memory for an array of 5 integers on the heap. arrPtr points to the first element of the array.

  • Line 15: It frees memory allocated by new int(...) at line 5.

  • Line 16: It frees memory allocated by new int[...] at line 10.

The risks of manual management

Manual memory management is powerful but notoriously dangerous. In complex programs, it is easy to lose track of who owns a pointer and who should delete it.

Three common errors plague manual management:

  1. Memory leaks: If we lose the pointer (e.g., the pointer variable goes out of scope) before calling delete, the memory remains occupied forever.

  2. Dangling pointers: If we delete memory but keep using the pointer, we are accessing invalid memory. This often crashes the program or corrupts data.

  3. Double free: If we try to delete the same memory address twice, the program will crash or behave unpredictably.

C++ 23
#include <iostream>
void createLeak() {
int* leakingPtr = new int(100);
// Function ends here. 'leakingPtr' (the stack variable) is destroyed.
// However, the heap memory at 'new int(100)' is NEVER deleted.
// This is a memory leak.
}
int main() {
createLeak(); // Every call leaks 4 bytes (assuming 32-bit int)
int* ptr = new int(50);
delete ptr;
// DANGER: ptr is now a "dangling pointer"
// *ptr = 10; // Undefined Behavior: Writing to freed memory!
return 0;
}

Let’s understand this step by step:

  • Line 4: We allocate memory on the heap.

  • Lines 5–7: The function returns without calling delete. The address is lost, and the memory cannot be recovered. This is an example of memory leak.

  • Line 14: We free the memory pointed to by ptr.

  • Line 17: Uncommenting this line would write to memory we no longer own, likely causing a crash.

Smart pointers in C++: Exclusive ownership with std::unique_ptr

Memory management is a core pillar of high-performance programming. To avoid memory leaks and dangling pointers, modern development relies heavily on smart pointers in C++. These are class templates that wrap a raw pointer to manage its lifecycle automatically using RAII (Resource Acquisition Is Initialization).

The most common type of smart pointer in C++ is std::unique_ptr, found in the <memory> header. A std::unique_ptr owns a resource exclusively. There can only be one unique pointer pointing to a specific resource at a time. When the unique_ptr goes out of scope, it automatically deletes the resource.

How to use std::unique_ptr

Because ownership is exclusive, you cannot copy a unique pointer. You can only move it, which transfers ownership from one variable to another. We typically use std::make_unique<T>() to create one, this is considered a best practice for smart pointers in C++ because it is safer and cleaner than using the new keyword.

C++ 23
#include <iostream>
#include <memory> // Required for smart pointers in C++
int main() {
// 1. Using std::make_unique to initialize smart pointers in C++
std::unique_ptr<int> uPtr = std::make_unique<int>(100);
// 2. Use it just like a raw pointer
std::cout << "Value: " << *uPtr << "\n";
// 3. Try to copy (This will cause a compiler error!)
// std::unique_ptr<int> uPtr2 = uPtr;
// 4. Move ownership to a new pointer
std::unique_ptr<int> uPtrMoved = std::move(uPtr);
if (!uPtr) {
std::cout << "uPtr is now empty (nullptr).\n";
}
std::cout << "uPtrMoved owns the value: " << *uPtrMoved << "\n";
return 0;
}

Let’s understand this step by step:

  • Line 6: std::make_unique is the recommended way to create smart pointers in C++ because it is exception-safe and removes the need for the new keyword.

  • Line 9: We dereference the pointer using *. One of the best features of smart pointers in C++ is that they behave just like raw pointers but with automatic memory management.

  • Line 12: Copying is disabled for std::unique_ptr. This ensures that only one "owner" exists for the memory at any time.

  • Line 15: We use std::move to transfer ownership. After this, the original pointer uPtr becomes null.

  • Line 22: No delete keyword is needed; the memory is cleaned up automatically when the pointer goes out of scope.

Shared ownership with std::shared_ptr

Sometimes, multiple parts of a program need access to the same resource, and we don't know which part will finish last. For this, we use std::shared_ptr. std::shared_ptr uses reference counting as follows:

  • When you copy a shared pointer, the internal counter increments.

  • When a shared pointer is destroyed, the counter decrements.

  • The memory is deleted only when the counter reaches zero (i.e., the last owner is gone).

Look at the code below.

C++ 23
#include <iostream>
#include <memory>
int main() {
// 1. Create a shared_ptr
std::shared_ptr<int> sPtr1 = std::make_shared<int>(500);
std::cout << "Ref Count: " << sPtr1.use_count() << "\n"; // Output: 1
{
// 2. Share ownership with a second pointer
std::shared_ptr<int> sPtr2 = sPtr1; // Copy is allowed!
std::cout << "Value: " << *sPtr2 << "\n";
std::cout << "Ref Count: " << sPtr1.use_count() << "\n"; // Output: 2
} // sPtr2 goes out of scope here, count decrements.
// 3. Back to single ownership
std::cout << "Ref Count after block: " << sPtr1.use_count() << "\n"; // Output: 1
return 0;
} // sPtr1 dies, count hits 0, memory is deleted.

Let’s break this down step by step:

  • Line 6: std::make_shared allocates the integer and the control block for reference counting.

  • Line 8: .use_count() shows how many pointers share this resource. Initially, it's 1.

  • Line 12: We copy sPtr1 to sPtr2. Both now points to the same data. So, the count becomes 2.

  • Line 16: The inner scope ends. sPtr2 is destroyed. The count drops back to 1, but the memory is not deleted yet.

  • Line 19: .use_count() shows that the count dropped back to 1.

  • Line 22: sPtr1 is destroyed. Count drops back to 0. The memory is finally freed.

Breaking cycles with std::weak_ptr

When two objects store std::shared_ptr instances pointing to each other, they form a circular reference. Because each object keeps the other’s reference count above zero, neither object is ever destroyed; even when the rest of the program no longer needs them. This prevents automatic cleanup and results in a memory leak.

C++ solves this problem with std::weak_ptr. A weak_ptr can observe an object managed by a shared_ptr, but it does not represent ownership. In other words, it does not increase the reference count, so it cannot keep an object alive by itself.

Since a weak_ptr does not own the object, the object may be destroyed while the weak_ptr still exists. For that reason, we must first convert it into a shared_ptr using .lock(). If the object is still alive, .lock() returns a valid shared_ptr. If it has already been destroyed, .lock() returns an empty shared_ptr, allowing us to safely detect that the memory is no longer available before using it.

C++ 23
#include <iostream>
#include <memory>
int main() {
std::shared_ptr<int> owner = std::make_shared<int>(99);
// 1. Create a weak_ptr observing the owner
std::weak_ptr<int> observer = owner;
// 2. Check if resource exists and access it
if (std::shared_ptr<int> lockedPtr = observer.lock()) {
std::cout << "Resource is alive: " << *lockedPtr << "\n";
} else {
std::cout << "Resource is gone.\n";
}
// 3. Reset the owner (simulate destruction)
owner.reset();
// 4. Try to access again
if (auto lockedPtr = observer.lock()) {
std::cout << "Resource is alive.\n";
} else {
std::cout << "Resource is gone.\n";
}
return 0;
}

Let’s understand this step by step:

  • Line 8: observer watches owner but does not increase the reference count.

  • Line 11: .lock() creates a temporary shared_ptr. If the object is alive, it returns a valid pointer; otherwise, it returns nullptr (or empty).

  • Line 18: owner.reset() destroys the shared pointer manually. The integer 99 is deleted because the count hits zero.

  • Line 21: .lock() now fails because the managed object no longer exists.

We have now moved from the risky world of manual allocation to the safety of modern C++. While new and delete are essential for understanding how memory works, modern C++ code relies almost exclusively on std::unique_ptr and std::shared_ptr. These tools ensure that no matter how complex your program logic becomes, your memory is always cleaned up correctly.