前阵子A同学准备C++技术面试,跟我说网上到处是“简单模拟实现vector”的教程,他照着一行一行敲完了,代码也能编译通过,可合上编辑器之后,脑子里只剩下“扩容要两倍”这五个字。我说这不怪你,因为大部分教程给的是成品代码,而不是思考过程——每一行代码为什么存在,出问题了该往哪里查,这才是手写vector真正值钱的地方。
这篇我打算换个讲法,不着急上完整代码,而是把实现过程拆成几个独立的问题逐个攻破:先想清楚内存模型,再搭骨架,接着处理扩容、拷贝控制、迭代器,最后用测试把隐藏问题逼出来。适合刚学完C++想找第一个完整练手项目的人,也适合面试前想彻底吃透动态数组的人。
1. 为什么建议你亲手写一遍vector,而不是背源码
1.1 一个看似简单的问题,能追到多深
先试着回答三句话:
- vector扩容时,老元素到底经历了什么?
- 为什么vector的析构函数里要手动调用析构,而不是直接
delete[]? - 为什么同一套模板,既能管
vector<int>又能管vector<std::string>?
这三个问题恰好落在内存管理、对象生命周期、模板泛型这三块上,全是C++里最容易出问题、也最值得弄明白的地方。背源码的人能复述“两倍扩容”,但被追问“如果T的拷贝构造抛异常,扩容到一半怎么收场”就答不上来了。亲手写一遍,这些追问会自己浮出来。
A同学后来跟我说,他照着教程抄的时候,push_back里那句new (data_ + size_) T(value)完全看不懂,索性跳过。但等他真正自己写的时候才发现:如果这里不是placement new,而是普通的data_[size_] = value,整个vector在扩容之后会暴露一大片“只有内存、没有对象”的区域,赋值操作根本没法进行。这种理解,是抄十遍代码也换不来的。
1.2 手写一次到底能换来什么
第一,你会彻底理解C++里“内存”和“对象”是两个正交概念。malloc给你的是裸内存,对象还得靠构造函数去“激活”;反过来,释放之前也要先“销毁”。这个认知会直接改变你以后阅读任何容器的体验。
第二,你会真正明白标准库容器为什么要分capacity和size。没有这一层预留,push_back就退化成每次插入都分配一次内存的灾难现场,性能直接塌方。
第三,你会得到一个可以反复“蹂躏”的测试对象。往里塞std::string、反复拷贝、故意让元素的构造函数抛异常——这些实验没人拦着你,代价只是重新编译一次。
有人可能会说:标准库都已经把vector实现得那么好了,何必再造轮子?我的看法是,这不是造轮子,是拆轮子。你不需要把它用到生产环境,但你需要知道轮子内部是怎么转的。C++面试里关于容器的追问,几乎都藏在这两百行代码里。
2. 内存模型:size、capacity与那块连续堆内存
2.1 vector在内存里到底长什么样
所有实现本质都是三件套:
template <typename T> class SimpleVector { private: T* data_; // 指向堆上一段连续内存 size_t size_; // 已经构造好的元素个数 size_t capacity_; // 当前分配的内存能容纳几个元素 };data_指向一块用底层operator new拿到的连续内存。size_是“活着的元素”数量,capacity_是“能容纳”的数量,永远满足size_ <= capacity_,而capacity_ - size_就是预留出来给未来push_back用的空间。
我建议把这两个数字想象成两个含义完全不同的东西:size_是“房间里住了几个人”,capacity_是“这栋楼一共有几间房”。人住不满,空房依然在那儿,但不属于任何具体居民。vector里未构造元素的区域就是这种“空房间”——内存是分到了,对象还不存在。
2.2 为什么不能图省事直接用 new T[n]
最容易犯的错误,是把数据成员写成new T[n]。问题在于new T[n]的语义是“分配内存 + 对n个元素做默认构造”。可vector的capacity经常大于size,多出来的那部分内存上根本没有对象,编译器不会替你在那里“凭空变出”对象。
所以必须把“分配内存”和“构造对象”拆成两步:
- 分配裸内存:
::operator new(n * sizeof(T)),这一步只给空间,不碰对象。 - 在裸内存上构造对象:
new (ptr + i) T(...),也就是placement new。 - 显式销毁对象:
(ptr + i)->~T()。 - 释放裸内存:
::operator delete(ptr)。
这个“先有房、再住人、走之前退租、最后拆楼”的流程,就是vector所有复杂行为的根。后面几节你会反复看到这四个步骤的不同组合。
提示:如果T是
int这种平凡类型,析构函数调用与否结果一样,编译器会直接优化掉。但vector作为通用容器,必须假设T是std::string这种自己管理内存的类型,这四个步骤一个都不能省。
2.3 预留空间的意义到底从哪来
如果只保留size而没有capacity,每次push_back都得重新分配一块刚好能装下所有元素的内存,再把老数据全部搬过去。这个操作如果每次插入都发生,往vector里塞n个元素的搬运总量就是1+2+3+...+n,均摊到每次是O(n)——这是灾难。
有了capacity,大部分push_back只是在预留位置构造一个元素,只有洞满时才“动一次大手术”。所以你可以把这块预留空间理解为“用空间换时间”的经典操作,这也是vector和链表一个很大的区别:链表用不连续的内存换插入灵活性,vector用连续内存加预留空间换访问速度和分摊后的插入效率。
3. 基础骨架:先写一个能构造、能析构的最小版本
3.1 选对内存原语
我建议直接用::operator new/::operator delete配合placement new,而不是malloc/free。原因有二。
第一,::operator new是new表达式的底层,类型安全和异常行为更贴合C++语义。分配失败时它会抛std::bad_alloc,不像malloc返回nullptr还得手动判断。
第二,placement new是C++标准的一部分,直接在已有内存上构造对象,语义清晰。用(T*)malloc(...)再手动构造,还得担心对齐和构造语法的问题,完全没必要自我加戏。
3.2 第一版代码骨架
#include <cstddef> #include <new> template <typename T> class SimpleVector { public: using value_type = T; SimpleVector() : data_(nullptr), size_(0), capacity_(0) {} explicit SimpleVector(size_t count, const T& value = T()) : data_(nullptr), size_(0), capacity_(0) { reserve_impl(count); for (size_t i = 0; i < count; ++i) { push_back(value); } } ~SimpleVector() { clear(); ::operator delete(data_); } size_t size() const { return size_; } size_t capacity() const { return capacity_; } bool empty() const { return size_ == 0; } void clear() noexcept { for (size_t i = 0; i < size_; ++i) { data_[i].~T(); } size_ = 0; } private: void reserve_impl(size_t newCapacity) { if (newCapacity <= capacity_) return; T* newData = static_cast<T*>(::operator new(newCapacity * sizeof(T))); size_t copied = 0; try { for (; copied < size_; ++copied) { new (newData + copied) T(data_[copied]); } } catch (...) { for (size_t j = 0; j < copied; ++j) { newData[j].~T(); } ::operator delete(newData); throw; } for (size_t j = 0; j < size_; ++j) { data_[j].~T(); } ::operator delete(data_); data_ = newData; capacity_ = newCapacity; } T* data_; size_t size_; size_t capacity_; };几个细节值得停下来看。
clear()只销毁对象、不释放内存,因为capacity不变,这块内存还要继续复用。这个语义和std::vector::clear完全一致。另外,::operator delete(nullptr)是安全空操作,所以空vector析构时不需要特判。
析构函数里先clear再::operator delete(data_),顺序不能反:先退租、再拆楼;反过来先拆楼,楼里面还住着的对象就没地方安全析构了。
reserve_impl里先在新内存上构造好全部旧元素,再销毁旧内存。这样即使拷贝到一半某个元素的构造函数抛异常,原vector也毫发无损——这就是异常安全里的“强保证”。catch块会把已经构造的部分逐个析构,然后重新抛出,“清理现场再走人”,这比带着一半垃圾继续跑要靠谱得多。
3.3 关于带初值构造的一个细节
explicit SimpleVector(size_t count, const T& value = T())里的T()要求T可以默认构造。这和标准库的行为不完全一样——std::vector<size_t>那个版本会对元素做值初始化,int会拿到0而不是未定义值。
我这里用“带初值+逐个push_back”的写法,主要是为了复用已经写好的push_back,让第一版代码尽量短。想更贴近标准库,可以把构造循环改成在data_ + i上直接new (data_ + i) T(),这属于进阶练习。实现vector本来就是个迭代过程,第一个能跑的版本永远不需要完美。
4. push_back与扩容机制:为什么偏偏是两倍
4.1 push_back的真面目
push_back其实就三件事:满了就扩容,在size_位置构造新元素,然后把size_加一。
void push_back(const T& value) { if (size_ == capacity_) { grow(); } new (data_ + size_) T(value); ++size_; }这里size_兼任“下一个元素该放哪的下标”,代码才能这么简洁。注意那句placement new:如果写成data_[size_] = value,本质上要求该位置已经有一个构造好的对象,这在预留的空房间里并不成立。对象必须先进驻,才谈得上赋值。
4.2 扩容步长与均摊复杂度
grow的实现:
void grow() { size_t newCapacity = capacity_ == 0 ? 1 : capacity_ * 2; reserve_impl(newCapacity); }关键就在这个“两倍”。如果每次扩容只多一个位置,连续push n次时,扩容发生了n次,每次都要搬动当前所有元素,总搬运量是1+2+3+...+n,约等于n²/2,摊到每次push就是O(n),这是灾难。
两倍扩容就不一样了。从1开始翻倍,第k次扩容后容量是2^k,需要搬运的元素是2^(k-1)个。所有扩容的搬运量加起来是1+2+4+...+n,约等于2n,摊到n次push上,每次接近一两次拷贝。这就是“均摊O(1)”的由来:偶尔一次很贵,但整体代价是线性的。
为什么有些实现用1.5倍而不是2倍?因为2倍扩容之后,旧内存往往比新内存小不了多少,空间浪费比较明显;1.5倍更省内存,但搬运次数会多一些。对学习实现来说,两倍足够清晰,数学也最好算。
提示:
capacity_ == 0 ? 1 : capacity_ * 2里的“0变1”特别容易被忽略。第一次push_back时capacity_是0,如果直接乘2依然是0,vector就永远扩不了容了。别笑,这个bug我见过不止一次。
4.3 扩容期间元素是怎么“搬”的
reserve_impl里的实质是:新内存上逐个构造、旧内存上逐个析构。我为了突出异常安全用了拷贝构造,更现代的做法是优先移动:
new (newData + copied) T(std::move(data_[copied]));但这里有个微妙的点:如果T的移动构造会抛异常,搬了一半再回滚就很麻烦。标准库对此的解法是std::move_if_noexcept:T的移动构造不抛异常就移动,否则退回到拷贝。我建议你实现完之后,回头看看你本机标准库头文件里这个细节——手写一次vector,再看源码的体验是完全不同的。
还有一个连带问题:扩容会让所有指向旧内存的迭代器、引用、指针全部失效,因为data_换了地址。这是vector最著名的行为特性之一,也是面试常问“为什么插入可能导致vector迭代器失效,而list不会”的底层原因。
5. 三/五法则:拷贝控制是手写vector的分水岭
5.1 默认拷贝构造会怎么坑你
如果我们什么都不写,编译器生成的拷贝构造会把三个成员逐个复制:新对象的data_直接等于旧对象的data_指针。于是两个vector共享同一块内存,析构时先后两次调用::operator delete,触发double free。表现就是程序退出main时莫名其妙崩溃。
这不是C++的bug,而是编译器在替你做它力所能及的浅拷贝。至于你的类深层语义是什么,它管不了。凡是持有堆内存的类,几乎都绕不开这条规则:要么完整实现拷贝语义,要么显式禁止拷贝。
A同学第一次跑出double free时还以为是代码写错了,把崩溃信息发给我,我说你把拷贝构造和析构补齐全就没事了。他补完之后感叹:原来编译器默认能干的活,有时候恰恰是在帮倒忙。
5.2 拷贝构造:深拷贝的正确姿势
SimpleVector(const SimpleVector& other) : data_(nullptr), size_(0), capacity_(0) { reserve_impl(other.size_); for (size_t i = 0; i < other.size_; ++i) { new (data_ + i) T(other.data_[i]); } size_ = other.size_; }拷贝构造的要点:目标对象先以空状态初始化,然后分配与other.size_匹配的内存,再逐个placement new拷贝构造。这里分配的是size_而不是capacity_,因为新对象没必要继承源对象的空余容量——这是合理的取舍,不违反vector的任何语义,只是少占点内存而已。
5.3 赋值运算符与copy-and-swap
赋值比拷贝构造麻烦,因为调用时*this可能已经持有内存,要在“复用旧内存”和“整体换血”之间做选择。最省心的答案是copy-and-swap:
SimpleVector& operator=(const SimpleVector& other) { if (this != &other) { SimpleVector tmp(other); swap(tmp); } return *this; } void swap(SimpleVector& other) noexcept { std::swap(data_, other.data_); std::swap(size_, other.size_); std::swap(capacity_, other.capacity_); }思路很直白:先拿other拷贝出一个临时tmp。如果拷贝抛异常,*this原样不动,安全。然后用一个不抛异常的swap把tmp的资源接管过来。tmp在函数结束析构时,恰好带走*this之前那块旧的堆内存。整个过程逻辑极其清晰,还天然处理了自赋值,不需要额外判断。
这是我最喜欢的写法,因为它把最难的部分推给了已经写好的拷贝构造和析构,赋值运算符只剩下九行。如果你在纸上推演一遍v1 = v2和v1 = v1两条路径,会发现它们都安全。别手写“逐成员释放再逐成员拷贝”的老路,那种写法一旦拷贝中途抛异常,*this就已经处于半残状态了。
5.4 移动语义简版
C++11之后建议补上一个移动构造:
SimpleVector(SimpleVector&& other) noexcept : data_(other.data_), size_(other.size_), capacity_(other.capacity_) { other.data_ = nullptr; other.size_ = 0; other.capacity_ = 0; }移动就是“把别人的资源整个接过来”,然后把源对象置成空状态,避免它析构时释放本属于新对象的资源。注意这个noexcept:vector扩容需要搬移元素时,如果T的移动构造不是noexcept,容器和算法宁可退化成拷贝也不冒险用移动。所以给移动构造函数标noexcept不只是礼仪,更是性能。
6. operator[]与迭代器:让它用起来真的“像数组”
6.1 一对operator[]:非const与const
T& operator[](size_t index) { return data_[index]; } const T& operator[](size_t index) const { return data_[index]; }必须同时提供两个版本:普通对象调用命中非const版本,拿到T&,可以写v[3] = 42;const对象调用命中const版本,拿到const T&,只能读不能写。如果只写非const版本,const对象就完全无法通过[]访问元素,因为const T*没法隐式转成T*返回。
这里也不做边界检查,和std::vector::operator[]行为一致。要安全检查就用at(),越界会抛std::out_of_range。简单实现里可以不加at,但心里要清楚这两者的语义差异。
6.2 为什么vector的迭代器可以直接用裸指针
对vector来说,元素在内存里严格连续,所以迭代器本质上就是T*:
using iterator = T*; using const_iterator = const T*; iterator begin() { return data_; } iterator end() { return data_ + size_; } const_iterator begin() const { return data_; } const_iterator end() const { return data_ + size_; } const_iterator cbegin() const { return data_; } const_iterator cend() const { return data_ + size_; }裸指针天然支持*、->、++、--、+n、[]以及各种比较运算,恰好满足随机访问迭代器的全部要求。所以标准库的std::sort、std::find这些算法,直接就能拿它工作。这也是实现里值得加迭代器的原因:你会立刻体会到“容器和算法通过迭代器解耦”到底是什么意思。
一个小细节:end()返回的是data_ + size_,指向最后一个元素的“下一个位置”,而不是最后一个元素本身。空vector的begin()和end()相等。这个左闭右开区间约定是整个STL算法的基础,忘了它,范围遍历和二分查找都会出鬼。
6.3 range-for与迭代器失效的现场感悟
有了begin和end,for (auto& x : v)就直接能用了,因为range-for的展开机制就是调用这两个函数。等你的测试程序跑起来,会发现这个天天在用的语法糖,底下其实就是两个迭代器函数加一个隐藏的迭代器变量。
更重要的感悟是迭代器失效。你亲手把data_换成了新地址,于是之前保存的每一个指针都成了悬空指针。这个体会比背任何八股都深刻:vector的迭代器失效不是抽象规则,而是你亲手造成的事实。
7. 测试与排查:让bug自己现形
7.1 先用最朴素的测试压一遍
把完整实现放进一个文件,写一个简单的main:先测空构造和size/capacity,再连续push_back跨过多次扩容,然后pop_back、clear、拷贝构造、赋值、迭代器遍历、const访问。
我经常用的测试清单:
| 测试点 | 预期行为 |
|---|---|
| 空vector的size() | 0,begin() == end() |
| 第一次push_back | capacity变为1 |
| 连续push 100个元素 | 不崩溃,元素全部可访问 |
| pop_back后再访问 | 最后一个元素消失、size回退 |
| 拷贝后修改副本 | 原对象不受影响 |
| 自赋值 | 不崩溃、不泄漏 |
| 元素用std::string | 无double free,无内存泄漏 |
尤其推荐用std::string当元素类型测一遍:string内部自己管理内存,任何浅拷贝和析构顺序错误都会迅速导致崩溃或泄漏,比用int灵敏得多。int这类平凡类型会让很多bug静默溜走。
7.2 排查思路与常见问题对照
如果测试时撞见崩溃,先别慌,按这个顺序排查:先确认是不是double free——把析构和拷贝构造里加打印,或者临时注释掉观察现象;再确认是不是内存泄漏——重复构造析构循环时观察内存增长;最后确认是不是越界——把operator[]临时改成带检查版本,打印index和size_。
下面这个对照表是我自己踩坑加上帮人看代码攒出来的,命中率很高:
| 症状 | 常见根因 |
|---|---|
| 程序退出时崩溃 | 析构重复释放,通常因为浅拷贝导致两个对象共享data_ |
| 内存只涨不降 | 忘了显式调用析构函数,或者clear没销毁对象 |
| 扩容后老数据全乱 | reserve_impl里循环次数写错,比如用了newCapacity而不是size_ |
| const对象编译失败 | 只写了非const版本的operator[] |
| 拷贝后改原对象影响副本 | 深层语义仍是浅拷贝,拷贝构造没写对 |
7.3 和标准库做一次行为对齐
最后一个建议:拿实现的SimpleVector和std::vector做对照实验。比如都塞10万个int然后随机读写,或者在Debug构建下反复构造析构。你会发现自己的实现性能上肯定打不过标准库——人家有成熟的内存分配策略、优化过的搬移手段、还有调试期迭代器检查——但只要接口行为对齐,你的代码能通过同一套测试用例。这个“对齐”本身就是最好的验收标准。
如果还想继续进阶,可以从这几个方向下手:加at()边界检查、加emplace_back和shrink_to_fit、支持右值版本的push_back、把内存分配抽成独立的allocator、再对比研究你本机标准库的完整实现。每个方向都能单独写一篇长文,但基础永远是这篇的骨架:分配与构造分离,拷贝控制完备,边界行为对齐。
我自己当年写vector,从下午写到半夜,卡在赋值运算符上整整三个小时。后来把copy-and-swap想通的那一刻,那种“一块石头终于落地”的感觉,比看十篇源码解析都来得实在。所以我的建议一直是:别急着背源码,先动手写;别怕出错,出错才会长出排查的经验。等你写完了再回头翻标准库的头文件,你会发现它不再是黑箱,而是一个能把每个设计决定都摊开来讨论的老朋友。