Memory Management in Modern C++

Memory management is a critical aspect of C++ programming, especially in systems programming and performance-critical applications. In this article, we'll explore modern C++ memory management techniques, focusing on smart pointers, RAII, and best practices.

The Evolution of Memory Management in C++

Traditional C-Style Memory Management

void processData() {
    int* data = (int*)malloc(100 * sizeof(int));
    if (!data) {
        // Handle allocation failure
        return;
    }
    
    // Use the allocated memory
    for (int i = 0; i < 100; ++i) {
        data[i] = i * i;
    }
    
    // Don't forget to free the memory!
    free(data);
}

Modern C++ Approach with Smart Pointers

#include <memory>
#include <vector>

void processData() {
    auto data = std::make_unique<int[]>(100);
    
    // Memory is automatically managed
    for (int i = 0; i < 100; ++i) {
        data[i] = i * i;
    }
    
    // No need to manually free memory
    // The unique_ptr will handle it automatically
}

Smart Pointers in C++

1. std::unique_ptr

A unique pointer is a smart pointer that owns and manages another object through a pointer and disposes of that object when the unique_ptr goes out of scope.

#include <memory>
#include <iostream>

class Resource {
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource released\n"; }
    void doSomething() { std::cout << "Using resource\n"; }
};

int main() {
    // Create a unique_ptr to a Resource
    std::unique_ptr<Resource> res = std::make_unique<Resource>();
    
    // Use the resource
    res->doSomething();
    
    // No need to delete, memory is automatically released
    return 0;
}

2. std::shared_ptr

Shared pointers allow multiple pointers to refer to the same object, and the object is only destroyed when the last shared_ptr is destroyed.

#include <memory>
#include <vector>

class Node {
public:
    std::string name;
    std::vector<std::shared_ptr<Node>> children;
    
    Node(const std::string& n) : name(n) {}
    
    void addChild(std::shared_ptr<Node> child) {
        children.push_back(child);
    }
};

int main() {
    auto root = std::make_shared<Node>("root");
    auto child1 = std::make_shared<Node>("child1");
    auto child2 = std::make_shared<Node>("child2");
    
    root->addChild(child1);
    root->addChild(child2);
    
    // Circular reference can cause memory leaks
    // To fix this, use std::weak_ptr for parent references
    
    return 0;
}

3. std::weak_ptr

Weak pointers are used to break circular references between shared_ptr instances.

class Node {
public:
    std::string name;
    std::vector<std::shared_ptr<Node>> children;
    std::weak_ptr<Node> parent;  // Weak reference to parent
    
    Node(const std::string& n) : name(n) {}
    
    void setParent(std::shared_ptr<Node> parentNode) {
        parent = parentNode;
        parentNode->children.push_back(shared_from_this());
    }
};

RAII (Resource Acquisition Is Initialization)

RAII is a programming idiom where resource allocation is tied to object lifetime.

#include <fstream>
#include <string>
#include <stdexcept>

class FileHandler {
    std::fstream file;
    
public:
    FileHandler(const std::string& filename, std::ios_base::openmode mode) 
        : file(filename, mode) {
        if (!file.is_open()) {
            throw std::runtime_error("Failed to open file");
        }
    }
    
    ~FileHandler() {
        if (file.is_open()) {
            file.close();
        }
    }
    
    // Delete copy constructor and assignment operator
    FileHandler(const FileHandler&) = delete;
    FileHandler& operator=(const FileHandler&) = delete;
    
    // Move constructor and assignment
    FileHandler(FileHandler&& other) noexcept : file(std::move(other.file)) {}
    FileHandler& operator=(FileHandler&& other) noexcept {
        if (this != &other) {
            if (file.is_open()) file.close();
            file = std::move(other.file);
        }
        return *this;
    }
    
    // Example method
    std::string readLine() {
        std::string line;
        std::getline(file, line);
        return line;
    }
};

Memory Management Best Practices

  1. Prefer Stack Allocation

    // Good
    void process() {
        std::vector<int> data(1000);  // On stack if possible, otherwise heap
        // ...
    }
    
    // Avoid
    void process() {
        int* data = new int[1000];  // Manual memory management
        // ...
        delete[] data;  // Easy to forget
    }
    
  2. Use Smart Pointers by Default

    • std::unique_ptr for exclusive ownership
    • std::shared_ptr for shared ownership
    • std::weak_ptr to break circular references
  3. Avoid Raw new and delete

    // Bad
    int* createArray(int size) {
        return new int[size];  // Caller must remember to delete[]
    }
    
    // Good
    std::vector<int> createVector(int size) {
        return std::vector<int>(size);  // Automatic memory management
    }
    
  4. Be Careful with Containers of Pointers

    // Bad - potential memory leak
    std::vector<Widget*> widgets;
    widgets.push_back(new Widget());
    
    // Good - use smart pointers
    std::vector<std::unique_ptr<Widget>> widgets;
    widgets.push_back(std::make_unique<Widget>());
    
  5. **Use std::make_unique and std::make_shared

    // Good - exception-safe and more efficient
    auto ptr = std::make_shared<Widget>(arg1, arg2);
    
    // Avoid
    std::shared_ptr<Widget> ptr(new Widget(arg1, arg2));
    

Common Memory Issues and Solutions

1. Memory Leaks

Problem: Allocated memory is never freed.

Solution: Use RAII and smart pointers.

2. Dangling Pointers

Problem: Pointer points to freed memory.

Solution: Use smart pointers and avoid raw pointers for ownership.

3. Double Deletion

Problem: Memory is deleted more than once.

Solution: Use std::unique_ptr or std::shared_ptr.

4. Memory Fragmentation

Problem: Memory becomes fragmented over time.

Solution:

  • Use custom allocators
  • Pre-allocate memory pools
  • Use std::vector instead of linked lists for better cache locality

Advanced Techniques

Custom Deleters

// For C-style APIs that require custom cleanup
void closeFile(FILE* fp) {
    if (fp) fclose(fp);
}

void readFile(const std::string& filename) {
    std::unique_ptr<FILE, decltype(&closeFile)> file(
        fopen(filename.c_str(), "r"),
        &closeFile
    );
    
    if (!file) {
        throw std::runtime_error("Failed to open file");
    }
    
    // Use file...
}

Memory Pools

#include <memory>
#include <vector>

template<typename T, size_t ChunkSize = 1024>
class MemoryPool {
    struct Chunk {
        std::unique_ptr<T[]> data;
        size_t nextFree = 0;
        
        Chunk() : data(std::make_unique<T[]>(ChunkSize)) {}
    };
    
    std::vector<std::unique_ptr<Chunk>> chunks;
    
public:
    T* allocate() {
        // Find or create a chunk with free space
        for (auto& chunk : chunks) {
            if (chunk->nextFree < ChunkSize) {
                return &chunk->data[chunk->nextFree++];
            }
        }
        
        // No space in existing chunks, allocate a new one
        auto newChunk = std::make_unique<Chunk>();
        T* ptr = &newChunk->data[0];
        newChunk->nextFree = 1;
        chunks.push_back(std::move(newChunk));
        return ptr;
    }
    
    // Note: This is a simple pool that doesn't support deallocation
    // For a complete implementation, you'd need to track free slots
};

Conclusion

Modern C++ provides powerful tools for memory management that eliminate many common pitfalls. By following these best practices and leveraging the standard library's smart pointers and containers, you can write safer, more maintainable, and more efficient C++ code.

Further Reading