本次我们将自己实现一个简单的顺序表,以及其中部分接口,但是与C阶段的顺序表实现不同,本次我们将采用模板的方式来实现,也就是说这个顺序表可以同时适配多种类型。
但是类模板声明定义分离的时候一定要都放在一个文件中去,否则会导致链接错误。这个问题的具体原因我们后面有一节模板进阶会主要讲解。
1. vector基本结构
我们将一个类的基本结构其实就是它的成员变量,这个东西是一个类的骨架,之后的成员函数都是这个类的血肉,将整个类描绘的更漂亮更实用。
如果按照我们之前的思路写出来的基本结构应该是这样的
T*作为一个数组用来存数据,_size控制有多少有效成员,_capacity控制总空间大小。
但是当我们查看STL库中vector的源码后发现这些成员都是用迭代器控制起来的,因此本次实现我们也使用更接近源码的迭代器
先给上缺省值,更方便我们写构造和拷贝构造的初始化列表。
这里我们用三个迭代器控制了整个vector,比起旧思路成员变量与vector之间有了更强的关联性。_start迭代器指向数据开始的地方,_finish(图中写错了)指向所有有效数据的下一个位置,_end_of_storage指向整个空间结束的地方。
2. 析构
3. 访问操作
3.1 operator[ ]
这种 [ ] 访问的方式只有string类和顺序表存在,这得益于它们独特的连续内存结构
3.2 迭代器
得益于vector的成员变量的结构,这个迭代器实现方法也异常简单
4. 扩容操作
扩容前我们先做好准备操作,能够取到容量和有效数据个数。
扩容的实现我们先判断是否有必要扩容。有必要扩容的话,我们先开一个临时的空间用来存储旧空间的内容,这块临时空间的大小是新空间所需的大小,然后让_start指向这块新空间就好了。
思路虽说是这么个思路,但是实现上却有坑。
这里的 size() ,计算的时候是用的 旧_finish - 新_start,这就出大问题了啊,这剪出来的根本不对劲。所以给出的第一个解决办法是先更新 _finish 再更新 _start ,这样就是用的 旧 _finish - 旧_start,算出来的size()就没有问题了。
但是这么做不利于代码的维护,万一后面有人不知道这块的坑,又将更新顺序换回去了,那代码就出bug了,所以我们有了第二种方案,提前记忆 size() 的大小。
这样直接记忆 旧 _finish - 旧_start 的大小,之后更新_finish的时候直接用就好了,这样也保持了一个较为习惯的更新顺序。
5. 尾插尾删
插入数据之前首先就要判断剩余空间够不够用,不够要扩容。
尾删太简单,不说了。
6. 随机位置的插入删除
6.1 随机位置插入
思路还是先判断是否需要扩容,然后从后向前把每个数据都向后挪一位,直到把pos位置空出来。
但是这么写是有很大问题的,一旦扩容之后,_start指向了一块新的空间,但是pos还是指向旧位置中,那么此时的pos就很像是一个野指针,那么我们管这种现象叫 迭代器失效 ,那么解决办法就是再扩容的时候把pos也移动到新空间的对应位置。
但仅仅是这样,这个成员函数还尚未完成。
6.1.1 算法库中的find
之前的string类中有 find() 成员函数,用来查找字符或字符串,但是在其他容器中并没有find这个成员函数,那我们如何查找某个数据呢。事实上,在算法库<algorithm>中有一个用迭代器实现的fin()函数
官网资料:find - C++ Reference
这个算法库中的 find函数 需要我们传两个迭代器用来画出查找区间,然后再传一个查找的内容就好了,注意迭代器画出的区间永远是要画成左闭右开的。
返回范围 [first,last) 中第一个等于 val 的元素的迭代器。如果没有找到这样的元素,函数将返回last迭代器。
那么到这里我们整体测试一下我们写出来的东西。
我们用库中的find 和 我们自己写的insert很好的配合,在小写的 c 前加上了一个大写的 A ,之所以库中的find还可以适配我们自己写的类和迭代器,就是因为这个find是用模板生成的,这就是模板的好处。
可能有同学已经看到我那里标注了一个外部迭代器失效。没错,如果 insert函数 触发了扩容操作,那么我们的迭代器 it 就失效了,因为它还指着曾经的那块空间,但是扩容之后所有的数据都来到了新的空间。如果此时我们想 *it 访问一下这块空间那程序就会崩掉。
所有的扩容操作都可能会导致迭代器的失效,那我们为了规避风险,统一将这种情况下的迭代器视为已经失效的迭代器,因此不再使用它。
那如果说就是有这方面的需求,一定要用这个迭代器怎么办,我们STL中的vector给出了一个解决方案,在 insert函数 中给出更新这个迭代器的机会。
将新的pos迭代器返回,我们 insert 之后提取这个新pos的返回值,就可以继续用这个迭代器了。
这样更新迭代器之后我们又可以接着用这个迭代器了,当然,如果不想接收这个更新值也行,就是之后不要再用这个已经失效的迭代器了。
6.2 随机位置删除
这个操作看起来很简单,只需要找到 pos迭代器 的下一位置,然后把后面的内容依次向前覆盖一位就好了。
但事实上并不是这样的,erase 虽然没有 insert 扩容导致迭代器失效的问题,但它可能会因为缩容而导致迭代器失效。就比如有的编译器认为一次性删除顺序表中一半以上的的内容的时候就要缩容了,那么此时就可能因为缩容而导致迭代器失效。当然,我这个代码只删一个数据,不会有这种情况,但是人家正经的库函数是可以用迭代器画出一个范围来删除的。这里还要提示一下,VS下erase是坚决不会缩容的。
当然,还有一种比较好理解的情况导致迭代器失效,就是我们选择的pos是顺序表的最后一个有效数据,那么此时删除了它之后,pos 就指向了_finish 或者说空位置,此时迭代器失效。那么库函数为了防止迭代器可能失效后,用户还想无视风险继续访问,采取了和insert类似的解决办法,再返回一个有效的迭代器。不过~
insert返回的是第一个新插入元素的迭代器。
erase返回的是删除的最后一个元素的下一个位置的迭代器。如果操作擦除了序列中的最后一个元素,那就返回容器的结尾 _finish,当然这个位置也是失效的。
不过因为我们的iterator是用指针直接模拟的,写法不对,所以无法在自创的代码中正确模拟出删除后的效果,即使模拟出来了也是吃了我们的erase只能删一个数据的巧合
我们测试一下这个代码
我们成功的将 1 删除了,并且删除后返回的正是它的下一数据 2 的迭代器。
7. 构造
7.1 拷贝构造
之所以到这里才说拷贝构造的原因就是现在的深拷贝有更好的写法了
我们先一把开好新容器的所有空间,避免 push_back() 的时候频繁扩容消耗过大,之后再用一个范围for尾插旧容器到新容器中,完成深拷贝
7.2 默认构造
但此时写完拷贝构造之后我们的程序就用不了了,因为现在的vector没有了默认构造函数。回顾一下类的构造函数如果不写,会自动生成,如果写了任何一种拷贝,就将不会再自动生成任何种类的构造函数。我们刚写的拷贝构造也是构造,因此写完之后电脑不自动生成构造函数了,此时我们自己写的vector类就缺少了默认构造函数。
之前啥都不写能生成出构造函数,得益于我们在写成员变量时给的 nullptr 缺省值,让自动生成的构造函数的初始化列表可以使用,因此才能自动生成出可以使用的默认构造。
那这里我还是不像写默认构造,还是想让编译器自动生成,此时有一种新写法
这样可以让编译器强制生成默认构造
7.3 迭代区间构造
迭代区间构造的用法并不是简单的用 一个vector<int>的某段迭代区间,去构造另一个 vector<int>,这中用法太落伍了,还不如直接拷贝构造呢。
迭代区间构造真正牛的地方在于它是一个模板函数,也就是说传任何类型迭代区间都可以用来构造这个 vector<int>。举个例子:我可以通过这个构造函数,把一个链表list中的数据构造到顺序表vector中去。
7.4 n个val构造
这个构造函数的逻辑很简单,但是真正有意思的是它第二个参数的缺省值 T()
T()是匿名对象,它用来给 val 缺省值的意义就是,当我们只给顺序表大小 n 的时候,T()可以通过 T 的默认构造函数进行给这n个数据初始化。以全局的视角来讲,就是在构造这个vector的时候,只要你设置好了模板所使用的类型,也就是T是什么类型,比如 T 是一个日期类 Date ,那么在构造的时候,只需要输入n的大小,选择构造多大的vector,其中的数据会调用 Date 的默认构造函数进行自动初始化。
讲到默认构造函数,在C++中,为了应对上面这种情况,即使是内置类型也拥有了自己的构造函数和默认构造函数,内置类型的初始化可以不拘泥于曾经的赋值了。
可以通过上面代码中 c 的值看出int的默认构造是将变量初始化成 0,那么我们就可以搞一下
可以看到所有 10 个数据都被 int 的默认构造初始化成 0 了,这就是缺省值 T() 的作用
7.4.1 构造间的冲突
此时完成了 迭代区间构造 和 n个val构造 ,但是他俩目前是有冲突的。我们这么构造一下:
可以看到它报了一个非法间接寻址的错,并且跳转进了迭代区间构造的函数中去了,而我们的本意是构造装有10个2的顺序表。
这是因为迭代区间构造是用模板写的,这么写的画当然在参数为(int, int)时,比(size_t, const T&)的构造更适配,所以就会跳转到更适配的模板函数中,而int又不支持*first解引用,因此就报了非法简介寻址的错。
解决也没有太好的办法,库函数也是重载了一下(int, int)参数类型的n个val构造,保证比模板函数更适配
7.5 initializer_lish 构造 C++11
可能有同学见过上图这样初始化出来的容器。乍一看毫无道理,容器如何可以接收随机数量的数据再构造。事实上这用到C++11中的概念(我们现在都在基于C++98的定义讲解,除了像这样的个例),我们看C++11中的vector构造:
正是这个 initializer_list 支持了我们这么构造,这是一种参数列表,其本质是一个模板类
官网资料:vector::vector - C++ Reference
这个类就是用来收录一组相同数据用的,然后这一组数据可以拿去构造容器们,我们还可以使用typeid()来打印一下这种东西的类型看一看。
这个initializer_list是个类,它也支持迭代器和size接口
因此我们使用initializer_list构造的函数就可以这么写
8. operator= 赋值重载
赋值重载我们还是使用那个超简单的现代写法完成,准备好一个交换函数,然后利用传值传参的拷贝构造特性,将形参v当成临时变量进行交换,这样还省下了我们销毁这个临时变量v的代码量。
9. 类似vector<string>扩容时出现的浅拷贝问题
这个问题是我们自己写的这个vector类的最后一个,并且是相当重要的一个问题,我们看这样一段代码。
我们尾插4个string的时候都没啥问题,但是到尾插第5个的时候程序崩了,这个能打印出来是个意外,我的VS版本是2022版的,太强大了,2019版的打印出来的都是乱码。
言归正传,这个问题出现的原因就在于扩容中,我们逐步来分析,走读代码
首先目前容器 v 中存了4个string,空间已经满了,第五次的 push_back() 让它必须要去开空间,开到容量为 8 ,开空间就要去调用reserve函数,于是乎reserve函数中新开出来了一个大小为8的临时容器tmp,并在new中完成了初始化,因此tmp中出现了8个string的结构,但是还没有内容。
下一步进到问题发生地 memcpy ,这是一个浅拷贝,只是将旧空间的变量值都拷贝了过来,也就是说,现在 tmp中的string 与 v中的string 指向了同一片空间
然后 delete[ ] 掉_start,就是说释放了v容器,但是delete是一个深度释放的过程,发现容器 v 中的是自定义类型,于是再调用自定义类型的析构函数,完全释放掉自定义类型,也就是说,此时这些字符串都不复存在了。那么tmp中的string也就都指向了空的空间,出现问题了。
这也就是为什么尾插到第五个元素的时候会崩溃的原因,其实不止是string,只要vector中存放的是一个需要指向别的空间的自定义类型,都会出现这种问题,比如vector<vector<T>> 、vector<list<T>>等。
那么解决这个问题我们就要在移动数据时使用深拷贝。
我们最后再验证一下5个string能不能尾插了
很明显可以正常跑了,之后在重点讲解C++11的篇章中,我们还会有另一种解决办法,就是移动拷贝。
10. 完整代码
vector.h
#include<assert.h>
#include<iostream>
#include<algorithm>
using namespace std;
namespace atl
{
template<class T>
class vector
{
public:
typedef T* iterator;
typedef const T* const_iterator;
//默认构造
vector() = default;
//拷贝构造
vector(const vector<T>& v)
{
reserve(v.capacity());
for (auto& e : v)
{
this->push_back(e);
}
}
//迭代区间构造
template <class InputIterator>
vector(InputIterator first, InputIterator last)
{
while (first != last)
{
push_back(*first);
++first;
}
}
//n个val构造
vector(size_t n,const T& val = T())
{
reserve(n);
for (size_t i = 0; i < n; i++)
{
push_back(val);
}
}
vector(int n, int val = int())
{
reserve(n);
for (size_t i = 0; i < n; i++)
{
push_back(val);
}
}
//initializer_lish 构造 C++11
vector(initializer_list<T> il)
{
reserve(il.size());
for (auto& e : il)
{
push_back(e);
}
}
void swap(vector<T>& v)
{
std::swap(_start, v._start);
std::swap(_finish, v._finish);
std::swap(_end_of_storage, v._end_of_storage);
}
//operator=
vector<T>& operator=(vector<T> v)
{
this->swap(v);
return *this;
}
//析构
~vector()
{
if (_start)
{
delete[] _start;
_start = _finish = _end_of_storage = nullptr;
}
}
//迭代器
iterator begin()
{
return _start;
}
iterator end()
{
return _finish;
}
const_iterator begin() const
{
return _start;
}
const_iterator end() const
{
return _finish;
}
T& operator[](size_t i)
{
assert(i < size());
return _start[i];
}
size_t capacity() const
{
return _end_of_storage - _start;
}
size_t size() const
{
return _finish - _start;
}
//扩容
void reserve(size_t n)
{
if (n > capacity())//如果确实有需要再阔
{
//在前面处理上一个oldsize
size_t oldsize = size();
T* tmp = new T[n];
if(_start)
{
for (int i = 0; i < oldsize; i++)
{
tmp[i] = _start[i];
}
delete[] _start;
}
_start = tmp;
_finish = tmp + oldsize;
_end_of_storage = _start + n;
}
//坚决不缩容
}
//尾插
void push_back(const T& x)
{
//如果容量不够就扩容
if (_finish == _end_of_storage)
{
size_t newcapacity = capacity() == 0 ? 4 : capacity() * 2;
reserve(newcapacity);
}
//现在容量够了
*_finish = x;
++_finish;
}
//尾删
void pop_back(const T& x)
{
assert(size() > 0);
--_finish;
}
//随机位置插入
iterator insert(iterator pos, const T& x)
{
assert(pos >= _start && pos <= _finish);
if (_finish == _end_of_storage)
{
//防止pos迭代器失效
size_t len = pos - _start;
size_t newcapacity = capacity() == 0 ? 1 : capacity() * 2;
reserve(newcapacity);
pos = _start + len;
}
//从后向前挪动
iterator end = _finish - 1;
while (end >= pos)
{
*(end + 1) = *end;
--end;
}
*pos = x;
++_finish;
return pos;
}
//随机位置删除
iterator erase(iterator pos)
{
assert(pos >= _start && pos < _finish);
iterator it = pos + 1;
while (it < _finish)
{
*(it - 1) = *it;
++it;
}
--_finish;
return pos;
}
private:
iterator _start = nullptr;
iterator _finish = nullptr;
iterator _end_of_storage = nullptr;
};
}