Container Classes

Think about everyday storage solutions: file cabinets organize documents, bookshelves arrange books by category, and playlists group your favorite songs. These physical containers make managing collections intuitive and accessible. Programming needs similar organizational tools.

A container class manages a collection of objects of a specific type. Rather than juggling multiple individual variables, container classes bundle related data together and provide standardized operations for working with that data. While C++ offers built-in arrays, programmers typically prefer container classes like std::vector or std::array because they provide automatic memory management, size tracking, and bounds checking.

Well-designed container classes typically provide:

  • Constructors to create empty or pre-sized containers
  • Methods to add elements to the container
  • Methods to remove elements from the container
  • Functions to report the current size
  • Operations to clear all elements
  • Access mechanisms for stored elements
  • Optional sorting capabilities

Some container classes deliberately omit certain operations. For instance, array-based containers might skip insertion and removal methods because these operations require shifting many elements, making them inefficient for large datasets.

Container classes establish a contains relationship. Elements stored in a container belong to that container, though we're using "belong to" in the logical sense, not referring to C++ class membership.

Types of containers

Containers fall into two categories. Value containers use composition to store copies of objects, taking full responsibility for creating and destroying those copies. Reference containers use aggregation to store pointers or references to objects managed elsewhere, avoiding ownership responsibilities.

Unlike physical containers that accept any item, C++ containers enforce type safety by holding only one data type. An integer container holds only integers. While some languages allow mixed-type containers, C++ generally requires separate containers for different types, or the use of templates to create generic containers. Despite these constraints, containers dramatically simplify program structure, improving both safety and development speed.

Building a temperature log container

We'll create a container that stores daily temperature readings. This TemperatureLog class will be a value container, making copies of the temperature values it stores. Think of it as similar to std::vector<double>.

Let's start with the header structure:

#pragma once

class TemperatureLog
{
};

Our container needs to track two things: the number of temperature readings stored, and the actual data. Since we want dynamic resizing capability, we'll use a pointer for data storage:

#pragma once

class TemperatureLog
{
private:
    int m_count{};
    double* m_temperatures{};
};

Next, we'll add constructors. We need a default constructor for creating empty logs, and another constructor that pre-allocates space for a specified number of readings:

#pragma once

#include <cassert>
#include <cstddef>

class TemperatureLog
{
private:
    int m_count{};
    double* m_temperatures{};

public:
    TemperatureLog() = default;

    TemperatureLog(int capacity)
        : m_count{ capacity }
    {
        assert(capacity >= 0);

        if (capacity > 0)
            m_temperatures = new double[static_cast<std::size_t>(capacity)]{};
    }
};

Resource management requires cleanup functions. A destructor releases dynamically allocated memory, while a clear() function erases the log and resets the count:

    ~TemperatureLog()
    {
        delete[] m_temperatures;
    }

    void clear()
    {
        delete[] m_temperatures;
        m_temperatures = nullptr;
        m_count = 0;
    }

To access individual temperature readings, we'll overload the subscript operator and add a size query function:

#pragma once

#include <cassert>
#include <cstddef>

class TemperatureLog
{
private:
    int m_count{};
    double* m_temperatures{};

public:
    TemperatureLog() = default;

    TemperatureLog(int capacity)
        : m_count{ capacity }
    {
        assert(capacity >= 0);

        if (capacity > 0)
            m_temperatures = new double[static_cast<std::size_t>(capacity)]{};
    }

    ~TemperatureLog()
    {
        delete[] m_temperatures;
    }

    void clear()
    {
        delete[] m_temperatures;
        m_temperatures = nullptr;
        m_count = 0;
    }

    double& operator[](int index)
    {
        assert(index >= 0 && index < m_count);
        return m_temperatures[index];
    }

    int getCount() const { return m_count; }
};

At this point, we have a functional container. We can create temperature logs of any size and use the subscript operator to read or modify values.

However, our class still lacks several capabilities: resizing, adding/removing readings, and proper copying. The current implementation would cause problems if we copied a TemperatureLog, since that would perform a shallow copy of the data pointer.

Let's implement resizing with two approaches. First, reallocate() provides fast resizing but discards existing data. Second, resize() preserves existing data but runs slower:

#include <algorithm>

    void reallocate(int newCapacity)
    {
        clear();

        if (newCapacity <= 0)
            return;

        m_temperatures = new double[static_cast<std::size_t>(newCapacity)]{};
        m_count = newCapacity;
    }

    void resize(int newCapacity)
    {
        if (newCapacity == m_count)
            return;

        if (newCapacity <= 0)
        {
            clear();
            return;
        }

        double* newData{ new double[static_cast<std::size_t>(newCapacity)]{} };

        if (m_count > 0)
        {
            int itemsToCopy{ (newCapacity > m_count) ? m_count : newCapacity };
            std::copy_n(m_temperatures, itemsToCopy, newData);
        }

        delete[] m_temperatures;
        m_temperatures = newData;
        m_count = newCapacity;
    }

The resize operation is intricate. We allocate a new array, copy the appropriate number of elements from the old array, delete the old array, and update our pointers. The number of elements copied is the minimum of the old and new sizes.

Now let's add copy operations to enable safe copying:

    TemperatureLog(const TemperatureLog& source)
        : TemperatureLog(source.getCount())
    {
        std::copy_n(source.m_temperatures, m_count, m_temperatures);
    }

    TemperatureLog& operator=(const TemperatureLog& source)
    {
        if (&source == this)
            return *this;

        reallocate(source.getCount());
        std::copy_n(source.m_temperatures, m_count, m_temperatures);

        return *this;
    }

Many containers would stop here, but let's add insertion and deletion for completeness. These operations are computationally expensive because they require shifting elements:

    void insertAt(double temperature, int index)
    {
        assert(index >= 0 && index <= m_count);

        double* newData{ new double[static_cast<std::size_t>(m_count + 1)] };

        std::copy_n(m_temperatures, index, newData);
        newData[index] = temperature;
        std::copy_n(m_temperatures + index, m_count - index, newData + index + 1);

        delete[] m_temperatures;
        m_temperatures = newData;
        ++m_count;
    }

    void removeAt(int index)
    {
        assert(index >= 0 && index < m_count);

        if (m_count == 1)
        {
            clear();
            return;
        }

        double* newData{ new double[static_cast<std::size_t>(m_count - 1)] };

        std::copy_n(m_temperatures, index, newData);
        std::copy_n(m_temperatures + index + 1, m_count - index - 1, newData + index);

        delete[] m_temperatures;
        m_temperatures = newData;
        --m_count;
    }

Here's the complete TemperatureLog container:

TemperatureLog.h:

#pragma once

#include <algorithm>
#include <cassert>
#include <cstddef>

class TemperatureLog
{
private:
    int m_count{};
    double* m_temperatures{};

public:
    TemperatureLog() = default;

    TemperatureLog(int capacity)
        : m_count{ capacity }
    {
        assert(capacity >= 0);

        if (capacity > 0)
            m_temperatures = new double[static_cast<std::size_t>(capacity)]{};
    }

    ~TemperatureLog()
    {
        delete[] m_temperatures;
    }

    TemperatureLog(const TemperatureLog& source)
        : TemperatureLog(source.getCount())
    {
        std::copy_n(source.m_temperatures, m_count, m_temperatures);
    }

    TemperatureLog& operator=(const TemperatureLog& source)
    {
        if (&source == this)
            return *this;

        reallocate(source.getCount());
        std::copy_n(source.m_temperatures, m_count, m_temperatures);

        return *this;
    }

    void clear()
    {
        delete[] m_temperatures;
        m_temperatures = nullptr;
        m_count = 0;
    }

    double& operator[](int index)
    {
        assert(index >= 0 && index < m_count);
        return m_temperatures[index];
    }

    void reallocate(int newCapacity)
    {
        clear();

        if (newCapacity <= 0)
            return;

        m_temperatures = new double[static_cast<std::size_t>(newCapacity)]{};
        m_count = newCapacity;
    }

    void resize(int newCapacity)
    {
        if (newCapacity == m_count)
            return;

        if (newCapacity <= 0)
        {
            clear();
            return;
        }

        double* newData{ new double[static_cast<std::size_t>(newCapacity)]{} };

        if (m_count > 0)
        {
            int itemsToCopy{ (newCapacity > m_count) ? m_count : newCapacity };
            std::copy_n(m_temperatures, itemsToCopy, newData);
        }

        delete[] m_temperatures;
        m_temperatures = newData;
        m_count = newCapacity;
    }

    void insertAt(double temperature, int index)
    {
        assert(index >= 0 && index <= m_count);

        double* newData{ new double[static_cast<std::size_t>(m_count + 1)] };

        std::copy_n(m_temperatures, index, newData);
        newData[index] = temperature;
        std::copy_n(m_temperatures + index, m_count - index, newData + index + 1);

        delete[] m_temperatures;
        m_temperatures = newData;
        ++m_count;
    }

    void removeAt(int index)
    {
        assert(index >= 0 && index < m_count);

        if (m_count == 1)
        {
            clear();
            return;
        }

        double* newData{ new double[static_cast<std::size_t>(m_count - 1)] };

        std::copy_n(m_temperatures, index, newData);
        std::copy_n(m_temperatures + index + 1, m_count - index - 1, newData + index);

        delete[] m_temperatures;
        m_temperatures = newData;
        --m_count;
    }

    void insertAtStart(double temperature) { insertAt(temperature, 0); }
    void insertAtEnd(double temperature) { insertAt(temperature, m_count); }

    int getCount() const { return m_count; }
};

Let's test our container:

#include "TemperatureLog.h"
#include <iostream>

int main()
{
    TemperatureLog weekLog{ 7 };

    weekLog[0] = 72.5;
    weekLog[1] = 75.0;
    weekLog[2] = 68.3;
    weekLog[3] = 71.2;
    weekLog[4] = 73.8;
    weekLog[5] = 70.1;
    weekLog[6] = 69.5;

    weekLog.resize(5);

    weekLog.insertAt(74.4, 2);

    weekLog.removeAt(4);

    weekLog.insertAtEnd(77.2);
    weekLog.insertAtStart(65.8);

    {
        TemperatureLog backup{ weekLog };
        backup = weekLog;
        backup = backup;
        weekLog = weekLog;
    }

    for (int i{ 0 }; i < weekLog.getCount(); ++i)
        std::cout << weekLog[i] << ' ';

    std::cout << '\n';

    return 0;
}

Output:

65.8 72.5 75.0 74.4 68.3 71.2 77.2

Once you've built and tested a container class, you can reuse it throughout your codebase without additional programming effort.

Several enhancements could improve this class:

  • Template the class to work with any copyable type, not just double
  • Add const overloads for functions that read data
  • Implement move semantics with move constructor and move assignment
  • Use move operations instead of copying during resize and insertion

Advanced note: Exception safety improvements include using move operations only when the move constructor is noexcept and providing strong exception guarantees for resize and insertion operations.

Remember: when a standard library container meets your needs, use it instead of writing your own. For example, prefer std::vector<double> over TemperatureLog. Standard library containers are thoroughly tested, optimized, and integrate seamlessly with other standard library components. However, when you need specialized container behavior not provided by the standard library, knowing how to build your own is invaluable.

Summary

Container classes organize collections of objects of a specific type, providing standardized operations for managing that data. They fall into two categories: value containers (using composition to store copies) and reference containers (using aggregation to store pointers or references to externally managed objects).

Essential container operations include constructors for creating empty or pre-sized containers, methods to add and remove elements, size reporting functions, clear operations, element access mechanisms, and optional sorting capabilities. Some containers deliberately omit expensive operations like insertion and removal for array-based implementations.

Building custom containers requires implementing resource management (constructors, destructors, clear functions), element access (subscript operators, size queries), resizing capabilities (reallocate and resize), copy operations (copy constructor and assignment operator), and potentially insertion/deletion operations. The TemperatureLog example demonstrated these concepts by creating a dynamic array-based container for storing temperature readings.

Enhancements for production containers include templating the class to work with any copyable type, adding const overloads for read-only operations, implementing move semantics with move constructor and move assignment, and using move operations during resize and insertion when appropriate.

Container classes enable code reuse throughout your codebase, though custom containers should only be built when standard library containers don't meet your specific needs.