1.前言
在前面的学习中,我们已经学习了C++中的类和对象,以及string类的底层实现。string主要帮助我们理解:
一个类如何管理动态资源。
而今天学习的vector,其实也是同样的问题。相信大家在C++中一定经常使用:
vector<int> v;例如:
vector<int> v; v.push_back(1); v.push_back(2); v.push_back(3);相比静态数组:数组大小一旦确定,就无法改变。
int arr[100];vector最大的特点就是:
空间可以动态增长。
而vector可以根据数据量自动扩容。但是vector真正值得学习的地方,并不是这些接口。重点在于:
- vector底层如何管理空间?
- 为什么扩容、删除之后迭代器会失效?
- 为什么vector不能简单使用memcpy拷贝?
- 深拷贝和浅拷贝到底有什么区别?
这些才是vector这一章真正需要掌握的内容。
2.vector是什么?
2.1 vector的介绍
vector是STL中的一个容器。官方定义:
vector是表示可变大小数组的序列容器。
在我的理解上,vector本质上就是:动态数组。它和普通数组一样:
v[0],v[1],v[2]可以通过下标访问。但是不同的是:数组:
int arr[10];空间固定,而vector空间可以动态增长。与我们上一期学的string的关系:两者设计思想高度一致,都具有size()、capacity()、reserve()、resize()、push_back()等接口,可以将string看作是面向字符的特殊动态序列,但二者也有些许不同,哪里呢,让我们一探究竟。
2.2 vector底层结构
vector底层其实并不复杂。它主要通过三个指针管理空间:
T* _start; T* _finish; T* _endofstorage;分别表示:
_start | ↓ [1][2][3][4][ ][ ][ ] ↑ ↑ finish endofstorage其中:
_start表示数据开始位置。_finish表示有效数据结束位置。_endofstorage表示空间结束位置。
千万不要把有效数据和空间结束位搞混了,这在编译的过程中经常容易犯错。
有效数据个数其实就是:
size = _finish - _start容量就是:
capacity = _endofstorage - _start3.vector的简单使用
3.1 vector的构造和遍历
构造方式:支持默认构造、指定元素个数和值构造、区间构造,其中STL的区间均遵循左闭右开[first, last)的规则。
例如:
vector<int> v1; vector<int> v2(5, 1); vector<int> v3(v2); int a[] = {1, 2, 3, 4}; vector<int> v4(a, a + 4);这里的最后一个是迭代器区间构造,区间构造非常常用,尤其在数组、字符串、其他容器之间转换时。迭代器提供了统一的方式去访问容器,这个在list会介绍,它很重要。vector的遍历也是常见的三种:下标遍历、迭代器遍历以及称为“语法糖”的范围for遍历,其实for的底层是迭代器我们也已经说过了。
3.2 迭代器遍历
有提供begin和end两个接口,begin是指向第一个元素的位置,end是指向最后一个元素的下一个位置,那么end()能不解引用?不能,不是有效元素的位置哦。至于rbegin()和rend()就是反过来,前者是最后一个元素的位置,后者是第一个元素的前一个位置。其他两个范围for和下标遍历其实不用过多赘述了,范围for中要修改的话就要加&。
3.3 vector常用接口
掌握这些先,后面还有swap、clear等等,这里附上文档链接,可去官方文档查查,对英文要求也不很高。
| 接口 | 作用 |
|---|---|
| push_back() | 尾插 |
| pop_back() | 尾删 |
| size() | 获取有效数据个数 |
| capacity() | 获取容量 |
| reserve() | 扩容 |
| resize() | 改变size |
| operator[]() | 下标访问 |
| insert() | 在某个位置插入元素 |
| erase() | 删除某个位置的元素 |
例如:
#include<vector> using namespace std; int main() { vector<int> v; v.push_back(1); v.push_back(2); v.push_back(3); for(size_t i = 0; i < v.size(); i++) { cout << v[i] << " "; } return 0; }输出:
4.vector扩容机制
4.1 vector最大的特点:
空间不足时会自动扩容。
例如:
vector<int> v; v.push_back(1); v.push_back(2); v.push_back(3); v.push_back(4); v.push_back(5);如果空间不足(假设初始空间只有四个)
旧空间 [1][2][3][4] 申请新空间 [1][2][3][4][ ][ ][ ][ ][ ] 释放旧空间所以扩容实际上包含:
- 检测空间是否不足
- 开辟新空间
- 拷贝旧数据
- 释放旧空间
- 修改指针
这里有一个问题:
既然扩容需要重新搬移数据,那么vector为什么还要预留空间?
你想想如果每插入一个数据就扩容一次,效率会低得一批。所以C++规定一般会:
一次申请较大的空间 ↓ 不断插入 ↓ 直到空间不足 ↓ 再次扩容4.2 reserve和resize区别
这两个接口非常容易混。reserve,只扩个容:
vector<int> v; v.reserve(100);但:
v.size()仍然是:
0resize,才改变有效元素个数:
v.resize(10);不仅开空间,还会初始化元素。
4.3 和string不同,能代替string吗?
首先,先说答案,不能。试问一下如果真的代替了,虽然C++有向前兼容性,但是string能一直保留必定有它的独特之处。原因我觉得有这两方面:
- string作为特意处理字符串数组的结构,它在一个字符串后会自动加'\0',这是vector所没有的,虽然可以有vector<string>,但总归还是不太方便,它不会自动填末尾的斜杠0。
- string接口丰富,有比较大小、+=,但是vector不用。
5.vector模拟实现(重要部分)
我们简单实现一个vector,其实说简单也不会简单,有一些坑,因为vector要进行内存管理!这四个字意味着必须要考虑深拷贝,核心成员:
template<class T> class vector { private: T* _start; T* _finish; T* _endofstorage; };5.1 push_back实现
如果vector内部使用memcpy。那么:
v1中的string ↓ hello ↑ v2中的string两个对象共享资源,析构时,第一个string:
delete[] _str;释放空间。第二个string再次delete释放同一块空间。
结果:
double free程序崩溃。所以vector扩容和拷贝时不能使用memcpy,而应该调用对象自己的赋值,它会去调用operator=()
tmp[i]=_start[i];因为:
string自己的赋值运算符会完成深拷贝。
8.vector的深拷贝
vector自身也存在拷贝问题。例如:
vector<int> v1; vector<int> v2(v1);如果默认拷贝:
v1._start ↓ 一块空间 v2._start ↓ 同一块空间两个vector管理同一块资源。析构时同样会出现问题。所以vector需要自己实现拷贝构造。
8.1 深拷贝实现
vector(const vector<T>& v) { _start=new T[v.capacity()]; for(size_t i=0;i<v.size();i++) { _start[i]=v._start[i]; } _finish=_start+v.size(); _endofstorage=_start+v.capacity(); }核心
创建新的空间,再复制数据。
9.vector的赋值运算
赋值:
v1=v2;由于也是要开辟空间进行内存管理,所以同样存在浅拷贝问题。现代写法:
vector<T>& operator=(vector<T> v) { swap(v); return *this; }这里参数是传值,而不是传引用,一旦调用a=b,编译器就会自动拷贝一份(深拷贝)b的副本给v,然后进入swap函数体,a和v交换,相当于a变成b的内容,v变成a的内容,返回时,v作为临时副本,把a的旧数据释放掉,析构。
10.vector迭代器失效问题(在insert,erase)
vector迭代器本质:
T*也就是指针,如果发生扩容或者删除操作:
旧空间释放 ↓ 原来的指针失效例如:
auto it=v.begin(); v.push_back(10); cout<<*it; for(size_t i=0; i<=v.size();i++) { v[i - 1] = v[i]; }这里可能崩溃。
it ↓ 旧空间但是旧空间已经释放。所以vector中以下操作可能导致迭代器失效:
- insert
- erase
10.1 vector的模拟实现全部代码参考附上
namespace wj { template<class T> class vector { public: 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; } // vector<int> v(10, 1); // vector<string> v1(10, "1111"); vector(size_t n, const T& val = T()) { resize(n, val); } vector(int n, const T& val = T()) { resize(n, val); } // [first, last) template<class InputIterator> vector(InputIterator first, InputIterator last) { while (first != last) { push_back(*first); ++first; } } vector(const vector<T>& v) { _start = new T[v.capacity()]; for (size_t i = 0; i < v.size(); i++) { _start[i] = v._start[i]; } _finish = _start + v.size(); _endofstorage = _start + v.capacity(); } void swap(vector<T>& v) { std::swap(_start, v._start); std::swap(_finish, v._finish); std::swap(_endofstorage, v._endofstorage); } // v1 = v2 vector<T>& operator=(vector<T> v) { swap(v); return *this; } ~vector() { if (_start) { delete[] _start; _start = _finish = _endofstorage = nullptr; } } // void reserve(size_t n) { if (n > capacity()) { size_t sz = size(); T* tmp = new T[n]; if (_start) { for (size_t i = 0; i < sz; i++) { tmp[i] = _start[i]; } delete[] _start; } _start = tmp; _finish = _start + sz; _endofstorage = _start + n; } } void resize(size_t n, const T& val = T()) { if (n < size()) { _finish = _start + n; } else { reserve(n); while (_finish != _start + n) { *_finish = val; ++_finish; } } } void push_back(const T& x) { /*if (_finish == _endofstorage) { size_t newcapacity = capacity() == 0 ? 4 : capacity() * 2; reserve(newcapacity); } *_finish = x; ++_finish;*/ insert(end(), x); } void pop_back() { erase(--end()); } size_t capacity() const { return _endofstorage - _start; } size_t size() const { return _finish - _start; } T& operator[](size_t pos) { assert(pos < size()); return _start[pos]; } iterator insert(iterator pos, const T& x) { assert(pos >= _start && pos <= _finish); if (_finish == _endofstorage) { size_t len = pos - _start; size_t newcapacity = capacity() == 0 ? 4 : capacity() * 2; reserve(newcapacity); // 解决pos迭代器失效问题 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 _endofstorage = nullptr; };11.最后
vector这一章看似是在学习一个STL容器。但是实际上学习的是:
一个管理动态资源的类应该如何设计。
vector最重要的不是:
push_back() erase() insert()这些接口。真正重要的是:
vector ↓ 动态开辟空间 ↓ 扩容 ↓ 资源迁移 ↓ 拷贝问题 ↓ 浅拷贝 ↓ 深拷贝 ↓ 对象管理资源memcpy这个问题一定要理解。对于内置类型memcpy没有问题。但是对于:
string vector这种管理资源的对象:不要用memcpy
不要认为能复制内存就一定能复制对象。
C++中真正安全的拷贝,应该让对象自己完成资源管理。
这也是我们学习vector模拟实现最大的意义。后面会学习list,这里很考验对迭代器的理解,学了list后会对类模板以及封装有另一层意境的理解。
感谢大家的观看阅读,如果这篇文章能够给你们带来哪怕一点点收获和解决困惑,那对我而言都是一种动力和成就感。如果文章对你有帮助,欢迎点赞 👍、收藏 ⭐、评论 💬 支持一下!