目录
前言
重要接口模拟实现
默认成员函数
1.构造函数
2.析构函数
3.拷贝构造函数
4.赋值运算符重载
迭代器
简单接口
1.size()
2.capacity()
3.swap()
操作符重载
1.操作符[]
扩容接口
1.reserve()
2.resize()
增删查改接口
1.push_back()
2.pop_back()
3.insert()
4.erase()
迭代器失效问题
1.问题及解决
重载调用问题
1.问题及解决
后记
在模拟完string类之后,下一个我们来模拟实现的是STL中的vector类,相当于c语言中的顺序表,在一些接口的实现上可以参考顺序表的实现,所以这篇文章是在讲解vector的重要接口,一些普通接口不过多赘述。
根据STL库里的vector,成员变量有_start、_finish、_end_of_storage,是三个指针变量,分别指向第一个数据的位置地址、最后一个数据的下一个位置地址、总申请空间的下一个位置地址,实则vector是一个类模板,实现为tempale
构造函数有多种重载形式,包括普通无参的默认构造函数、传迭代器区间构造函数、用n个值构造函数,
①无参的构造函数很简单,将三个指针变量置空即可,在初始化列表可以,在函数体内实现也可;
②定义函数模板InputIterator,而不直接使用iterator?因为可以传入不同类型的迭代器区间进行构造,不局限于类模板中的数据类型,这里也是复用的方式用尾插数据;
注意:一定要在初始化列表置空成员变量,因为pushback一开始肯定会reserve,释放空间delete遇到未初始化指针会报错。
③用n个值构造也很好理解,传入值的个数和值,循环尾插值即可,其中,这里T()是匿名对象,若不传值进来,就会调用默认构造初始化val,(相当于int类型不传值就默认是0,指针不传值就默认是nullptr),如果T是自定义类型能理解,如果是内置类型,难道内置类型也有默认构造函数?yes!比如:int a = int(),此时a是0 。
注意:使用 n个值构造函数构造时,需要加上下面代码中最后一个重载,这涉及到重载调用问题,在文章最后会介绍到。
代码:
//无参构造函数
Vector()
: _start(nullptr)
, _finish(nullptr)
, _end_of_storage(nullptr)
{
}
//传迭代器区间拷贝
template
Vector(InputIterator first, InputIterator last)
: _start(nullptr)
, _finish(nullptr)
, _end_of_storage(nullptr)
{
while (first != last)
{
push_back(*first);
++first;
}
}
//n个值构造
Vector(size_t n, const T& val= T())
: _start(nullptr)
, _finish(nullptr)
, _end_of_storage(nullptr)
{
reserve(n); //reserve里有delete,需要先在初始化列表置空
for (size_t i; i < n; i++)
{
push_back(val);
}
}
Vector(int n, const T& val = T())
: _start(nullptr)
, _finish(nullptr)
, _end_of_storage(nullptr)
{
reserve(n);
for (int i = 0; i < n; i++)
{
push_back(val);
}
}
对应的析构函数也不难,因为后面插入数据肯定是要申请空间的,所以在析构函数中需要去释放,也就是_start指向的地址空间,释放之后将三个指针置空即可。
代码:
~Vector()
{
delete[] _start;
_start = _finish = _end_of_storage = nullptr;
}
对于拷贝构造函数,与模拟实现string时差不多,除了使用传统写法,是不是可以尝试使用现代写法呢?
先看传统写法,很好理解,先开辟相同大小的地址空间,再将数据拷贝过去,然后将成员变量指向正确的位置,其中,size()是返回元素个数,capacity()是返回容量,值得注意的是,拷贝数据时不能使用memcpy,因为是值拷贝,如果T是自定义类型,就会在析构时出错,而要用赋值的方式(每一次赋值都是深拷贝);
复用的方法也很简单,将原对象的数据循环尾插到目标对象,也要注意,在范围for那里传引用,因为里面的元素可能是自定义类型(比如string),那么传值给e,又是一次深拷贝,代价很大,所以建议用引用传值;
现代写法:使用传迭代器构造函数定义一个临时对象,再使用swap函数(下面有介绍)交换两个对象的成员,而临时对象作为局部变量,出了作用域自动调用析构函数销毁,完美拷贝构造了目标对象。
代码:
//传统方法
Vector(const Vector& v)
{
_start = new T[v.capacity()];
//memcpy(_start, v._start, sizeof(T) * v.size());
for (size_t i = 0; i < v.size(); i++)
{
_start[i] = v._start[i];
}
_finish = _start + v.size();
_end_of_storage = _start + v.capacity();
}
//复用的方式
Vector(const Vector& v)
: _start(nullptr)
, _finish(nullptr)
, _end_of_storage(nullptr)
{
reserve(v.size());
for (const auto& e : v)
{
push_back(e);
}
}
//现代写法
Vector(const Vector& v)
{
Vector tmp(v.begin(), v.end());
swap(tmp);
}
赋值运算符重载的实现不多赘述,参考拷贝构造函数的现代写法。
代码:
Vector& operator=(const Vector& v)
{
Vector tmp(v.begin(), v.end());
swap(tmp);
return *this;
}
vector的迭代器也是原生指针,是里面存放的数据类型的指针,与string一样,可以参考http://t.csdn.cn/dYgNp ,同时也要加上const对象可以调用的迭代器。
代码:
typedef T* iterator;
typedef const T* const_iterator;
iterator begin()
{
return _start;
}
iterator end()
{
return _finish;
}
const_iterator begin() const
{
return _start;
}
const_iterator end() const
{
return _finish;
}
size()是指数据元素的个数,因为_start指向第一个数据的位置地址、_finish是最后一个数据的下一个位置地址,所以元数个数就是_finish - _start 。
代码:
size_t size() const
{
return _finish - _start;
}
capacity()是指所申请空间的个数,因为_start指向第一个数据的位置地址,_end_of_storage是总申请空间的下一个位置地址,所以元数个数就是_end_of_storage- _start 。
代码:
size_t capacity() const
{
return _end_of_storage - _start;
}
由于stl库里algorithm中的swap是深拷贝,会将两个对象的变量连同地址空间一块交换,代价较大,所以一般情况下,每个自定义类型要有自己的swap,交换过程则是复用stl里的swap函数,仅交换指针的指向。
代码:
void swap(Vector& v)
{
std::swap(_start, v._start); //这里仅是交换这两个指针的指向
std::swap(_finish, v._finish);
std::swap(_end_of_storage, v._end_of_storage);
}
由于vector底层就是个数组,所以stl库里也是提供[]加下标访问元素,实现与string中的[]操作符重载一致,不再赘述。
代码:
T& operator[](size_t pos)
{
assert(pos < size());
return _start[pos];
}
const T& operator[](size_t pos) const
{
assert(pos < size());
return _start[pos];
}
实际上,reserve()的实现也是参考string的模拟实现可以写出,注意点已在下方代码中标记,注意即可。
代码:
void reserve(size_t cap)
{
if (cap > capacity())
{
size_t ts = size();
iterator tmp = new T[cap];
//memcpy(tmp, _start, sizeof(T) * ts); //也不能用memecpy
for (size_t i = 0; i < ts; i++)
{
tmp[i] = _start[i];
}
delete[] _start;
_start = tmp;
_finish = _start + ts; //这里不能使用size(),因为此时start位置已经更新,应该使用旧size(),即ts
_end_of_storage = _start + cap;
}
}
resize()比reserve()多个初始化,即不仅要修改_end_of_storage的指向,还要修改_finish的指向,实现过程中,注意好三种情况:①要求容量比实际容量大;②要求容量比实际容量小,但比元素个数大;③要求容量比元素个数小
代码:
void resize(size_t cap, const T& val = T())
{
size_t ts = size();
if (cap > capacity())
{
reserve(cap);
for (size_t i = 0; i < capacity() - ts; i++) //注意:不能是capacity()-size(),因为size()会变化
{
push_back(val);
}
}
else
{
if (cap > ts)
{
for (size_t i = 0; i < cap - ts; i++)
{
push_back(val);
}
}
else
{
_finish = _start + cap;
}
}
}
push_back ()即尾插,传参最好是引用传参,否则有可能T是string等类型,深拷贝代价特别大,实现简单,不再赘述。
代码:
void push_back(const T& t)
{
if (_finish == _end_of_storage)
{
reserve(capacity() ? capacity() * 2 : 4);
}
*_finish = t;
_finish++;
}
pop_back()即尾删,删除最后一个元素,直接_finish指针--即可。
代码:
void pop_back()
{
assert(_finish > _start);
_finish--;
}
在stl库里的insert()实现中,形参是插入位置的迭代器和需插入元素,返回插入元素的迭代器,根据string中insert的实现逻辑,这里的insert() 也是很容易实现出来,但这不是重点,值得注意的是会出现迭代器失效问题,文章最后会介绍并且解决。
代码:
iterator insert(iterator pos, const T& t)
{
assert(pos >= _start);
assert(pos <= _finish);
if (pos == _finish)
{
push_back(t);
return _finish;
}
if (_finish == _end_of_storage)
{
size_t pos_len = pos - _start; //①
reserve(capacity() ? capacity() * 2 : 4);
pos = _start + pos_len; //②
}
iterator i = _finish;
while (i > pos) //③
{
*i = *(i - 1);
--i;
}
*pos = t;
++_finish;
return pos;
}
erase()则是删除所传入迭代器的元素,规定返回所删除位置的下一个位置的迭代器,实现逻辑参考string模拟实现也不难写出,这里讨论删除一定数量的元素是否应该缩容?
答:不建议缩容,因为缩容是以时间换空间,效率低下,如果涉及到缩容,就有可能引发迭代器失效问题(看了后面介绍就知道为什么会引发),但也是建议不要删除pos位置的数据之后,再次访问pos迭代器,因为排除不了其他库对erase()的实现没有缩容。
代码:
iterator erase(iterator pos)
{
assert(pos >= begin());
assert(pos < end());
if (pos == end() - 1)
{
pop_back();
return end();
}
iterator i = pos;
while (i < _finish - 1)
{
*i = *(i + 1);
i++;
}
--_finish;
return pos;
}
在上面的insert()、erase()实现中都有提到一个迭代器失效问题,是什么呢?先说结论:在insert()、erase()函数中,不要直接访问pos,访问了要更新,不然会出现意料之外的结果,这就是迭代器失效。看看下面三种情况:
情况一(野指针的失效):扩容/缩容之后pos就会失效。见代码一,这是insert实现的一段代码,当需要扩容时,调用reserve函数删除旧空间,开辟新空间,就会导致pos指针所指向的空间被释放,进而导致③处进入死循环。
解决:加上①②处语句,即记录原本pos距离开头的位置,reserve回来之后更新pos位置即可。情况二:调用insert()之后,再次使用pos,此时pos可能因为扩容变了,或者插入了数据之后变了,导致失效。看下面代码二,使用了stl中的insert()的一段,看上去很正确,但执行就会报错,如下图:
而将标记处注释掉,就可以正常运行,如图:
说明:若insert函数中发生扩容,释放旧空间开辟新空间后,pos指向空间释放或者指向新位置,但由于是一份拷贝,不会影响实参,所以外面的pos没有发生变化,当再次使用pos时就会失效,那么这里为什么insert函数不传pos的引用呢?
答:首先库里没用,其次就是会有后患,比如再次调用insert(v.begin(),80);其中begin()函数返回迭代器的拷贝,具有常性,无法成为insert函数实参传引用到insert函数里。解决:这是一个固有问题,无法解决,只能避免这种情况,即在pos位置插入数据之后,不要再访问迭代器pos,因为pos可能已经失效。
情况三:循环调用insert()、erase()函数(比如删除所有的偶数)时,注意控制去遍历的迭代器变量,控制不好就会发生失效,如下代码三,错误实现会报错:
正确与错误实现的关键区别在于:erase函数会返回所删除元素的下一个位置的迭代器,不需要再次it++,而没调用erase函数才需要it++。
解决:insert()、erase()函数都会返回新的迭代器,在使用这两个函数时一定要注意控制迭代器。
代码一:
if (_finish == _end_of_storage)
{
size_t pos_len = pos - _start; //①
reserve(capacity() ? capacity() * 2 : 4);
pos = _start + pos_len; //②
}
iterator i = _finish;
while (i > pos) //③
{
*i = *(i - 1);
--i;
}
代码二:
int main()
{
Vector v1;
v1.push_back(1);
v1.push_back(2);
v1.push_back(3);
v1.push_back(4);
v1.push_back(5);
Vector::iterator pos = find(v1.begin(), v1.end(), 3);
if (pos != v1.end())
{
v1.insert(pos, 80);
//再次访问迭代器pos
cout << *pos << endl; //标记处
}
for (auto e : v1)
{
cout << e << " ";
}
cout << endl;
return 0;
}
代码三:
//正确
int main()
{
Vector v1;
v1.push_back(1);
v1.push_back(2);
v1.push_back(3);
v1.push_back(4);
//v1.push_back(5);
auto it = v1.begin();
while (it != v1.end())
{
if (*it % 2 == 0)
{
it = v1.erase(it);
}
else
{
++it;
}
}
return 0;
}
//错误
int main()
{
Vector v1;
v1.push_back(1);
v1.push_back(2);
v1.push_back(3);
v1.push_back(4);
//v1.push_back(5);
auto it = v1.begin();
while (it != v1.end())
{
if (*it % 2 == 0)
{
it = v1.erase(it);
}
++it;
}
return 0;
}
介绍:当有多个重载形式时,调用此函数要格外小心,因为会调用形参与实参最匹配的重载形式,导致没能调用到目标函数,而产生意料之外的结果。
eg:当使用n个值构造函数构造时,会发现Vector
v(10, 1)会报错,但Vector v(10, 'a')不会报错,因为什么?
原因:对于多个重载形式,调用时编译器会用参数匹配度最高的函数,Vectorv4(10, 1)中的实参类型相同且都是int,所以会调用传迭代器区间构造函数,而不是n个值构造函数,因为10和1都是int类型,正好符合first和last(又不是一定要传迭代器),而n个值构造函数是size_t和int类型,不够符合,从而进入传迭代器区间构造函数,而函数中有个*first,即对int类型解引用,所以会报间接寻址的错,而Vector v4(10, 'a')由于参数类型不同,正好匹配n个值构造函数,所以不会报错。 解决:重载下方代码中的第二个构造函数(Vector(int n, const T& val = T()) ),传两个int类型时,就会调用此构造函数,因为比传迭代器区间拷贝函数匹配度更高。
代码:
//n个值构造
Vector(size_t n, const T& val= T())
: _start(nullptr)
, _finish(nullptr)
, _end_of_storage(nullptr)
{
reserve(n); //reserve里有delete,需要先在初始化列表置空
for (size_t i; i < n; i++)
{
push_back(val);
}
}
Vector(int n, const T& val = T())
: _start(nullptr)
, _finish(nullptr)
, _end_of_storage(nullptr)
{
reserve(n);
for (int i = 0; i < n; i++)
{
push_back(val);
}
}
在模拟实现string的基础上,模拟出大部分vector的接口函数并不难,难以捉摸的是此外出现的问题,而模拟实现vector类的重点也正是如此,要不然也并不值得作为一篇博客,希望能够抓住重点,理解vector的重点接口实现以及衍生问题的解决,多多练习,拜拜!