23-6 容器类

现实生活中,我们时刻都在使用容器。早餐麦片装在盒子里,书页被封面和装订固定在一起,车库里的物品也存放在各种容器中。若没有容器,处理这些物品将极其不便。试想阅读一本没有装订的书,或不使用碗直接吃盒装麦片——场面将一片狼藉。容器的核心价值在于其组织和存储内部物品的能力。

同理,容器类是专门设计用于容纳并组织其他类型(另一类或基本类型)多个实例的类。容器类种类繁多,每种都具备不同的优势、劣势及使用限制。编程中最常用的容器当属数组,你已见过许多相关示例。尽管C++内置数组功能,但程序员常选择数组容器类(如std::array或std::vector),因其能提供额外优势。与内置数组不同,数组容器类通常支持动态调整大小(当元素增删时)、在传递给函数时记住其大小,并执行边界检查。这不仅使数组容器类比普通数组更便捷,也更安全。

容器类通常实现一套标准化的基础功能集。大多数规范容器都包含以下操作:

  • 通过构造函数创建空容器
  • 向容器插入新对象
  • 从容器移除对象
  • 报告容器当前对象数量
  • 清空容器内所有对象
  • 访问存储对象
  • 排序元素(可选)

某些容器类可能省略部分功能。例如数组容器常省略插入和移除函数,因其效率低下且设计者不鼓励使用。

容器类实现成员关系。例如数组元素是数组的成员(属于数组)。需注意此处“成员”采用常规含义,而非C++类成员的特定含义。


容器类型

容器类通常分为两种类型。值容器Value containers是存储其持有对象副本的组合体(因此负责创建和销毁这些副本)。引用容器是存储指向其他对象的指针或引用的聚合体(因此不负责创建或销毁这些对象)。

与现实中容器可容纳任意类型对象不同,C++中的容器通常仅存储单一类型数据。例如,整型数组仅能存储整数。与某些语言不同,多数C++容器不支持随意混合类型。若需同时存储整数和双精度数,通常需编写两个独立容器(或使用模板——这是C++的高级特性)。尽管存在使用限制,容器仍极具实用价值,能显著提升编程的便捷性、安全性与效率。


数组容器类

在此示例中,我们将从零开始编写一个整型数组类,实现容器应具备的大部分常用功能。该数组类将作为值容器,存储其所组织元素的副本。顾名思义,该容器将持有整型数组,类似于 std::vector

首先创建 IntArray.h 文件:

#ifndef INTARRAY_H
#define INTARRAY_H

class IntArray
{
};

#endif

我们的IntArray需要同时记录两个值:数据本身和数组的大小。由于我们希望数组能够动态调整大小,因此必须采用动态分配机制,这意味着需要使用指针来存储数据。

#ifndef INTARRAY_H
#define INTARRAY_H

class IntArray
{
private:
    int m_length{};
    int* m_data{};
};

#endif

现在我们需要添加一些构造函数,以便能够创建 IntArray 数组。我们将添加两个构造函数:一个用于构造空数组,另一个用于构造预先确定大小的数组。

#ifndef INTARRAY_H
#define INTARRAY_H

#include <cassert> // for assert()
#include <cstddef> // for std::size_t

class IntArray
{
private:
    int m_length{};
    int* m_data{};

public:
    IntArray() = default;

    IntArray(int length):
        m_length{ length }
    {
        assert(length >= 0);

        if (length > 0)
            m_data = new int[static_cast<std::size_t>(length)]{};
    }
};

#endif

我们还需要一些函数来帮助清理 IntArrays。首先,我们将编写一个析构函数,它仅需释放所有动态分配的数据。其次,我们将编写一个名为 erase() 的函数,该函数将清除数组并将其长度设置为 0。

~IntArray()
{
    delete[] m_data;
    // we don't need to set m_data to null or m_length to 0 here, since the object will be destroyed immediately after this function anyway
}

void erase()
{
    delete[] m_data;

    // We need to make sure we set m_data to nullptr here, otherwise it will
    // be left pointing at deallocated memory!
    m_data = nullptr;
    m_length = 0;
}

现在让我们重载[]运算符,以便访问数组元素。我们需要确保索引参数具有有效值,可通过使用assert()函数实现。同时将添加一个access函数来返回数组长度。以下是当前代码:

#ifndef INTARRAY_H
#define INTARRAY_H

#include <cassert> // for assert()
#include <cstddef> // for std::size_t

class IntArray
{
private:
    int m_length{};
    int* m_data{};

public:
    IntArray() = default;

    IntArray(int length):
        m_length{ length }
    {
        assert(length >= 0);

        if (length > 0)
            m_data = new int[static_cast<std::size_t>(length)]{};
    }

    ~IntArray()
    {
        delete[] m_data;
        // we don't need to set m_data to null or m_length to 0 here, since the object will be destroyed immediately after this function anyway
    }

    void erase()
    {
        delete[] m_data;
        // We need to make sure we set m_data to nullptr here, otherwise it will
        // be left pointing at deallocated memory!
        m_data = nullptr;
        m_length = 0;
    }

    int& operator[](int index)
    {
        assert(index >= 0 && index < m_length);
        return m_data[index];
    }

    int getLength() const { return m_length; }
};

#endif

至此,我们已拥有可直接使用的IntArray类。它支持按指定大小分配数组,并能通过[]运算符读取或修改元素值。

然而,当前的IntArray仍存在若干局限:无法调整数组大小,无法插入或删除元素,也无法进行排序。复制数组时会引发问题,因为这仅会浅拷贝数据指针。

首先编写可调整数组大小的代码。为此将实现两个不同函数:reallocate() 函数在调整大小时会销毁数组中所有现有元素,但速度较快;resize() 函数在调整大小时会保留数组中所有现有元素,但速度较慢。

#include <algorithm> // for std::copy_n

    // reallocate resizes the array.  Any existing elements will be destroyed.  This function operates quickly.
    void reallocate(int newLength)
    {
        // First we delete any existing elements
        erase();

        // If our array is going to be empty now, return here
        if (newLength <= 0)
            return;

        // Then we have to allocate new elements
        m_data = new int[static_cast<std::size_t>(newLength)];
        m_length = newLength;
    }

    // resize resizes the array.  Any existing elements will be kept.  This function operates slowly.
    void resize(int newLength)
    {
        // if the array is already the right length, we're done
        if (newLength == m_length)
            return;

        // If we are resizing to an empty array, do that and return
        if (newLength <= 0)
        {
            erase();
            return;
        }

        // Now we can assume newLength is at least 1 element.  This algorithm
        // works as follows: First we are going to allocate a new array.  Then we
        // are going to copy elements from the existing array to the new array.
        // Once that is done, we can destroy the old array, and make m_data
        // point to the new array.

        // First we have to allocate a new array
        int* data{ new int[static_cast<std::size_t>(newLength)] };

        // Then we have to figure out how many elements to copy from the existing
        // array to the new array.  We want to copy as many elements as there are
        // in the smaller of the two arrays.
        if (m_length > 0)
        {
            int elementsToCopy{ (newLength > m_length) ? m_length : newLength };
            std::copy_n(m_data, elementsToCopy, data); // copy the elements
        }

        // Now we can delete the old array because we don't need it any more
        delete[] m_data;

        // And use the new array instead!  Note that this simply makes m_data point
        // to the same address as the new array we dynamically allocated.  Because
        // data was dynamically allocated, it won't be destroyed when it goes out of scope.
        m_data = data;
        m_length = newLength;
    }

呼!这有点棘手!

我们还需添加复制构造函数和赋值运算符,以便复制数组。

IntArray(const IntArray& a): IntArray(a.getLength()) // use normal constructor to set size of array appropriately
{
    std::copy_n(a.m_data, m_length, m_data); // copy the elements
}

IntArray& operator=(const IntArray& a)
{
    // Self-assignment check
    if (&a == this)
        return *this;

    // Set the size of the new array appropriately
    reallocate(a.getLength());
    std::copy_n(a.m_data, m_length, m_data); // copy the elements

    return *this;
}

许多数组容器类到此为止就结束了。不过,为了让你了解插入和删除功能的实现方式,我们还将继续编写这些功能。这两种算法都与 resize() 非常相似。

void insertBefore(int value, int index)
 {
     // Sanity check our index value
     assert(index >= 0 && index <= m_length);

     // First create a new array one element larger than the old array
     int* data{ new int[static_cast<std::size_t>(m_length+1)] };

     // Copy all of the elements up to the index
     std::copy_n(m_data, index, data);

     // Insert our new element into the new array
     data[index] = value;

     // Copy all of the values after the inserted element
     std::copy_n(m_data + index, m_length - index, data + index + 1);

     // Finally, delete the old array, and use the new array instead
     delete[] m_data;
     m_data = data;
     ++m_length;
 }

 void remove(int index)
 {
     // Sanity check our index value
     assert(index >= 0 && index < m_length);

     // If this is the last remaining element in the array, set the array to empty and bail out
     if (m_length == 1)
     {
         erase();
         return;
     }

     // First create a new array one element smaller than the old array
     int* data{ new int[static_cast<std::size_t>(m_length-1)] };

     // Copy all of the elements up to the index
     std::copy_n(m_data, index, data);

     // Copy all of the values after the removed element
     std::copy_n(m_data + index + 1, m_length - index - 1, data + index);

     // Finally, delete the old array, and use the new array instead
     delete[] m_data;
     m_data = data;
     --m_length;
 }

以下是我们的 IntArray 容器类完整代码。

IntArray.h:

#ifndef INTARRAY_H
#define INTARRAY_H

#include <algorithm> // for std::copy_n
#include <cassert> // for assert()
#include <cstddef> // for std::size_t

class IntArray
{
private:
    int m_length{};
    int* m_data{};

public:
    IntArray() = default;

    IntArray(int length):
        m_length{ length }
    {
        assert(length >= 0);

        if (length > 0)
            m_data = new int[static_cast<std::size_t>(length)]{};
    }

    ~IntArray()
    {
        delete[] m_data;
        // we don't need to set m_data to null or m_length to 0 here, since the object will be destroyed immediately after this function anyway
    }

    IntArray(const IntArray& a): IntArray(a.getLength()) // use normal constructor to set size of array appropriately
    {
        std::copy_n(a.m_data, m_length, m_data); // copy the elements
    }

    IntArray& operator=(const IntArray& a)
    {
        // Self-assignment check
        if (&a == this)
            return *this;

        // Set the size of the new array appropriately
        reallocate(a.getLength());
        std::copy_n(a.m_data, m_length, m_data); // copy the elements

        return *this;
    }

    void erase()
    {
        delete[] m_data;
        // We need to make sure we set m_data to nullptr here, otherwise it will
        // be left pointing at deallocated memory!
        m_data = nullptr;
        m_length = 0;
    }

    int& operator[](int index)
    {
        assert(index >= 0 && index < m_length);
        return m_data[index];
    }


    // reallocate resizes the array.  Any existing elements will be destroyed.  This function operates quickly.
    void reallocate(int newLength)
    {
        // First we delete any existing elements
        erase();

        // If our array is going to be empty now, return here
        if (newLength <= 0)
            return;

        // Then we have to allocate new elements
        m_data = new int[static_cast<std::size_t>(newLength)];
        m_length = newLength;
    }

    // resize resizes the array.  Any existing elements will be kept.  This function operates slowly.
    void resize(int newLength)
    {
        // if the array is already the right length, we're done
        if (newLength == m_length)
            return;

        // If we are resizing to an empty array, do that and return
        if (newLength <= 0)
        {
            erase();
            return;
        }

        // Now we can assume newLength is at least 1 element.  This algorithm
        // works as follows: First we are going to allocate a new array.  Then we
        // are going to copy elements from the existing array to the new array.
        // Once that is done, we can destroy the old array, and make m_data
        // point to the new array.

        // First we have to allocate a new array
        int* data{ new int[static_cast<std::size_t>(newLength)] };

        // Then we have to figure out how many elements to copy from the existing
        // array to the new array.  We want to copy as many elements as there are
        // in the smaller of the two arrays.
        if (m_length > 0)
        {
            int elementsToCopy{ (newLength > m_length) ? m_length : newLength };
            std::copy_n(m_data, elementsToCopy, data); // copy the elements
        }

        // Now we can delete the old array because we don't need it any more
        delete[] m_data;

        // And use the new array instead!  Note that this simply makes m_data point
        // to the same address as the new array we dynamically allocated.  Because
        // data was dynamically allocated, it won't be destroyed when it goes out of scope.
        m_data = data;
        m_length = newLength;
    }

    void insertBefore(int value, int index)
    {
        // Sanity check our index value
        assert(index >= 0 && index <= m_length);

        // First create a new array one element larger than the old array
        int* data{ new int[static_cast<std::size_t>(m_length+1)] };

        // Copy all of the elements up to the index
        std::copy_n(m_data, index, data);

        // Insert our new element into the new array
        data[index] = value;

        // Copy all of the values after the inserted element
        std::copy_n(m_data + index, m_length - index, data + index + 1);

        // Finally, delete the old array, and use the new array instead
        delete[] m_data;
        m_data = data;
        ++m_length;
    }

    void remove(int index)
    {
        // Sanity check our index value
        assert(index >= 0 && index < m_length);

        // If this is the last remaining element in the array, set the array to empty and bail out
        if (m_length == 1)
        {
            erase();
            return;
        }

        // First create a new array one element smaller than the old array
        int* data{ new int[static_cast<std::size_t>(m_length-1)] };

        // Copy all of the elements up to the index
        std::copy_n(m_data, index, data);

        // Copy all of the values after the removed element
        std::copy_n(m_data + index + 1, m_length - index - 1, data + index);

        // Finally, delete the old array, and use the new array instead
        delete[] m_data;
        m_data = data;
        --m_length;
    }

    // A couple of additional functions just for convenience
    void insertAtBeginning(int value) { insertBefore(value, 0); }
    void insertAtEnd(int value) { insertBefore(value, m_length); }

    int getLength() const { return m_length; }
};

#endif

现在,让我们测试一下,以证明它确实有效:

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

int main()
{
    // Declare an array with 10 elements
    IntArray array(10);

    // Fill the array with numbers 1 through 10
    for (int i{ 0 }; i<10; ++i)
        array[i] = i+1;

    // Resize the array to 8 elements
    array.resize(8);

    // Insert the number 20 before element with index 5
    array.insertBefore(20, 5);

    // Remove the element with index 3
    array.remove(3);

    // Add 30 and 40 to the end and beginning
    array.insertAtEnd(30);
    array.insertAtBeginning(40);

    // A few more tests to ensure copy constructing / assigning arrays
    // doesn't break things
    {
        IntArray b{ array };
        b = array;
        b = b;
        array = array;
    }

    // Print out all the numbers
    for (int i{ 0 }; i<array.getLength(); ++i)
        std::cout << array[i] << ' ';

    std::cout << '\n';

    return 0;
}

这产生了以下结果:

image

尽管编写容器类可能相当复杂,但好消息是只需编写一次。一旦容器类正常运行,您就可以随心所欲地使用和复用它,无需额外编程工作。

可改进之处:

  1. 模板化设计:应将其设计为模板类,使之支持任意可复制类型而非仅限整型。
  2. 常量支持:需为各成员函数添加常量重载版本,以正确支持常量数组。
  3. 移动语义支持:应通过添加移动构造函数和移动赋值操作符实现移动语义。
  4. 优化操作效率:执行调整大小或插入操作时,可采用元素移动替代复制。

进阶读者参考
与异常处理相关的进阶改进:

最后补充: 若标准库中的类能满足需求,请优先使用而非自行创建。例如,比起使用 IntArray,更推荐使用 std::vector。它经过实战检验,效率高,且能与标准库中的其他类良好协作。但有时需要标准库中不存在的专用容器类,因此掌握创建方法仍很有必要。

posted @ 2026-01-31 06:58  游翔  阅读(0)  评论(0)    收藏  举报