前言
STL 容器是 C++ 开发中绕不开的'神兵利器',而 vector 作为最常用的动态数组容器,更是新手入门 STL 的核心内容。但多数时候,我们只是'会用',却对它的底层逻辑一知半解 —— 比如它如何动态扩容? 的内存管理是怎样的?构造函数的匹配规则为何如此复杂?
模拟实现 C++ STL 中的 vector 容器,涵盖动态数组底层原理、内存管理(指针_start/_finish/_endofstorage)、默认成员函数(构造、拷贝、赋值、析构)、迭代器机制及常见接口(size、capacity、reserve、resize、push_back、insert、erase、pop_back)。重点解析扩容策略、浅拷贝问题处理、迭代器失效场景(扩容野指针、逻辑错位)及解决方案,帮助深入理解 C++ 容器底层逻辑。

前言
STL 容器是 C++ 开发中绕不开的'神兵利器',而 vector 作为最常用的动态数组容器,更是新手入门 STL 的核心内容。但多数时候,我们只是'会用',却对它的底层逻辑一知半解 —— 比如它如何动态扩容? 的内存管理是怎样的?构造函数的匹配规则为何如此复杂?
vectorpush_back与其停留在'黑盒调用'的层面,不如亲手模拟实现一个 vector:从底层的指针管理(_start/_finish/_endofstorage),到核心接口(push_back/resize/operator[]),再到构造、拷贝等特殊函数的实现,一步步揭开 STL 容器的面纱。
本文不会纠结过于晦涩的标准细节,而是以'实用、易懂'为核心,带你用 C++ 手动实现一个具备基础功能的 vector—— 既能加深对容器原理的理解,也能锻炼 C++ 的底层编程能力。
简单说,vector 是 C++ 里可以动态变大小的数组—— 它既保留了普通数组'连续存储、下标访问高效'的优点,又解决了普通数组'大小固定、不能自动扩容'的痛点。
它的底层逻辑是动态分配的数组:
当你往 vector 里加新元素时,它会自动管理内存 —— 如果当前数组存满了,就会重新分配一块更大的空间,把旧元素全搬过去,再继续存新元素(不过这个'搬家'操作开销不低,所以 vector 会提前多分配一些额外空间,避免频繁搬家)。
它的特点可以总结成'优劣势分明':
**优势:**访问元素(用下标)、在末尾增删元素,效率都很高;
**劣势:**在中间 / 开头增删元素,效率很低(因为要挪动大量元素);
对比其他容器(比如
list):vector的访问更快,但灵活增删不如list。
namespace ayj // 避免与标准库 std::vector 命名冲突 {
template <class T>
class vector {
public:
// 迭代器类型定义:vector 的迭代器本质是原生指针(因底层连续内存)
typedef T* iterator; // 普通迭代器:支持读写元素
typedef const T* const_iterator; // 常量迭代器:仅支持读元素,不可修改
private:
// -------------------------- 核心指针(迭代器) --------------------------
// 1. 指向容器中第一个有效元素的起始位置
// 空容器时为 nullptr,有效元素范围:[_start, _finish)
iterator _start = nullptr;
// 2. 指向容器中最后一个有效元素的下一个位置(尾后迭代器)
// 空容器时为 nullptr,_finish - _start = 有效元素个数(size)
iterator _finish = nullptr;
// 3. 指向容器已分配内存空间的最后一个位置的下一个位置
// 空容器时为 nullptr,_endofstorage - _start = 总容量(capacity)
// 当_finish == _endofstorage 时,容器满,插入元素需扩容
iterator _endofstorage = nullptr;
};
}
这就是 vector 的核心底层结构 —— 通过三个指针(_start/_finish/_endofstorage)管理连续内存,既实现了数组的高效访问,又能动态调整容量。
/* ===================== 构造函数 ===================== */
// 空构造:初始化空容器,无元素
vector() : _start(nullptr) , _finish(nullptr) , _endofstorage(nullptr) {}
// 填充构造:创建 n 个值为 val 的元素
vector(size_t 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;
}
}
讲一个特殊的场景:
按道理来说我们是想用 10 个 1 构造一个 vector 这没啥问题吧,那为啥会编译不通过那,而且报的错误还是和迭代器范围构造相关的
这是因为 vector<int> v(10, 1) 会误匹配此版本(10/1 被当作 InputIterator),此时 *first 会把 10 当作指针解引用,触发未定义行为(崩溃)
这个时候有两种办法解决这个问题:
方法 1:
将 10 显式指定为 size_t 类型(写成 10u),这样编译器会优先匹配填充构造函数(而非范围构造函数),从而正常创建包含 10 个值为 1 的元素的 vector。
方法 2:
新增一个以 int 类型为参数的填充构造函数重载版本,让 vector<int> v(10, 1) 这类传入 int 型数值的场景,能直接匹配该重载函数,避免编译器误匹配范围构造函数。
//vector<int> v(10, 1)
vector(int n, const T& val = T()) {
resize(n, val);
}
C++ 函数匹配规则:
/*=====================拷贝构造传统写法========================*/
vector(const vector<T>& v) :
_start(nullptr), _finish(nullptr), _endofstorage(nullptr) {
_start = new T[v.capacity()]; //浅拷贝 - 容器存储的是有动态资源的自定义类型,使用 memcpy 就会造成浅拷贝
//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();
_endofstorage = _start + v.capacity();
}
/*=====================拷贝构造现代写法========================*/
vector(const vector<T>& v) :
_start(nullptr), _finish(nullptr), _endofstorage(nullptr) {
reserve(v.capacity());
for (size_t i = 0; i < v.size(); i++) {
push_back(v[i]);
}
}
先来说传统写法:先给数组开辟对应空间,然后进行数据拷贝,最后更新 _finish 和 _endofstorage 指针。
这里需要注意:拷贝数据不能用 memcpy。因为如果数据类型是带有动态资源的自定义类型(比如 string),用 memcpy 拷贝会引发浅拷贝问题—— 它只会复制指针的值,不会复制指针指向的动态资源,最终导致多个对象共用同一块内存空间。
这个解决方案其实很简单:直接通过赋值操作完成拷贝即可。因为对于 string 这类自定义类型,赋值时会自动调用其赋值运算符重载函数,从而实现内部动态资源的深拷贝;而对于 int、double 等内置类型,赋值就是简单的数值拷贝,不会有任何额外影响。
/*=====================赋值重载现代写法========================*/
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;
}
operator= 的参数是值传递(vector<T> v),会自动调用拷贝构造函数生成实参的临时副本;
借助 std::swap 交换当前对象和临时对象的内部指针,临时对象出作用域时会自动释放原当前对象的旧资源,避免内存泄漏;
返回 *this 以支持连续赋值(如 v1 = v2 = v3),符合赋值运算符重载的规范。
/*=====================赋值重载传统写法========================*/
vector<T>& operator=(vector<T>& v) {
T* tmp = new T[v.capacity()];
for (size_t i = 0; i < v.size(); i++) {
tmp[i] = v._start[i];
}
delete[] _start;
_start = tmp;
_finish = _start + v.size();
_endofstorage = _start + v.capacity();
return *this;
}
这段赋值重载传统写法,先开辟临时空间深拷贝数据,再释放当前对象旧空间,最后更新指针并返回当前对象引用,实现安全且正确的深拷贝赋值。
~vector() {
if (_start) {
delete[] _start;
_start = _endofstorage = _finish = nullptr;
}
}
/*==========================迭代器=============================*/
iterator begin() {
return _start;
}
iterator end() {
return _finish;
}
const_iterator begin() const {
return _start;
}
const_iterator end() const {
return _finish;
}
迭代区间始终遵循 [begin, end) 的规则,以下是迭代器 begin() 与 end() 的边界定位示意图:
(图注:begin() 指向容器的第一个有效元素,end() 指向最后一个有效元素的下一个位置)
反向迭代器我们先暂时放一放,等后面学到 list 的反向迭代器时,vector 的反向迭代器写法也就自然掌握了。
size_t size() const {
return _finish - _start;
}
size_t capacity() const {
return _endofstorage - _start;
}
bool empty() const {
return size() == 0;
}
/*==========================扩容接口============================*/
void reserve(size_t n) {
if (n > capacity()) {
size_t sz = size();
T* tmp = new T[n];
if (_start) {
//浅拷贝 -- 如果存储的是带动态资源的自定义类型将就会有浅拷贝问题
//memcpy(tmp, _start, sizeof(T) * sz);
//每次调用赋值重载解决了浅拷贝问题
for (size_t i = 0; i < sz; i++) {
tmp[i] = _start[i];
}
delete[] _start;
}
_start = tmp;
//_finish = _start + size() 错误代码;因为 size() 中的_start 和_finish 指的不是同一块空间
_finish = _start + sz;
_endofstorage = _start + n;
}
}
该接口用于为 vector 预留至少 n 个元素的存储空间,仅当 n 大于当前容器容量时才执行扩容操作;扩容时先记录当前有效元素个数,开辟 n 个大小的临时空间,通过循环调用元素赋值重载完成深拷贝(解决自定义类型动态资源的浅拷贝问题),随后释放原空间,更新容器的_start、_finish、_endofstorage 指针,使容器容量更新为 n,且保留原有全部有效元素,有效避免频繁扩容带来的性能损耗。
扩容接口有两个特殊问题:
1、自定义类型动态资源的浅拷贝问题:当容器存储的是带动态资源的自定义类型时,直接用 memcpy 拷贝会导致浅拷贝,这一问题的解决思路在赋值运算符重载和拷贝构造的内容中已有说明,此处不再赘述;
2、必须提前用 sz 记录当前有效元素个数:因为扩容后原空间会被释放,_start 指针会指向新空间,若此时再调用 size() 获取元素个数,会因 _start 和 _finish 不再指向原空间而计算错误,所以需要在扩容前先把当前元素个数存到 sz 变量中。
/*=========================开空间 + 填值(resize 接口)===========================*/
void resize(size_t n, const T& val = T()) {
// 情况 1:n 小于当前有效元素个数,执行截断操作(保留前 n 个元素,后面的元素失效)
if (n < size()) {
// 直接调整_finish 指针,指向第 n 个元素的下一个位置,实现逻辑截断(物理空间仍保留原有容量)
_finish = _start + n;
}
// 情况 2:n 大于等于当前有效元素个数,执行扩容 + 填值操作
else {
// 调用 reserve 接口预留 n 个元素的空间,reserve 内部会判断 n 是否大于当前容量,仅需扩容时才会执行实际内存分配
reserve(n);
// 循环填充:从当前_finish 位置开始,到_start+n 位置结束,将新增空间填充为指定 val 值
while (_finish != _start + n) {
*_finish = val; // 给当前有效元素末尾的新位置赋值
_finish++; // 移动_finish 指针,扩大有效元素范围
}
}
}
这段 resize 接口用于调整 vector 的有效元素个数:
第二个参数的缺省值是调用默认构造,内置类型由于加入了模板,也有了自己的默认构造
当 n 小于当前元素数时,直接截断(仅调整 _finish 指针,保留前 n 个元素);
当 n 更大时,先调用 reserve 预留空间,再将新增位置填充为指定值,最终更新 _finish 指针,既支持缩小容器,也能安全扩容并填充元素。
void push_back(const T& x) {
// 判断是否需要扩容:当有效元素末尾指针等于容量末尾指针时,说明当前内存已满
if (_finish == _endofstorage) {
// 计算新容量:若当前容量为 0 则初始化为 4,否则扩容为原来的 2 倍(常用扩容策略)
size_t newcapacity = capacity() == 0 ? 4 : capacity() * 2;
// 调用 reserve 接口完成扩容,预留新容量空间
reserve(newcapacity);
}
// 给当前有效元素末尾赋值(尾插核心操作)
*_finish = x;
// 移动有效元素末尾指针,扩大有效元素范围
++_finish;
}
//指定位置插入
iterator insert(iterator pos, const T& x) {
assert(pos >= _start && pos <= _finish);
//扩容
if (_finish == _endofstorage) {
//记录 pos 相对位置
size_t len = pos - _start;
size_t newcapacity = capacity() == 0 ? 4 : capacity() * 2;
reserve(newcapacity);
//解决内部迭代器失效问题
pos = _start + len;
}
iterator end = _finish - 1;
while (end >= pos) {
*(end + 1) = *end;
end--;
}
*pos = x;
++_finish;
//解决外部迭代器失效问题 - 指向新插入元素的位置
return pos;
}
insert 的参数 pos 在扩容时,原内存空间会被销毁回收,此时 pos 指向的旧地址会失效并变为野指针;而 (*pos)++ 对这个野指针进行了解引用操作,最终导致程序崩溃。
那我们该如何解决这个问题呢?
我标记的这两步是为了解决 pos 迭代器在函数内部失效的问题,通过记录扩容前 pos 和_start 的相对位置以便于在扩容后更新 pos 的位置,让其指向新空间的当前位置
返回更新后的 pos,解决外部迭代器失效问题。
第一个调用 v.insert(pos, 5):传的是 pos 变量(左值迭代器),引用传参能直接修改外部 pos,所以没问题;
第二个调用 v.insert(v.begin(), 5):传的是临时迭代器(右值),临时数据自带'常性'(不可修改),而引用传参要求修改它,这属于'权限放大',因此会编译失败。
若未触发扩容:插入点之后的迭代器全部失效(元素后移导致指向错位,属于逻辑错位);
// 指定位置删除元素
iterator erase(iterator pos) {
// 断言:确保 pos 迭代器合法(在有效元素区间内),且 vector 非空,防止非法访问
assert(pos >= _start && pos < _finish && size() > 0);
// 定义迭代器 it,从 pos 的下一个元素开始遍历
iterator it = pos + 1;
// 循环:将 pos 之后的所有元素依次向前移动一位,覆盖待删除元素(实现逻辑删除)
while (it != _finish) {
*(it - 1) = *it; // 后一个元素的值赋给前一个元素,完成前移覆盖
it++; // 迭代器后移,处理下一个元素
}
// 尾指针前移一位,缩小有效元素范围(底层内存未释放,仅标记该位置元素无效)
--_finish;
// 返回原 pos 迭代器(此时 pos 指向被删除元素的下一个有效元素,避免外部迭代器失效)
return pos;
}
情况 1:删除单个偶数(巧合没报错)
情况 2:删除连续偶数(迭代器失效导致跳过元素)
这个没删干净哦~,这是因为在删除第一个 2 后,这时候 it 指向第二个 2,但是 it++ 跳过了
情况 3:删除末尾偶数(迭代器失效导致崩溃)
删除末尾偶数会触发死循环,当遍历到末尾偶数 6 并执行 erase(it) 后,it 迭代器失效且与更新后的 vector 尾指针重合,后续的 it++ 无法正常推进迭代器,导致循环条件 it != v.end() 永远成立,循环无法退出而陷入死循环,部分运行环境中也可能因失效迭代器操作触发内存越界崩溃。
综上所述,上述所有问题(巧合未报错、跳过元素、死循环 / 崩溃),本质都是错误的遍历删除逻辑导致的迭代器失效:代码直接在
erase后执行it++,没有利用erase返回的有效迭代器更新it,使得失效的迭代器参与后续操作,进而引发未定义行为(结果随机、逻辑错乱、程序异常)。正确的写法应是用
erase的返回值更新迭代器(删除元素时不执行it++,仅在未删除时推进),避免使用失效的旧迭代器,才能保证代码的正确性与稳定性。
核心结论:对 vector 执行 erase 或 insert 操作后,原迭代器必然失效(可能因内存销毁成野指针,或因元素移位逻辑错位),此时再访问该迭代器的结果是未定义的(可能巧合运行、跳过元素、死循环甚至崩溃)。
解决方案:若需继续操作容器,必须用 erase/insert 返回的新有效迭代器替代旧迭代器 —— 这是 C++ 标准规定的唯一安全方式,确保每次操作都基于容器当前的有效状态。
简言之:旧迭代器作废,用返回的新迭代器续接操作。
不同编译器对失效迭代器的访问处理存在差异:比如在 VS 编译器中,一旦使用了失效的迭代器,程序会直接报错。
//尾删
void pop_back() {
erase(--end());
}
/*====================[]运算符重载=====================*/
// 可读可写版本:返回非 const 引用,支持修改元素值
T& operator[](size_t pos) {
assert(pos < size()); // 断言确保访问的下标合法,不越界
return _start[pos]; // 返回对应位置元素的引用,支持读和写
}
// 仅读版本:const 成员函数,返回 const 引用,禁止修改元素值
const T& operator[](size_t pos) const {
assert(pos < size()); // 断言确保访问的下标合法,不越界
return _start[pos]; // 返回对应位置元素的 const 引用,仅支持读操作
}
容器的 [] 访问多了逻辑 size 约束;
要求访问下标必须小于容器的 size(已初始化的有效元素数量),而普通数组仅受物理内存范围限制,无逻辑有效性检查。
vector<int> v;
v.reserve(10);
for (int i = 0; i < 10; i++) {
v[i] = i;
}
reserve(10) 仅预留内存,vector 的 size 仍为 0,直接用 v[i] = i([] 下标访问运算符重载规定,i < size)属于越界访问未初始化内存,会导致未定义行为(如崩溃)。
正确做法:用 resize(10) 初始化 10 个元素(size=10),再下标赋值;或 reserve 后用 push_back 添加元素。
vector 自身并没有提供 find 接口,因此如果需要查找元素,要调用算法库中的 find 接口来实现。
template <class InputIterator, class T>
InputIterator find(InputIterator first, InputIterator last, const T& val) {
while (first != last) {
if (*first == val) {
return first;
}
first++;
}
return last;
}

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online