很多人一搜“vector”,会同时搜出来两个完全不相干的东西:一个是C++标准库里的动态数组容器,另一个是做汽车总线工具的那家公司。如果你点进这篇内容,是想搞清楚前者,也就是动态数组(类似vector)的简易实现,那我们就聊到一块去了。
这个标题说的“简易实现”,不是让你从零复刻整个STL。而是把vector最核心的机制——动态扩容、拷贝管理、随机访问——用最少的代码自己搭一遍。做完之后你会发现两件事:一是以后再用std::vector,你会很清楚它底层到底干了什么;二是面试时再被问“vector扩容时发生了什么”,你不再是背书,而是真的见过这段代码。
我下面写的这套实现,适合已经会C++基本语法、但还没怎么碰过内存管理和模板的读者。我会把每一段代码掰开揉碎讲,也会把那些不跑一遍根本发现不了的坑一起说了。
1. 固定数组的三大痛点:为什么非动态不可
先回到最原始的场景:你写了一个成绩管理系统,需要存一个班50个人的分数。C风格数组直接写int scores[50],完事。但问题是,现实里“一个班”从来不是固定的。转学生来了呢?期中考试变成机考需要多存一个字段呢?你预先写成50,后面变成51,整个程序就得跟着改。
这就是固定数组的第一个痛点:容量在编译期就定死了,运行期改不了。你只能在“给多了浪费”和“给少了不够”之间赌。给多了,内存白白占着;给少了,程序直接越界写坏相邻内存,那种bug最恶心,往往不是你写错的那一行报错,而是跑了几百行之后在一个毫无关系的函数里崩掉。
第二个痛点是长度信息不会自己跟着走。C风格的数组传入函数之后,sizeof就失效了,你必须额外传一个len参数。稍微粗心一点,末尾忘加'\0',或者循环边界写成<=,就是经典的off-by-one。不是不能写,是每次都要小心,写多了总会翻车。
第三个痛点是插入和删除很笨重。固定数组中间插入一个元素,要把后面所有元素往后挪;删除一个,要把后面所有元素往前挪。挪动本身没问题,问题是容量不会变,你没法真正“插入”,只能覆盖。想在原数组上新增一个元素但后面已经没有空位时,唯一的办法是手动new一块更大的内存,把旧数据拷过去,再释放旧内存。这个操作每次都要手写,而手写一次,就是一次double free或者内存泄漏的机会。
所以动态数组的核心诉求就一句话:让数组自己管理容量,长度和容量分开记录,装不下就自动换一个更大的房子。std::vector做的是这件事,我们自己实现的简易动态数组,做的也是这件事。搞清楚这一点,你再看它的源码就不会觉得神奇了——无非是三个指针加上一段内存管理的逻辑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前的设计决策:一个动态数组类要管好哪几件事
在写第一行代码之前,我建议先想清楚数据结构和类接口。直接开写容易写到一半发现设计拧巴了。
标准库vector的经典表示用了三个指针:
cpp复制T* start_; // 指向数组起始位置
T* finish_; // 指向最后一个有效元素的下一个位置
T* end_of_storage_; // 指向当前分配的内存块的末尾
为什么不直接记size和capacity两个整数,而要记指针?因为指针本身就能做减法。finish_ - start_就是size,end_of_storage_ - start_就是capacity,不需要额外存两个成员。更重要的是,迭代器在vector里就是原生指针,begin()直接返回start_,end()返回finish_,这套设计和STL的迭代器模型严丝合缝。我们自己实现时也用指针,后面想扩展迭代器就非常自然。
三个指针之间的位置关系永远满足:
code复制start_ <= finish_ <= end_of_storage_
从左往右依次是:已构造的有效元素区、空闲预留区、未分配内存区。画成图就是:
code复制| 已用元素区 | 预留区 | 未分配区 |
^ ^ ^
start_ finish_ end_of_storage_
finish_和end_of_storage_之间的部分,是容量大于size的那部分空间,还没装元素,但内存已经申请下来了。
接下来要定两个设计问题:
第一个是扩容策略。当finish_ == end_of_storage_,也就是数组满了的时候,怎么办?最直觉的做法是每次加一个元素就多申请一个格子的内存。但这个做法的代价非常大:每加入一个元素,都要把旧数据全部拷贝到新内存,再释放旧内存。如果连续插入n个元素,总拷贝次数是0 + 1 + 2 + ... + n,也就是O(n^2)。而vector采用的翻倍扩容,每次容量翻倍,平摊到每一个push_back上,拷贝次数是O(1)。这也是为什么vector的push_back均摊时间复杂度是O(1),而不是每次调用都是O(1)。我们后面实现时会直接选翻倍策略,这里先把原理说透。
第二个是拷贝语义。类里面管理了裸指针和堆内存,编译器默认生成的拷贝构造函数做的是浅拷贝——两个对象的start_指向同一块内存。任何一个对象析构时delete[]掉这块内存,另一个对象就成了悬垂指针,再访问就是未定义行为。所以必须自己写深拷贝。这个坑几乎所有写过自定义类的都会踩一次,它不属于“高级技巧”,属于“必须处理”的底线。
类接口部分,我建议先实现这些最常用的:构造、析构、拷贝构造、拷贝赋值、push_back、pop_back、operator[]、size、capacity、empty、begin、end、reserve、resize、clear、erase。这些够用了,而且每个都有对应的真实使用场景。接下来我就按这个清单逐个实现。
3. SimpleVector的完整实现:核心代码逐段拆解
3.1 类骨架与三指针初始化
cpp复制#include <cstddef>
#include <utility>
template<typename T>
class SimpleVector {
public:
using value_type = T;
using iterator = T*;
using const_iterator = const T*;
SimpleVector() noexcept
: start_(nullptr), finish_(nullptr), end_of_storage_(nullptr) {}
private:
T* start_;
T* finish_;
T* end_of_storage_;
};
默认构造把三个指针全部置空。这个细节很重要:如果漏掉初始化,三个指针就是栈上的随机值,第一个push_back判断finish_ == end_of_storage_时大概率不相等,就会往一块野地址上写数据,段错误几乎是必然的。我见过太多人在这里翻车,多写一个初始化列表,少一次通宵调试。
3.2 构造函数:一次性分配n个元素
cpp复制explicit SimpleVector(size_t n, const T& value = T())
: start_(nullptr), finish_(nullptr), end_of_storage_(nullptr) {
start_ = new T[n];
for (size_t i = 0; i < n; ++i) {
start_[i] = value;
}
finish_ = start_ + n;
end_of_storage_ = finish_;
}
这版构造直接分配一块能放n个元素的内存,再用value逐一遍历填充。注意finish_和end_of_storage_相等,说明此时容量恰好等于size,没有任何预留空间。这跟STL vector的vector(n, value)行为一致。
一个细节:new T[n]要求T必须有默认构造函数,因为new[]会先默认构造所有元素,然后我们再把value赋值进去。这是个隐藏的限制,标准库vector没有这个要求。我先按“能跑起来”的简化实现来,第4节会专门说这个差距怎么来的、什么时候会出问题。
3.3 push_back与扩容路径
这是整个动态数组的灵魂,也是面试最喜欢追问的一段。
cpp复制void push_back(const T& value) {
if (finish_ == end_of_storage_) {
grow();
}
*finish_ = value;
++finish_;
}
逻辑很短:满了就扩容,没满就直接在finish_位置写值,然后把finish_后移一格。真正的复杂点在grow()里。
cpp复制void grow() {
size_t cap = capacity();
size_t new_cap = cap == 0 ? 1 : cap * 2;
reserve(new_cap);
}
首次push_back时容量为0,cap * 2还是0,所以新容量设为1。之后每次翻倍:1、2、4、8、16……reserve负责真正的内存搬移。
cpp复制void reserve(size_t n) {
if (n > capacity()) {
T* new_start = new T[n];
size_t len = size();
for (size_t i = 0; i < len; ++i) {
new_start[i] = start_[i];
}
delete[] start_;
start_ = new_start;
finish_ = start_ + len;
end_of_storage_ = start_ + n;
}
}
reserve的逻辑是:只有当请求的容量比当前容量大时才动手,否则什么都不做。动手时申请一块更大的内存,把旧元素逐一遍历拷贝过来,然后释放旧内存,最后重接三个指针。
这里要强调一个顺序问题:先拷贝,再释放旧内存。如果反过来,先把旧内存delete[]了,后面的拷贝就是在读一块已经归还给操作系统的内存,行为完全不可预测。这套“申请新内存 → 拷贝旧数据 → 释放旧内存 → 更新指针”的顺序,是动态数组扩容的基本盘,无论你的容器叫什么名字,底下都是这套动作。
3.4 拷贝构造、拷贝赋值与swap
深拷贝的写法很直接:
cpp复制SimpleVector(const SimpleVector& other) {
size_t len = other.size();
size_t cap = other.capacity();
start_ = new T[cap];
for (size_t i = 0; i < len; ++i) {
start_[i] = other.start_[i];
}
finish_ = start_ + len;
end_of_storage_ = start_ + cap;
}
这里有一个容易忽略的点:拷贝时要把容量也拷过来。如果只按len分配,那拷贝出来的新对象容量等于size,下次一个push_back就要扩容。虽然是正确的,但性能上比原对象差很多——原对象已经预留好的空间被白白丢掉了。拷贝构造尽量保持原对象的容量特征,是“顺手做”但很体现水准的细节。
拷贝赋值我用了copy-and-swap技术:
cpp复制SimpleVector& operator=(const SimpleVector& other) {
if (this != &other) {
SimpleVector tmp(other);
swap(tmp);
}
return *this;
}
void swap(SimpleVector& other) noexcept {
using std::swap;
swap(start_, other.start_);
swap(finish_, other.finish_);
swap(end_of_storage_, other.end_of_storage_);
}
为什么不用“先释放自己的内存,再一个个拷贝”的写法?因为那种写法如果中途拷贝抛异常(比如T的拷贝构造抛了),当前对象已经被释放了一半,实际上处于半破坏状态。copy-and-swap则先构造一个完整的临时对象,构造成功后再一次性交换指针,交换操作本身不会抛异常。所以这个写法提供的是强异常安全保证:要么赋值成功,要么原对象保持原样。
代价是额外构造了一个临时对象。对动态数组来说这完全值得,因为vector本来就要求拷贝操作具备较好的异常安全性。工程上,凡是管理堆内存的类,拷贝赋值第一个想到的应该是copy-and-swap,而不是手写释放和分配。
3.5 下标访问、size、capacity、迭代器
cpp复制T& operator[](size_t index) {
return start_[index];
}
const T& operator[](size_t index) const {
return start_[index];
}
size_t size() const {
return finish_ - start_;
}
size_t capacity() const {
return end_of_storage_ - start_;
}
bool empty() const {
return start_ == finish_;
}
iterator begin() {
return start_;
}
iterator end() {
return finish_;
}
const_iterator begin() const {
return start_;
}
const_iterator end() const {
return finish_;
}
operator[]我故意没有做边界检查。这不是偷懒,std::vector的operator[]也不检查,它要求调用者自己保证下标合法。想要安全检查,标准库提供了at(),会抛出std::out_of_range。我在自己的实现里维持这个语义:下标访问是面向性能的,检查留给at()或者用户在调试期开编译选项。这个选择背后是对C++哲学“不为不用的东西付出代价”的尊重。
size()和capacity()用指针相减实现,顺手还练了一个知识点:指向同一个数组的两个指针相减,得到的是元素个数,这是C++标准明确保证的。
3.6 pop_back、resize、clear、erase
cpp复制void pop_back() {
if (finish_ > start_) {
--finish_;
}
}
pop_back看似只是把finish_往前移一格。但注意,那个被“删掉”的元素,它的析构函数不会被执行。对int这种类型无所谓,但如果T是std::string,这个string内部的堆内存就泄漏了。标准库vector在pop_back时会显示调用析构函数。我这里为了保持代码简单没做,但这个点必须让读者心里有数,后面第4节我会给出一版更完善的处理方法。
cpp复制void resize(size_t n, const T& value = T()) {
if (n < size()) {
finish_ = start_ + n;
} else if (n > size()) {
if (n > capacity()) {
reserve(n);
}
for (size_t i = size(); i < n; ++i) {
start_[i] = value;
}
finish_ = start_ + n;
}
}
resize的逻辑是:变小就把finish_往回拉;变大就先确保容量够,然后把多出来的部分填充value。注意这里隐藏着一个性能习惯:如果明确知道最终会变成多大,先resize再往里面填,比反复push_back高效得多,因为避免了多次扩容。
cpp复制void clear() {
finish_ = start_;
}
clear更简单,直接把finish_拉到和start_相等,size变成0,但容量不变。这也意味着clear之后内存不会被释放,只是从逻辑上“清空”了。如果想连内存一起释放,标准库的做法是shrink_to_fit(),或者用空容器swap,后面第5节讲工程写法时会详细对比。
cpp复制iterator erase(iterator pos) {
if (pos >= begin() && pos < end()) {
for (iterator it = pos; it + 1 != end(); ++it) {
*it = *(it + 1);
}
--finish_;
}
return pos;
}
erase把被删位置之后的元素逐个往前搬一格,然后finish_前移。搬运结束后,原来最后一个元素的位置已经“不属于有效区间”了。这段实现同样没有处理析构问题,和pop_back一样,是简易版和工业版的一个主要差距。
到这里,一个能用的简易动态数组就齐了。下面放一个完整的头文件,方便直接复制跑测试。
cpp复制#include <cstddef>
#include <utility>
template<typename T>
class SimpleVector {
public:
using value_type = T;
using iterator = T*;
using const_iterator = const T*;
SimpleVector() noexcept
: start_(nullptr), finish_(nullptr), end_of_storage_(nullptr) {}
explicit SimpleVector(size_t n, const T& value = T())
: start_(nullptr), finish_(nullptr), end_of_storage_(nullptr) {
start_ = new T[n];
for (size_t i = 0; i < n; ++i) {
start_[i] = value;
}
finish_ = start_ + n;
end_of_storage_ = finish_;
}
~SimpleVector() {
delete[] start_;
}
SimpleVector(const SimpleVector& other) {
size_t len = other.size();
size_t cap = other.capacity();
start_ = new T[cap];
for (size_t i = 0; i < len; ++i) {
start_[i] = other.start_[i];
}
finish_ = start_ + len;
end_of_storage_ = start_ + cap;
}
SimpleVector& operator=(const SimpleVector& other) {
if (this != &other) {
SimpleVector tmp(other);
swap(tmp);
}
return *this;
}
void swap(SimpleVector& other) noexcept {
using std::swap;
swap(start_, other.start_);
swap(finish_, other.finish_);
swap(end_of_storage_, other.end_of_storage_);
}
void push_back(const T& value) {
if (finish_ == end_of_storage_) {
grow();
}
*finish_ = value;
++finish_;
}
void pop_back() {
if (finish_ > start_) {
--finish_;
}
}
T& operator[](size_t index) {
return start_[index];
}
const T& operator[](size_t index) const {
return start_[index];
}
size_t size() const {
return finish_ - start_;
}
size_t capacity() const {
return end_of_storage_ - start_;
}
bool empty() const {
return start_ == finish_;
}
iterator begin() {
return start_;
}
iterator end() {
return finish_;
}
const_iterator begin() const {
return start_;
}
const_iterator end() const {
return finish_;
}
void reserve(size_t n) {
if (n > capacity()) {
T* new_start = new T[n];
size_t len = size();
for (size_t i = 0; i < len; ++i) {
new_start[i] = start_[i];
}
delete[] start_;
start_ = new_start;
finish_ = start_ + len;
end_of_storage_ = start_ + n;
}
}
void resize(size_t n, const T& value = T()) {
if (n < size()) {
finish_ = start_ + n;
} else if (n > size()) {
if (n > capacity()) {
reserve(n);
}
for (size_t i = size(); i < n; ++i) {
start_[i] = value;
}
finish_ = start_ + n;
}
}
void clear() {
finish_ = start_;
}
iterator erase(iterator pos) {
if (pos >= begin() && pos < end()) {
for (iterator it = pos; it + 1 != end(); ++it) {
*it = *(it + 1);
}
--finish_;
}
return pos;
}
private:
void grow() {
size_t cap = capacity();
size_t new_cap = cap == 0 ? 1 : cap * 2;
reserve(new_cap);
}
T* start_;
T* finish_;
T* end_of_storage_;
};
4. 踩坑记录:从简易实现暴露的几个致命细节
写完代码只是第一步。真正理解动态数组,是在踩过下面这些坑之后。
4.1 迭代器失效:扩容之后旧迭代器全部作废
这是vector最经典的坑,没有之一。看这段:
cpp复制SimpleVector<int> v;
v.push_back(1);
v.push_back(2);
int* it = v.begin(); // 指向v[0]
v.push_back(3); // 触发扩容
*it = 100; // 未定义行为
第二次push_back发生时,容量从2变成4,reserve里delete[]掉了旧内存,原来的it指向的地址已经不属于这个容器了。对它做任何读写,都是悬垂指针操作。这种bug在程序规模小的时候可能碰巧不崩,但换个编译器、换个优化等级,可能就莫名其妙的段错误。
所以永远记住两条铁律:
- 每次
push_back、insert、resize、reserve之后,之前拿到的迭代器都要假设已经失效。 - 如果你需要一边遍历一边插入,要么基于下标和
size()循环处理,要么在扩容前就预留好足够容量reserve,保证迭代过程不触发扩容。
4.2 浅拷贝导致的double free
如果我们在类里定义了自己的析构函数,却没有定义拷贝构造函数和拷贝赋值运算符,编译器会默认生成一个浅拷贝版本。这时候:
cpp复制SimpleVector<int> a;
a.push_back(10);
SimpleVector<int> b = a; // 两个对象的start_指向同一块内存
a和b析构时,各自delete[]一次同一块内存,第二delete是典型的double free,直接崩。更隐蔽的是,b通过push_back重新分配内存的话,会把a的内存释放掉,然后a变成了悬垂对象,后续访问完全失效。
这就是为什么管理裸指针的类,必须同时遵守“三之法则”:
如果类中有资源管理成员,那么析构函数、拷贝构造函数、拷贝赋值运算符三个中,只要自定义了其中一个,通常三个都要自己定义。
反过来,我们实现的copy-and-swap版本,天然同时覆盖了拷贝构造和拷贝赋值,这个坑就从根上消掉了。
4.3 new T[n]的两个隐含前提
new T[n]看起来很方便,但它背后有一个隐藏行为:它会把n个元素全部执行默认构造。这带来两个后果。
第一个后果是性能浪费。reserve(10000)实际上额外默认构造了10000个T对象,然后这些对象在push_back时又被赋值覆盖。如果T是std::string这种构造有开销的类型,这10000次默认构造加上后续的赋值,比标准库vector的“只构造有效元素”多了不少额外开销。
第二个后果更严重:T必须默认可构造。如果T是一个只有带参构造、没有默认构造的类型,new T[n]直接编译不过。标准库vector在reserve时使用allocator::allocate分配原始内存,不构造任何对象,直到push_back时才通过placement new在指定位置构造对象。这也正是为什么业界常说vector对“不可默认构造、不可拷贝类型”的支持度远超自己动手的简易版本。所以简易实现适合学习机制,真在关键项目里,还是老老实实用标准库vector。
4.4 析构和清空时,元素没有正确析构
这是前一节遗留的问题,这里再展开一下。pop_back只做了--finish_,并没有在原来的位置调用~T()。对int、double这类平凡类型没有任何影响,但对std::string、std::vector<int>这类自己管理堆内存的类型,那个位置上的string对象在finish_指针移走之后就再也找不到它了,它内部堆内存永远不会被释放。严格来说这是内存泄漏的一种变体。
标准库的处理是:finish_前移前先调用析构:
cpp复制void pop_back() {
--finish_;
finish_->~T();
}
而clear则需要遍历所有有效元素逐个析构。这也是为什么真正工业级的容器会配合allocator和std::destroy_at来做这件事。简易实现里不做,是为了让学习成本低一些,但你心里要有这根弦:指针前移只走了逻辑删除,物理资源不一定被清理。如果你之后的项目要用这个简易类存std::string,就务必补上析构调用。
5. 回到标准库vector:去重、清空、预分配这些高频操作怎么写
自己实现过一遍动态数组之后,你再回头用std::vector,很多以前靠死记的用法就自然理解了。
5.1 去重:sort + unique + erase 三板斧
网上关于“vector如何去重”的问题非常多,说明这是一个很常见的需求。最简单且标准的做法:
cpp复制#include <algorithm>
#include <vector>
std::vector<int> v = {5, 3, 2, 3, 1, 2, 4};
std::sort(v.begin(), v.end());
v.erase(std::unique(v.begin(), v.end()), v.end());
三步拆开看:
sort先把相同的元素聚到一起,保证相等的元素相邻。unique把相邻的重复元素“逻辑删除”,把不重复的元素往前搬,返回指向新逻辑末尾的迭代器。注意它没有真正删除元素,只是把后面的元素覆盖到前面,并把“最后一个有效元素的下一个位置”往后标定。erase(first, last)把从逻辑末尾到真正末尾这一段重复元素删掉。
这三板斧合起来,列表从{5,3,2,3,1,2,4}变成{1,2,3,4,5}。时间开销主要在排序,O(n log n)。如果数据本来就有序,或者就是想保留首次出现的顺序且不想排序,也可以用std::unordered_set配合双指针原地去重,但那是另一个话题了。
5.2 清空与释放内存:clear、shrink_to_fit、swap
“清空vector”是另一个高频需求,但很多人不知道的一层是:clear不释放内存。
cpp复制std::vector<int> v(10000, 1);
v.clear();
// v.size() == 0
// v.capacity() == 10000
clear之后,容器里没有有效元素了,但底层那10000个int的内存还占着。如果你希望马上把这部分内存交还给系统,有三种常见做法:
| 方法 | 效果 | 适用场景 |
|---|---|---|
v.clear() |
size归零,capacity不变,内存不释放 | 短时间内还要继续复用这块空间 |
v.shrink_to_fit() |
尝试把capacity缩小到等于size | 希望释放多余预留空间,但这是非强制请求 |
std::vector<int>().swap(v) |
用空容器交换,彻底释放内存 | 容器生命周期结束前,确定不再使用 |
第三种方法背后其实就是我们前面实现的swap的威力:用一个匿名空vector和v交换指针,匿名对象析构时带着v原来的内存一起释放了。这个技巧我在自己做的简易实现里也提到过,原理是同一个。
5.3 预分配:reserve是提升性能的第一板斧
我在前面reserve的讲解里反复强调过预分配的价值,这里给一个直观的场景。
假设你要往vector里连续写入100万个整数:
cpp复制std::vector<int> v1;
for (int i = 0; i < 1000000; ++i) {
v1.push_back(i);
}
std::vector<int> v2;
v2.reserve(1000000);
for (int i = 0; i < 1000000; ++i) {
v2.push_back(i);
}
v1在插入过程中会触发大约log2(1000000) ≈ 20次扩容,每次扩容都要搬移旧数据,而且越到后面搬移量越大,总搬移次数大约等同于最终size。v2只做一次内存分配,全程不搬移。在我本机实测里,这种差距非常明显,v1比v2慢好几倍,100万个元素时已经能感受到明显的卡顿差异。
这个差异背后的原因,就是翻倍扩容的“均摊代价”只保证了单个push_back在长时间跨度上平均是O(1),但某一个具体时刻的push_back为了翻倍,可能会触发一次O(n)的搬移。预分配把这种偶发的“大停顿”也消掉了。
5.4 知道了原理之后,use after后应该能自己回答了
以前有人问“vector和数组有什么区别”,可能你会回答“vector可以动态扩容”。但写完这个简易实现之后,你应该能更精确地描述:
- vector内部是一片连续内存,所以随机访问是O(1)。
- 扩容时,vector会分配新内存、搬移旧元素、释放旧内存,这个过程导致迭代器失效。
size表示有效元素个数,capacity表示已经分配的内存能容纳的元素个数。reserve只修改capacity,不改变size;resize直接修改size,可能构造或析构元素。- 中间插入和删除是O(n),因为要搬移元素;尾部插入删除均摊O(1)或者O(1)。
这些不是背概念,而是看得到代码的机制。对我来说,这就是写简易实现最大的收获。
最后说点实在的。这个SimpleVector我实际拿来做过一个很小的配置解析模块,存了几百个字符串,没出过问题。后来我把模板参数换成自定义结构体,就立刻撞上了默认构造的编译错误,那一刻我才真正理解为什么标准库要用allocator。学这类底层数据结构,从来不是一个下午能彻底吃透的,但把这份简化版抄一遍、跑一遍、拆一遍,你对vector的理解就已经远超那个只会push_back的“API调用者”了。如果你也想动手,我建议从reserve和erase这两个函数开始自己加功能,改着改着,功力就出来了。
