在现实生活中,我们无时无刻不在使用容器。早餐麦片装在盒子里,书籍的页面被封面与装订包裹,车库里的各种物品也被收纳于形形色色的容器之中。倘若没有容器,许多物品的使用将变得极为不便:设想阅读一本毫无装订的书,或在不用碗的情况下直接吃未装盒的麦片——必然是一团糟。容器所提供的主要价值,正在于它能够帮助整理并存储被置于其中的物品。
同理,容器类(container class)是一种为了保存并组织另一类型(可为其他类或基本类型)的多个实例而设计的类。容器类种类繁多,各自在使用时具有不同的优势、劣势与限制。迄今为止,编程中最常用的容器为数组(array),诸位已在诸多示例中见过。尽管 C++ 内置数组功能,程序员仍常选用数组容器类(std::array 或 std::vector),因其额外提供了诸多便利。与内置数组不同,数组容器类通常支持动态调整大小(增删元素后自动伸缩)、在作为函数参数传递时仍能记住自身大小,并可进行越界检查。这不仅使数组容器类较普通数组更易用,亦使其更安全。
容器类功能
容器类通常实现一套相当标准化的最小功能集合。任何定义良好的容器,大都包含以下函数:
- 通过构造函数创建一个空容器
- 向容器内插入新对象
- 从容器中移除对象
- 报告当前容器内的对象数目
- 清空容器内全部对象
- 提供对已存对象的访问
- (可选)对元素进行排序
某些容器类有时会省略上述部分功能。例如,数组容器类常省略插入与删除函数,因其效率低下,设计者亦不希望鼓励其使用。
容器类体现的是“成员归属”(member-of)关系。例如,数组中的元素即为该数组的“成员”。请注意,此处“member-of”指日常语义,而非 C++ 中类成员(class member)之意。
容器的种类
容器类通常可分为两大类型。值容器(value container)属于组合(composition),保存所容纳对象的副本,故负责这些副本的创建与销毁。引用容器(reference container)属于聚合(aggregation),存储指向其他对象的指针或引用,故不负责这些对象的创建与销毁。
与现实生活不同,现实中的容器可随意容纳任意类型之物,而 C++ 中容器通常只能保存单一类型的数据。例如,若有一整数数组,则其只能存放整数。与某些其它语言不同,许多 C++ 容器不允许任意混用类型。若需容器同时存放整数与双精度浮点数,通常必须编写两个分离的容器(或采用模板——C++ 的高级特性之一)。尽管使用上存在限制,容器仍极其有用,它们令编程更轻松、更安全、更高效。
一个数组容器类示例
本节我们将从零开始编写一个整数数组类,实现容器应具备的大部分常用功能。该数组类为值容器,将保存所组织元素的副本。顾名思义,该容器将保存一个整数数组,功能类似于 std::vector<int>
。
首先,创建 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
我们尚需若干函数以清理 IntArray
。首先编写析构函数,仅负责释放动态分配的内存;其次编写 erase()
函数,用于清空数组并将长度置零。
~IntArray()
{
delete[] m_data;
// 此处无需将 m_data 置空或将 m_length 置 0,因为对象将在该函数执行后立即销毁
}
void erase()
{
delete[] m_data;
// 必须将 m_data 设为 nullptr,否则它将悬空指向已释放的内存!
m_data = nullptr;
m_length = 0;
}
现在重载下标运算符 []
,以便访问数组元素。我们需确保索引参数有效,可借助 assert()
实现;另增一函数返回数组长度。至此全部代码如下:
#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;
// 此处无需将 m_data 置空或将 m_length 置 0,因为对象将在该函数执行后立即销毁
}
void erase()
{
delete[] m_data;
// 必须将 m_data 设为 nullptr,否则它将悬空指向已释放的内存!
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
,并通过 []
运算符访问或修改元素。
然而,当前 IntArray
仍无法:改变大小、插入或删除元素、排序;且复制数组时,会因浅拷贝数据指针而引发问题。
调整数组大小
首先,编写允许调整数组大小的代码。我们实现两个函数:其一 reallocate()
,调整大小时销毁原有全部元素,但速度快;其二 resize()
,调整大小时保留原有元素,但速度较慢。
#include <algorithm> // for std::copy_n
// reallocate 调整数组大小。所有现有元素将被销毁。本函数执行迅速。
void reallocate(int newLength)
{
// 首先删除现有元素
erase();
// 若调整后数组为空,直接返回
if (newLength <= 0)
return;
// 然后分配新元素
m_data = new int[static_cast<std::size_t>(newLength)];
m_length = newLength;
}
// resize 调整数组大小。所有现有元素将被保留。本函数执行缓慢。
void resize(int newLength)
{
// 若数组长度已符合要求,无需操作
if (newLength == m_length)
return;
// 若调整为空数组,则清空并返回
if (newLength <= 0)
{
erase();
return;
}
// 此时可假设 newLength 至少为 1。算法如下:
// 首先分配新数组,然后将原数组元素复制到新数组,
// 完成后销毁旧数组,并使 m_data 指向新数组。
// 首先分配新数组
int* data{ new int[static_cast<std::size_t>(newLength)] };
// 然后计算需复制的元素数:取新旧数组长度较小者
if (m_length > 0)
{
int elementsToCopy{ (newLength > m_length) ? m_length : newLength };
std::copy_n(m_data, elementsToCopy, data); // 复制元素
}
// 现在删除旧数组
delete[] m_data;
// 使用新数组!注意:此处仅令 m_data 指向动态分配的新数组地址,
// 因 data 为动态分配,离开作用域时不会被销毁
m_data = data;
m_length = newLength;
}
呼!这确实有些棘手!
拷贝构造函数与赋值运算符
再添加拷贝构造函数与赋值运算符,以支持数组拷贝。
IntArray(const IntArray& a): IntArray(a.getLength()) // 使用普通构造函数设置数组大小
{
std::copy_n(a.m_data, m_length, m_data); // 复制元素
}
IntArray& operator=(const IntArray& a)
{
// 自检自赋值
if (&a == this)
return *this;
// 先调整新数组大小
reallocate(a.getLength());
std::copy_n(a.m_data, m_length, m_data); // 复制元素
return *this;
}
插入与删除元素
许多数组容器类实现到此即可;然而,若诸位想观察插入与删除功能如何实现,我们亦一并给出。两算法与 resize()
类似。
void insertBefore(int value, int index)
{
// 对索引值进行健全性检查
assert(index >= 0 && index <= m_length);
// 首先创建比旧数组大一个元素的新数组
int* data{ new int[static_cast<std::size_t>(m_length+1)] };
// 复制索引前的所有元素
std::copy_n(m_data, index, data);
// 在新数组的指定索引处插入新元素
data[index] = value;
// 复制插入元素之后的所有值
std::copy_n(m_data + index, m_length - index, data + index + 1);
// 最后删除旧数组,并使用新数组
delete[] m_data;
m_data = data;
++m_length;
}
void remove(int index)
{
// 对索引值进行健全性检查
assert(index >= 0 && index < m_length);
// 若数组仅剩最后一个元素,清空后返回
if (m_length == 1)
{
erase();
return;
}
// 首先创建比旧数组小一个元素的新数组
int* data{ new int[static_cast<std::size_t>(m_length-1)] };
// 复制索引前的所有元素
std::copy_n(m_data, index, data);
// 复制被删元素之后的所有值
std::copy_n(m_data + index + 1, m_length - index - 1, data + index);
// 最后删除旧数组,并使用新数组
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;
// 此处无需将 m_data 置空或将 m_length 置 0,因为对象将在该函数执行后立即销毁
}
IntArray(const IntArray& a): IntArray(a.getLength()) // 使用普通构造函数设置数组大小
{
std::copy_n(a.m_data, m_length, m_data); // 复制元素
}
IntArray& operator=(const IntArray& a)
{
// 自检自赋值
if (&a == this)
return *this;
// 先调整新数组大小
reallocate(a.getLength());
std::copy_n(a.m_data, m_length, m_data); // 复制元素
return *this;
}
void erase()
{
delete[] m_data;
// 必须将 m_data 设为 nullptr,否则它将悬空指向已释放的内存!
m_data = nullptr;
m_length = 0;
}
int& operator[](int index)
{
assert(index >= 0 && index < m_length);
return m_data[index];
}
// reallocate 调整数组大小。所有现有元素将被销毁。本函数执行迅速。
void reallocate(int newLength)
{
// 首先删除现有元素
erase();
// 若调整后数组为空,直接返回
if (newLength <= 0)
return;
// 然后分配新元素
m_data = new int[static_cast<std::size_t>(newLength)];
m_length = newLength;
}
// resize 调整数组大小。所有现有元素将被保留。本函数执行缓慢。
void resize(int newLength)
{
// 若数组长度已符合要求,无需操作
if (newLength == m_length)
return;
// 若调整为空数组,则清空并返回
if (newLength <= 0)
{
erase();
return;
}
// 此时可假设 newLength 至少为 1。算法如下:
// 首先分配新数组,然后将原数组元素复制到新数组,
// 完成后销毁旧数组,并使 m_data 指向新数组。
// 首先分配新数组
int* data{ new int[static_cast<std::size_t>(newLength)] };
// 然后计算需复制的元素数:取新旧数组长度较小者
if (m_length > 0)
{
int elementsToCopy{ (newLength > m_length) ? m_length : newLength };
std::copy_n(m_data, elementsToCopy, data); // 复制元素
}
// 现在删除旧数组
delete[] m_data;
// 使用新数组!注意:此处仅令 m_data 指向动态分配的新数组地址,
// 因 data 为动态分配,离开作用域时不会被销毁
m_data = data;
m_length = newLength;
}
void insertBefore(int value, int index)
{
// 对索引值进行健全性检查
assert(index >= 0 && index <= m_length);
// 首先创建比旧数组大一个元素的新数组
int* data{ new int[static_cast<std::size_t>(m_length+1)] };
// 复制索引前的所有元素
std::copy_n(m_data, index, data);
// 在新数组的指定索引处插入新元素
data[index] = value;
// 复制插入元素之后的所有值
std::copy_n(m_data + index, m_length - index, data + index + 1);
// 最后删除旧数组,并使用新数组
delete[] m_data;
m_data = data;
++m_length;
}
void remove(int index)
{
// 对索引值进行健全性检查
assert(index >= 0 && index < m_length);
// 若数组仅剩最后一个元素,清空后返回
if (m_length == 1)
{
erase();
return;
}
// 首先创建比旧数组小一个元素的新数组
int* data{ new int[static_cast<std::size_t>(m_length-1)] };
// 复制索引前的所有元素
std::copy_n(m_data, index, data);
// 复制被删元素之后的所有值
std::copy_n(m_data + index + 1, m_length - index - 1, data + index);
// 最后删除旧数组,并使用新数组
delete[] m_data;
m_data = data;
--m_length;
}
// 为方便起见再提供两个函数
void insertAtBeginning(int value) { insertBefore(value, 0); }
void insertAtEnd(int value) { insertBefore(value, m_length); }
int getLength() const { return m_length; }
};
#endif
测试 IntArray
下面简单测试以验证其可用性:
#include <iostream>
#include "IntArray.h"
int main()
{
// 声明一个包含 10 个元素的数组
IntArray array(10);
// 用 1 至 10 填充数组
for (int i{ 0 }; i<10; ++i)
array[i] = i+1;
// 将数组大小调整为 8 个元素
array.resize(8);
// 在索引 5 之前插入数字 20
array.insertBefore(20, 5);
// 删除索引 3 的元素
array.remove(3);
// 在末尾与开头分别添加 30 与 40
array.insertAtEnd(30);
array.insertAtBeginning(40);
// 再做若干拷贝构造/赋值测试,确保无异常
{
IntArray b{ array };
b = array;
b = b;
array = array;
}
// 打印所有数字
for (int i{ 0 }; i<array.getLength(); ++i)
std::cout << array[i] << ' ';
std::cout << '\n';
return 0;
}
程序输出:
40 1 2 3 5 20 6 7 8 30
尽管编写容器类颇为复杂,但好消息是只需编写一次。一旦容器类正常工作,便可随时反复使用,无需额外编程。
改进方向
若干可/应进一步改进之处:
- 可将其改为模板类,使之适用于任何可复制类型,而非仅限于
int
。 - 应添加各成员函数的
const
重载,以正确支持const IntArray
。 - 应添加移动语义支持(通过增加移动构造函数与移动赋值运算符)。
- 执行
resize
或插入操作时,可使用移动而非复制元素。
进阶读者
有关异常处理的高级改进:
- 执行
resize
或插入操作时,仅当元素的移动构造函数为noexcept
时才移动元素,否则复制(std::move_if_noexcept
)。 - 为
resize
或插入操作提供强异常安全保障(Exception specifications andnoexcept
)。
最后提醒:若标准库中的类已能满足需求,请优先使用,而非自行创建。例如,与其使用 IntArray
,不如直接使用 std::vector<int>
——它经过充分测试、高效,且与标准库中的其他类配合良好。然而,有时你需要标准库尚未提供的专用容器类,因此了解如何自行创建亦属必要。