C++ vector 模拟实现(一):从三个指针开始,理解动态数组的骨架
前言
上一篇我们梳理了 vector 的常用接口和避坑点,但“会用”和“懂”之间还差一层——底层实现。
很多人背过“vector 底层是三个指针”,但被追问“为什么是三个指针而不是 size+capacity 两个变量”“指针相减为什么能得到 size”时就卡壳了。本文作为模拟实现系列的开篇,先搭好 vector 的骨架:成员变量设计、构造、析构、容量接口和迭代器。骨架立住了,后续的增删查改就是往上填肉。
本文所有代码均可直接运行,建议边看边手敲一遍。
一、为什么是三个指针?
先看结论,vector 的成员变量设计如下:
template<typenameT>classvector{public:typedefT*iterator;typedefconstT*const_iterator;private:iterator _start;// 指向数据块的起始位置iterator _finish;// 指向最后一个有效元素的下一个位置iterator _end_of_storage;// 指向已分配内存的末尾};1.1 三指针 vs size+capacity
有读者会问:用T* _data; size_t _size; size_t _capacity;不是更直观吗?为什么 STL 要用三个指针?
核心原因有三个:
① 迭代器天然就是指针
vector 的迭代器本质上就是原生指针(T*)。begin()返回_start,end()返回_finish,这是 O(1) 且零成本的。如果用 size+capacity,begin 需要返回_data,end 需要返回_data + _size,虽然也不复杂,但三指针的设计让迭代器和成员变量完全统一。
② 指针相减直接得到 size,无需额外存储
size_tsize()const{return_finish-_start;}size_tcapacity()const{return_end_of_storage-_start;}指针相减是编译器原生支持的运算,不占额外内存。而 size+capacity 方案需要两个size_t(各 8 字节),在 64 位平台上反而更占空间。
③ 扩容时指针更新更自然
扩容时需要申请新空间、拷贝数据、释放旧空间。三指针方案下,只需重新赋值三个指针;size+capacity 方案则需要同时维护数据指针和两个计数值,出错概率更高。
一句话总结:三指针设计让迭代器、容量计算、内存管理三者统一,代码更简洁,空间更省。
二、迭代器与容量接口
有了三个指针,容量相关的接口就是一行代码的事:
public:// ========== 迭代器 ==========iteratorbegin(){return_start;}iteratorend(){return_finish;}const_iteratorbegin()const{return_start;}const_iteratorend()const{return_finish;}// ========== 容量 ==========size_tsize()const{return_finish-_start;}size_tcapacity()const{return_end_of_storage-_start;}boolempty()const{return_start==_finish;}注意这里提供了const 版本和非 const 版本的 begin/end。const 对象调用时返回const_iterator,保证不能通过迭代器修改元素。这是 C++ 中常见的 const 重载技巧。
此时可以写个简单测试验证一下:
vector<int>v;cout<<v.size()<<" "<<v.capacity()<<" "<<v.empty()<<endl;// 输出:0 0 1但此时还没有构造函数,_start等指针是未初始化的野指针,直接调用 size() 会得到垃圾值。所以下一步必须写构造函数。
三、构造函数:从零开始
3.1 默认构造
默认构造要做的唯一一件事:把三个指针置空。
vector():_start(nullptr),_finish(nullptr),_end_of_storage(nullptr){}使用初始化列表而不是在函数体内赋值,是因为指针是内置类型,初始化列表才是真正的“初始化”,函数体内是“赋值”。虽然对指针来说差别不大,但养成好习惯。
3.2 填充构造:n 个 val
vector(size_t n,constT&val=T()):_start(nullptr),_finish(nullptr),_end_of_storage(nullptr){reserve(n);// 先开够空间for(size_t i=0;i<n;i++){push_back(val);// 逐个构造}}这里有两点需要说明:
① 为什么用 push_back 而不是直接赋值?
因为 vector 存储的可能是自定义类型(如 string),内存分配后这块空间是未初始化的原始内存,不能直接_start[i] = val,必须通过拷贝构造来初始化对象。push_back 内部会调用拷贝构造,是安全的做法。
②const T& val = T()是什么?
这是 C++ 的默认参数写法,T()会调用 T 的默认构造函数。对 int 来说T()就是 0,对 string 就是空串。这样vector<int> v(5);就会得到 5 个 0。
⚠️注意:此时 push_back 和 reserve 还没实现,文章后面会补上。这里先建立“构造 = 开空间 + 构造元素”的思路。
3.3 拷贝构造(深拷贝)
拷贝构造是最容易踩坑的地方。先看错误写法:
// ❌ 错误:浅拷贝vector(constvector<T>&v):_start(v._start),_finish(v._finish),_end_of_storage(v._end_of_storage){}这样写会导致两个 vector 指向同一块内存。当其中一个析构时释放了内存,另一个就变成了悬空指针,再次访问或析构就会崩溃(double free)。
正确写法:传统写法
vector(constvector<T>&v):_start(nullptr),_finish(nullptr),_end_of_storage(nullptr){reserve(v.capacity());// 开同样大的空间for(constauto&e:v){push_back(e);// 逐个深拷贝}}更简洁的现代写法(后续讲到赋值重载时会重点讲):
vector(constvector<T>&v):_start(nullptr),_finish(nullptr),_end_of_storage(nullptr){vector<T>tmp(v.begin(),v.end());// 用迭代器区间构造临时对象swap(tmp);// 交换指针}现代写法依赖迭代器区间构造和 swap,我们后面再实现,这里先掌握传统写法。
3.4 迭代器区间构造
这个构造函数非常通用,可以从数组、其他容器、甚至 vector 自身的区间构造:
template<classInputIterator>vector(InputIterator first,InputIterator last):_start(nullptr),_finish(nullptr),_end_of_storage(nullptr){while(first!=last){push_back(*first);++first;}}为什么用模板而不是直接写const T*?
因为这样不仅能接受指针,还能接受其他容器的迭代器(如list<int>::iterator),通用性更强。
四、析构函数
析构要做两件事:释放元素 + 释放内存。
~vector(){if(_start){// 1. 先析构所有有效元素(对自定义类型必须)for(size_t i=0;i<size();i++){_start[i].~T();}// 2. 再释放整块内存delete[]_start;}_start=_finish=_end_of_storage=nullptr;}为什么不能只 delete[]?
delete[] _start会释放内存,但对于自定义类型(如 string),它不会调用每个元素的析构函数(因为_start是T*,delete[] 只对“真正的数组”负责)。所以必须手动循环调用析构。
对 int、double 这类内置类型,.~T()是空操作,没有额外开销。
注意:这里用
delete[]而非delete,因为内存是通过new T[]分配的(后续扩容时会看到)。两者必须配对,否则是未定义行为。
五、reserve:扩容的核心
reserve是整个 vector 性能的关键,也是迭代器失效的根源。先看实现:
voidreserve(size_t n){if(n>capacity()){// 只有 n 大于当前容量才扩容size_t oldSize=size();// 记录旧 size(关键!)T*tmp=newT[n];// 1. 申请新空间// 2. 拷贝旧元素到新空间if(_start){for(size_t i=0;i<oldSize;i++){tmp[i]=_start[i];// 拷贝赋值}delete[]_start;// 3. 释放旧空间}// 4. 更新三个指针_start=tmp;_finish=tmp+oldSize;_end_of_storage=tmp+n;}}这段代码有几个极其关键的细节:
5.1 为什么先保存 oldSize?
如果先更新_start = tmp,那么size()计算的是_finish - _start,而_finish还没更新,结果就是负数或巨大值,循环会出错。所以必须先保存旧的 size。
5.2 为什么不用 memcpy?
很多初学者会写memcpy(tmp, _start, oldSize * sizeof(T)),这在存储内置类型时没问题,但对自定义类型是灾难:
vector<string>v;v.push_back("hello");v.push_back("world");v.reserve(10);// 如果内部用 memcpymemcpy 是按字节拷贝,会把 string 对象内部的指针原样复制。结果是新旧两个 string 的指针指向同一块堆内存。当旧 vector 析构时,这块内存被释放,新 vector 里的 string 就成了悬空指针,后续访问直接崩溃。
正确做法是用拷贝赋值(tmp[i] = _start[i]),它会调用 string 的赋值运算符,完成真正的深拷贝。
结论:只要 T 不是 trivially copyable 的类型,就绝不能用 memcpy。
5.3 扩容倍数
标准库实现的扩容倍数:VS 是 1.5 倍,GCC 是 2 倍。模拟实现时我们通常简化为 2 倍。为什么是指数增长?因为这样可以把 n 次 push_back 的总拷贝次数控制在 2n 以内,均摊复杂度为 O(1)。如果固定增长(如每次 +10),总拷贝次数是 O(n²)。
六、push_back:串起一切
最后实现 push_back,把上面所有东西串起来:
voidpush_back(constT&x){// 1. 检查是否需要扩容if(_finish==_end_of_storage){size_t newCapacity=capacity()==0?4:capacity()*2;reserve(newCapacity);}// 2. 在 _finish 位置构造元素*_finish=x;++_finish;}扩容策略说明:
- 空 vector 首次插入,分配 4 个空间(避免频繁小扩容)
- 之后每次按 2 倍增长
注意*_finish = x是拷贝赋值。严格来说这块内存还没构造对象,应该用 placement new,但对于大多数类型,拷贝赋值也能工作。真正的 STL 会用 allocator 的 construct 函数,这个我们放到进阶篇讲。
七、完整代码与测试
把上面所有代码整合起来:
#include<iostream>#include<string>usingnamespacestd;namespacemy_vector{template<typenameT>classvector{public:typedefT*iterator;typedefconstT*const_iterator;// ========== 构造 / 析构 ==========vector():_start(nullptr),_finish(nullptr),_end_of_storage(nullptr){}vector(size_t n,constT&val=T()):_start(nullptr),_finish(nullptr),_end_of_storage(nullptr){reserve(n);for(size_t i=0;i<n;i++)push_back(val);}template<classInputIterator>vector(InputIterator first,InputIterator last):_start(nullptr),_finish(nullptr),_end_of_storage(nullptr){while(first!=last){push_back(*first);++first;}}vector(constvector<T>&v):_start(nullptr),_finish(nullptr),_end_of_storage(nullptr){reserve(v.capacity());for(constauto&e:v)push_back(e);}~vector(){if(_start){for(size_t i=0;i<size();i++)_start[i].~T();delete[]_start;}_start=_finish=_end_of_storage=nullptr;}// ========== 迭代器 ==========iteratorbegin(){return_start;}iteratorend(){return_finish;}const_iteratorbegin()const{return_start;}const_iteratorend()const{return_finish;}// ========== 容量 ==========size_tsize()const{return_finish-_start;}size_tcapacity()const{return_end_of_storage-_start;}boolempty()const{return_start==_finish;}voidreserve(size_t n){if(n>capacity()){size_t oldSize=size();T*tmp=newT[n];if(_start){for(size_t i=0;i<oldSize;i++)tmp[i]=_start[i];delete[]_start;}_start=tmp;_finish=tmp+oldSize;_end_of_storage=tmp+n;}}// ========== 增 ==========voidpush_back(constT&x){if(_finish==_end_of_storage){reserve(capacity()==0?4:capacity()*2);}*_finish=x;++_finish;}// ========== 访问 ==========T&operator[](size_t i){return_start[i];}constT&operator[](size_t i)const{return_start[i];}private:iterator _start;iterator _finish;iterator _end_of_storage;};}// namespace my_vector// ========== 测试 ==========intmain(){my_vector::vector<int>v;v.push_back(1);v.push_back(2);v.push_back(3);v.push_back(4);v.push_back(5);cout<<"size="<<v.size()<<" capacity="<<v.capacity()<<endl;// 输出:size=5 capacity=8(第5个元素触发扩容到 8)for(size_t i=0;i<v.size();i++)cout<<v[i]<<" ";cout<<endl;// 输出:1 2 3 4 5// 测试拷贝构造(深拷贝)my_vector::vector<int>v2(v);v2[0]=100;cout<<"v[0]="<<v[0]<<" v2[0]="<<v2[0]<<endl;// 输出:v[0]=1 v2[0]=100(互不影响,证明深拷贝成功)// 测试自定义类型my_vector::vector<string>vs;vs.push_back("hello");vs.push_back("world");for(auto&s:vs)cout<<s<<" ";cout<<endl;// 输出:hello worldreturn0;}运行结果:
size=5 capacity=8 1 2 3 4 5 v[0]=1 v2[0]=100 hello world总结
本文搭好了 vector 的骨架,核心要点回顾:
| 要点 | 结论 |
|---|---|
| 成员变量 | 三指针_start/_finish/_end_of_storage |
| 容量计算 | 指针相减,O(1) 且零额外空间 |
| 构造 | 默认构造置空指针;填充构造用 push_back 保证正确初始化 |
| 拷贝构造 | 必须深拷贝,否则 double free |
| 析构 | 先循环析构元素,再 delete[] 释放内存 |
| reserve | 先保存 oldSize,用拷贝赋值而非 memcpy,指数扩容 |
| push_back | 满则扩容,未满则赋值并移动 _finish |
下篇预告:骨架有了,接下来实现 vector 的完整增删查改——insert、erase、resize、pop_back,重点讲清楚insert/erase 的迭代器失效问题以及返回值设计。这是面试和实战中最容易出错的部分,敬请期待。
如果这篇帮你搞懂了三个指针的设计,欢迎点赞 + 收藏。有任何疑问欢迎评论区交流,我会逐条回复。