C++ Vector容器实现
引言
本文主要内容
(1) C数组
(2)C指针 malloc 动态数组
(3) 类
(4)泛型编程 类模板
(5)动态分配
数组
引用《C++ Primer Plus》中关于数组的描述:数组是一种数据格式,能够存储多个同类型的值。
以下以一维数组为例:
a.定义数组:
元素类型名 数组名[元素个数]
例:
int nxMyArry[10]; // 定义一个叫做nxMyArry的int型数组,可以存储10个元素
b.使用数组
数组名[下标]
c.数组使用问题
我们都知道,在C++中,数组遵循先定义后使用的原则,在定义数组时,元素个数必须使用整型常数(如上述例子中10)或 const 值,也可以是常量表达式,即数组大小在编译时是已知的。这样带来的问题是,定义时不知道需要使用多大的数组,定义过小不够使用,定义过大造成浪费。解决此问题可以使用动态数组。
动态数组
动态数组,顾名思义就是数组的大小是动态的,是可变的。动态数组的原理是当原有数组大小不够时,定义一个新的数组,将原有数组中的数据拷贝至新的数组中,完成数组的扩充。
在C语言中,可以用库函数 malloc() 来分配内存 ;C++11也保留了这一特性,但C++还有更好的方法——new运算符。
以下是malloc使用的大致代码,主要思想是根据变量n的值来开辟对应大小的内存,达到不浪费内存的目的。
// 动态数组使用
int n; // 数组大小
int* p = null; // 定义p存放数组地址
p = malloc(n * sizeof(int)); // 更严格的写法 p = (int*)malloc(n * sizeof(int));
//中间使用...
free(p); // 使用完成后释放内存
下面以一个实际例子来说明:定义一个数组用来记录一组整型数据,数据的个数0-10000。
【分析】由于不知道数据的具体数量,因此在定义大小时会面临不够使用或者浪费的问题,数组大小不够(程序运行期间出现越界问题)是不能够容忍的,因此稳妥型编程如下:
#define ArrySize 10000
int _nxMyArry[_nArrySize];
这样定义的好处是不用担心数组不够用的情况发生,但是,当只有50个数据时,这样定义明显存在内存浪费的情况。(PS:这里的数据量只是为了说明存在浪费),当使用动态数组时:
int _nArrySize = 0; // _nArrySize为决定数组大小的变量
int* p = NULL; // 定义p存放数组地址
// _nArrySize 可以由其它参数给定,这里以键盘输入为例
cin >> _nArrySize;
p = malloc(_nArrySize * sizeof(int)); // 更严格的写法 p = (int*)malloc(n * sizeof(int));
//中间使用...
free(p); // 使用完成后释放内存
这样就完全没有浪费的情况发生,也不存在不够的情况,开辟的空间完全由需求决定。为方便后续理解,上述内存情况如图 1 所示:
数组类封装
当我们提供一个容器的时候,为方便消费者使用,需要提供一些功能,例如push、pop等最基本的操作,下面开始一步一步的进行完善和封装。
容器功能—插入数据
假设我们现在有一个函数push,功能是将 int 型数据 data 插入首地址为 p 的数组中存储,则插入该数据的流程图如图 2 所示:
int* p = NULL; // 指向内存段,用于记录存储数据的首地址
int num = 0; // 用于记录当前数据的数量
// 插入数据
void push(int data)
{
// p 为空时,表明不存在数据,直接开辟一个空间存储新数据即可
// p 不为空时,表明已存在数据,需要将空间扩大,再新添加数据
if (p != NULL)
{
// 1.1 新开内存
int* pNew = (int*)malloc((num + 1) * sizeof(int));
// 1.2 拷贝原有数据并释放原内存
memcpy(pNew, p, sizeof(int*) * num;
free(p);
// 1.3 p指向新开的内存
p = pNew;
// 1.4 添加新数据
p[num] = data;
// 1.5 更新容器中数据的数量
num++;
}
else
{
// 2.1 开内存
// 2.2 p指向开辟的内存空间
p = (int*)malloc(sizeof(int));
// 2.3 添加数据
p[num] = data;
// 2.4 更新容器中数据的数量
num++;
}
}
从上述流程图及代码可以看出,p 为空时,表明之前没有存储过数据,仅需要任意开辟一段内存空间存储需要 push 的数据即可;而当 p 不为空时,表明之前已经 push 过数据, 这时候的做法是重新开辟一块更大的空间,将原本存在的数据拷贝至新空间,然后再新加入本次需要 push 的数据。
细心的可能看出来了,上述代码存在问题:每次 push 一个数据都需要去开辟一次空间、拷贝一次数据、释放一次空间,这极大的拖慢了效率,而且还会造成额外的性能开销,解决办法后续再说。当已有数据存在是,重新开辟空间的示意图如下图所示,有人可能有疑惑,为什么不在原内存段后面直接增加一些空间,这样不就能不拷贝数据了吗?这是由于:
- (1)原地址后续地址可能已经分配给其他程序;
- (2)操作系统分配地址是随机的,只能给你一块未占用的大小足够的地址空间,而不能保证是你想要的具体地址。这就好比预定酒店的时候,不知道是具体哪间房,只知道是按你预定要求的某一间。
// 插入数据
void push(int data)
{
// 1 新开内存
int* pNew = new int[num + 1];
if (p != NULL)
{
// 2 拷贝原有数据并释放原内存
memcpy(pNew, p, sizeof(int*) * num;
delete[] p;
}
// 3 p 指向新开的内存
p = pNew;
// 4 添加新数据进来。数据量增1
p[num++] = data;
}
下面开始对这个功能进行封装,使之成为一个类。
这个类成员是数组首地址和数组的数据个数,类函数即插入数据,后续再增加一些删除、查找等功能,逐渐完善,直接看下面代码吧。
class MyIntArry{
prvite:
int* m_pArry; // 指向内存段,用于记录存储数据的首地址
int m_nNum; // 用于记录当前数据的数量
public:
MyIntArry()
{
m_pArry = NULL;
m_nNum = 0;
}
~MyIntArry()
{
if(m_pArry != NULL)
{
delete[] m_pArry;
m_pArry = NULL;
}
}
public:
void push(int nData_); // 插入数据
void pop(); // 删除数据
};
void MyIntArry::push(int nData_)
{
// 1 新开内存
int* pNew = new int[m_nNum + 1];
if (m_pArry != NULL)
{
// 2 拷贝原有数据并释放原内存
memcpy(pNew, m_pArry, sizeof(int*) * m_nNum;
delete[] m_pArry;
}
// 3 p 指向新开的内存
m_pArry = pNew;
// 4 添加新数据进来。数据量增1
m_pArry[num++] = nData_;
}
完成以上工作,这个类就已经完成封装了,需要什么功能再完善一下就好。当然,这不是我们这篇文章的终极目的,我们的终极目的是做一个容器,把这个类变成泛型的、动态分配的。
类模板封装
面向对象编程(OOP)和泛型编程都能处理在编写程序时不知道类型的情况。容器、迭代器都是泛型编程的例子。模板是C++中泛型编程的基础。下面将我们刚才封装好的类改为类模板:
template<class T>
class MyArry{
prvite:
T* m_pArry; // 指向内存段,用于记录存储数据的首地址
int m_nNum; // 用于记录当前数据的数量
public:
MyArry()
{
m_pArry = NULL;
m_nNum = 0;
}
~MyArry()
{
if(m_pArry != NULL)
{
delete[] m_pArry;
m_pArry = NULL;
}
}
public:
void push(const T& nData_); // 插入数据 const T& nData_ 这种写法更安全
void pop(); // 删除数据
};
void MyArry<T>::push(const T& nData_)
{
// 1 新开内存
T* pNew = new T[m_nNum + 1];
if (m_pArry != NULL)
{
// 2 拷贝原有数据并释放原内存
memcpy(pNew, m_pArry, sizeof(T*) * m_nNum;
delete[] m_pArry;
}
// 3 p 指向新开的内存
m_pArry = pNew;
// 4 添加新数据进来。数据量增1
m_pArry[m_nNum++] = nData_;
}
// 下面使用的例子
int main()
{
// 需要什么类型就写什么类型,不管什么类型这个容器都能使用
MyArry<int> intArry;
MyArry<double> doubleArry;
for (int i = 0; i < 10; i++)
{
intArry.push(i);
doubleArry.push(i + 0.1);
}
}
写到这,容器的基本功能就快完成了,接下来解决另一个问题—动态分配。刚刚已经提到,我们现在的写法是,每一次 push 一个数据,就需要完成以下步骤:开辟新空间、拷贝数据、释放空间,能不能空间不够的时候,多开辟一些空间呢,这样就不用每次都重复这个步骤,答案当然是可以的,请看以下代码:
double g_nNewSpaceRatio = 1.5 // 每次开辟新空间的倍数
template<class T>
class MyArry{
prvite:
T* m_pArry; // 指向内存段,用于记录存储数据的首地址
int m_nNum; // 用于记录当前数据的数量
int m_nMaxSize; // 用于记录当前容器能容纳的最大元素个数
public:
MyArry()
{
m_pArry = NULL;
m_nNum = 0;
m_nMaxSize = 5; // 默认值先给个5
}
~MyArry()
{
if(m_pArry != NULL)
{
delete[] m_pArry;
m_pArry = NULL;
}
}
public:
void push(const T& nData_); // 插入数据 const T& nData_ 这种写法更安全
void pop(); // 删除数据
};
void MyArry<T>::push(const T& nData_)
{
// 没有申请过空间时申请空间
if (m_pArry == NULL)
{
T* pNew = new T[m_nMaxSize];
}
// 表明空间不够用了,需要开辟新的空间
if (m_nNum > m_nMaxSize)
{
// 1 扩大m_nMaxSize为原来的g_nNewSpaceRatio倍;这里g_nNewSpaceRatio取1.5
m_nMaxSize = (int)(m_nMaxSize * g_nNewSpaceRatio);
// 2 开辟新空间
T* pNew = new T[m_nMaxSize];
// 3 拷贝原有数据并释放原内存
memcpy(pNew, m_pArry, sizeof(T*) * m_nNum;
delete[] m_pArry;
// 4 m_pArry指向新开的内存
m_pArry = pNew;
}
// 添加新数据进来。数据量增1
m_pArry[m_nNum++] = nData_;
}
PS:上面的代码还有不完美的地方,只是为了说明过程,缺少一些保护机制,比如m_nMaxSize
应该开辟内存成功后再更新,开辟内存失败的处理等等。
结束
作为一个容器,上面写的还远远不够,只是简单介绍了一下插入数据的功能。
下面一些 vector 待完善的点:
- 无参构造函数
- 有参构造函数
- 拷贝构造函数
- 迭代器
- reserve
- resize
- insert
- erase
- size
- capacity
- front
- clear
以后再慢慢写吧!先更新到这~
[!CAUTION] 第一次写文章,有错误或者建议欢迎在评论区指出!
想更深入了解 vector 可以看看以下博主的文章:
C++ vector函数接口及其底层原理:http://t.csdn.cn/plaSu
2023/07 第二次更新
之前存在没写全的功能已补充,具体代码可见 github 仓库:
https://github.com/DesperateBreaker/MySTL.git