
前阵子A同学准备C技术面试跟我说网上到处是“简单模拟实现vector”的教程他照着一行一行敲完了代码也能编译通过可合上编辑器之后脑子里只剩下“扩容要两倍”这五个字。我说这不怪你因为大部分教程给的是成品代码而不是思考过程——每一行代码为什么存在出问题了该往哪里查这才是手写vector真正值钱的地方。这篇我打算换个讲法不着急上完整代码而是把实现过程拆成几个独立的问题逐个攻破先想清楚内存模型再搭骨架接着处理扩容、拷贝控制、迭代器最后用测试把隐藏问题逼出来。适合刚学完C想找第一个完整练手项目的人也适合面试前想彻底吃透动态数组的人。1. 为什么建议你亲手写一遍vector而不是背源码1.1 一个看似简单的问题能追到多深先试着回答三句话vector扩容时老元素到底经历了什么为什么vector的析构函数里要手动调用析构而不是直接delete[]为什么同一套模板既能管vectorint又能管vectorstd::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个元素的搬运总量就是123...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_castT*(::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::vectorsize_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次每次都要搬动当前所有元素总搬运量是123...n约等于n²/2摊到每次push就是O(n)这是灾难。两倍扩容就不一样了。从1开始翻倍第k次扩容后容量是2^k需要搬运的元素是2^(k-1)个。所有扩容的搬运量加起来是124...n约等于2n摊到n次push上每次接近一两次拷贝。这就是“均摊O(1)”的由来偶尔一次很贵但整体代价是线性的。为什么有些实现用1.5倍而不是2倍因为2倍扩容之后旧内存往往比新内存小不了多少空间浪费比较明显1.5倍更省内存但搬运次数会多一些。对学习实现来说两倍足够清晰数学也最好算。提示capacity_ 0 ? 1 : capacity_ * 2里的“0变1”特别容易被忽略。第一次push_back时capacity_是0如果直接乘2依然是0vector就永远扩不了容了。别笑这个bug我见过不止一次。4.3 扩容期间元素是怎么“搬”的reserve_impl里的实质是新内存上逐个构造、旧内存上逐个析构。我为了突出异常安全用了拷贝构造更现代的做法是优先移动new (newData copied) T(std::move(data_[copied]));但这里有个微妙的点如果T的移动构造会抛异常搬了一半再回滚就很麻烦。标准库对此的解法是std::move_if_noexceptT的移动构造不抛异常就移动否则退回到拷贝。我建议你实现完之后回头看看你本机标准库头文件里这个细节——手写一次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-swapSimpleVector 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 移动语义简版C11之后建议补上一个移动构造SimpleVector(SimpleVector other) noexcept : data_(other.data_), size_(other.size_), capacity_(other.capacity_) { other.data_ nullptr; other.size_ 0; other.capacity_ 0; }移动就是“把别人的资源整个接过来”然后把源对象置成空状态避免它析构时释放本属于新对象的资源。注意这个noexceptvector扩容需要搬移元素时如果T的移动构造不是noexcept容器和算法宁可退化成拷贝也不冒险用移动。所以给移动构造函数标noexcept不只是礼仪更是性能。6. operator[]与迭代器让它用起来真的“像数组”6.1 一对operator[]非const与constT operator[](size_t index) { return data_[index]; } const T operator[](size_t index) const { return data_[index]; }必须同时提供两个版本普通对象调用命中非const版本拿到T可以写v[3] 42const对象调用命中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和endfor (auto x : v)就直接能用了因为range-for的展开机制就是调用这两个函数。等你的测试程序跑起来会发现这个天天在用的语法糖底下其实就是两个迭代器函数加一个隐藏的迭代器变量。更重要的感悟是迭代器失效。你亲手把data_换成了新地址于是之前保存的每一个指针都成了悬空指针。这个体会比背任何八股都深刻vector的迭代器失效不是抽象规则而是你亲手造成的事实。7. 测试与排查让bug自己现形7.1 先用最朴素的测试压一遍把完整实现放进一个文件写一个简单的main先测空构造和size/capacity再连续push_back跨过多次扩容然后pop_back、clear、拷贝构造、赋值、迭代器遍历、const访问。我经常用的测试清单测试点预期行为空vector的size()0begin() end()第一次push_backcapacity变为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想通的那一刻那种“一块石头终于落地”的感觉比看十篇源码解析都来得实在。所以我的建议一直是别急着背源码先动手写别怕出错出错才会长出排查的经验。等你写完了再回头翻标准库的头文件你会发现它不再是黑箱而是一个能把每个设计决定都摊开来讨论的老朋友。