
1. 项目概述为什么我们要亲手“撕”一个vector如果你正在学习C尤其是已经跨过了语法基础的门槛开始接触STL标准模板库那么vector这个容器绝对是你绕不开的“老朋友”。它被广泛宣传为动态数组用起来似乎和数组一样简单——push_back、pop_back、下标访问一气呵成。但很多朋友在面试或者自己写稍复杂一点的代码时一遇到“迭代器失效”就懵了或者对reserve和resize的区别感到模糊。这时候仅仅停留在“会用”的层面是远远不够的。我当年在准备面试和夯实基础时发现最有效的方法不是背八股文而是亲手去模拟实现一遍这些核心容器。vector作为STL序列式容器的代表其设计思想精巧涉及资源管理、迭代器抽象、异常安全等多个C核心议题。通过模拟实现你能真正理解size和capacity的区别、动态扩容的成本、拷贝控制三/五法则如何保证安全以及为什么在某些操作后你的迭代器会“失效”。这个过程业内戏称为“手撕vector”它不仅是检验你对C理解深度的试金石更是从“语言使用者”迈向“库设计者”思维的关键一步。本文将带你从零开始一步步构建一个属于你自己的MyVector。我们会从最基础的框架搭起逐步添加功能并重点剖析那些容易踩坑的细节。无论你是正在备战C面试还是希望深入理解STL底层机制这篇内容都将提供一条清晰的实践路径。我们不止于实现更会深入探讨每个设计决策背后的“为什么”。2. vector的核心设计思路与框架搭建2.1 理解vector的物理本质一段连续的动态内存首先必须破除一个迷思vector不是魔法。它的底层就是一段通过new或malloc分配的、连续的堆内存。所有元素都在这段内存中顺序存储。这带来了两个核心属性1. 随机访问效率极高O(1)因为地址可以通过基地址偏移量直接计算2. 在尾部之外的位置插入/删除元素成本高昂O(n)因为需要移动后续所有元素。为了管理这段内存一个最简单的vector类需要三个核心指针或等价物_start: 指向已使用内存的头部即第一个元素。_finish: 指向已使用内存的尾部即最后一个元素的下一个位置。_finish - _start就是当前元素数量即size()。_end_of_storage: 指向整块已分配内存的尾部。_end_of_storage - _start就是当前总容量即capacity()。这三个指针的关系定义了vector的整个状态。_start到_finish是已构造对象的区间_finish到_end_of_storage是未使用的预留空间。当_finish _end_of_storage时就意味着空间用尽需要扩容。实操心得为什么用指针而不是三个size_t变量使用指针T*是最直接和高效的方式。计算size()和capacity()只需指针相减。如果用size_t变量分别记录大小和容量我们还需要一个单独的指针T* _data来指向内存块并且在每次扩容后需要同步更新这个指针和大小容量变量更容易出错。STL的实现通常也采用指针或迭代器方案。2.2 搭建MyVector的初步骨架基于以上思路我们可以开始编写类的框架。我们将采用模版类以支持任意类型T并引入自定义的命名空间my以避免与标准库冲突。namespace my { templateclass T class vector { public: // 迭代器原生指针即可满足随机访问迭代器的所有要求 typedef T* iterator; typedef const T* const_iterator; // 默认构造函数 vector() : _start(nullptr) , _finish(nullptr) , _end_of_storage(nullptr) {} // 析构函数 ~vector() { if (_start) { // 先析构已构造的对象 iterator it _start; while (it ! _finish) { it-~T(); // 显式调用析构函数 it; } // 再释放内存 operator delete(_start); // 使用operator delete释放原始内存 _start _finish _end_of_storage nullptr; } } // 获取迭代器 iterator begin() { return _start; } iterator end() { return _finish; } const_iterator begin() const { return _start; } const_iterator end() const { return _finish; } // 容量相关 size_t size() const { return _finish - _start; } size_t capacity() const { return _end_of_storage - _start; } bool empty() const { return _start _finish; } private: iterator _start; // 指向数据块开始 iterator _finish; // 指向最后一个有效数据的下一个位置 iterator _end_of_storage; // 指向存储空间末尾的下一个位置 }; } // namespace my这里有几个关键点需要注意迭代器类型对于vector其迭代器就是原生指针T*的别名。因为指针本身就支持、--、*、-、 n、-等操作完全满足随机访问迭代器的要求。析构函数的责任析构函数必须做两件事a) 对[_start, _finish)区间内的每个已构造对象调用其析构函数b) 释放_start指向的原始内存块。顺序不能错否则会导致资源泄漏如果T是管理资源的类如string。内存分配与释放这里使用了operator delete来释放内存它应与operator new配对使用。我们在后续的扩容中会使用operator new分配原始内存。为什么不直接用new T[n]和delete[]因为new T[n]会同时分配内存并调用n次T的默认构造函数这不符合vector“先分配原始内存再按需构造”的精细控制需求。3. 核心功能解析与迭代器失效的根源3.1 动态扩容机制reserve与resize的深刻区别这是vector最核心的机制之一也是面试高频考点。reserve和resize名字相似但行为天差地别。reserve(n)它只影响capacity。如果n大于当前capacity它会重新分配一块至少能容纳n个元素的新内存将旧元素移动或拷贝到新内存释放旧内存并更新三个指针。如果n capacity()它什么都不做。它不会改变size()也不会构造或销毁任何对象。它的目的是避免多次插入导致频繁重新分配是一种性能优化手段。resize(n, val T())它改变size()。如果n size()它会在尾部添加n - size()个值为val或默认值的元素这可能导致扩容。如果n size()它会销毁尾部size() - n个元素减少size但不一定减少capacity。它直接影响了容器中元素的数量。模拟实现reservevoid reserve(size_t n) { if (n capacity()) { // 1. 分配新内存 size_t old_size size(); T* new_start static_castT*(operator new(n * sizeof(T))); // 只分配不构造 // 2. 转移数据拷贝构造 // 这里面临选择拷贝构造还是移动构造为了强异常安全我们先尝试拷贝。 // 如果T的拷贝构造函数可能抛异常我们需要保证旧数据不被破坏。 T* new_finish new_start; iterator it _start; try { for (; it ! _finish; it) { // 定位new在new_start指向的原始内存上构造对象 new (new_finish) T(*it); // 调用T的拷贝构造函数 new_finish; } } catch (...) { // 如果构造过程中发生异常需要析构已成功构造的新对象并释放新内存 while (new_finish ! new_start) { (--new_finish)-~T(); } operator delete(new_start); throw; // 重新抛出异常 } // 3. 释放旧内存先析构旧对象 for (iterator i _start; i ! _finish; i) { i-~T(); } operator delete(_start); // 4. 更新指针 _start new_start; _finish new_finish; _end_of_storage _start n; } // 如果 n capacity(), 什么都不做 }注意事项异常安全与“拷贝后置”上面的实现提供了“强异常安全”保证如果拷贝构造过程中抛出异常旧vector的状态完全不变。这是通过“先在新内存构造成功后再替换”的模式实现的。在C11后如果T有noexcept的移动构造函数我们可以用std::move来优化性能实现移动构造。但异常安全是首要考虑。模拟实现resizevoid resize(size_t n, const T val T()) { if (n size()) { // 需要扩容 if (n capacity()) { reserve(n); // reserve会处理扩容 } // 在尾部构造新元素 while (_finish ! _start n) { new (_finish) T(val); // 定位new构造 _finish; } } else if (n size()) { // 需要销毁尾部元素 iterator new_finish _start n; while (_finish ! new_finish) { (--_finish)-~T(); // 显式调用析构函数 } // _finish 已经在循环中更新了 } // n size() 的情况什么都不做 }3.2 迭代器失效的全面剖析与应对“迭代器失效”是使用vector时最常见的坑。根本原因在于迭代器本质是指向底层内存的指针或类似指针的对象。当底层内存发生重新分配即_start指针改变所有指向旧内存的迭代器、指针、引用都会立即失效变成“野指针”。导致失效的操作主要有两类所有可能引起扩容的操作push_back、insert、reserve、resize当n capacity时。一旦发生扩容_start指向新内存旧迭代器全部失效。所有可能移动元素的操作insert在非尾部插入、erase。即使没有扩容这些操作也会导致被操作位置之后的元素在内存中发生移动从而使指向这些移动元素的迭代器、指针、引用失效。但注意对于erase被删除元素及其之后的所有迭代器都失效了对于insert插入位置之后的所有迭代器都失效。一个经典错误示例std::vectorint v {1, 2, 3, 4, 5}; auto it v.begin() 2; // it指向3 v.push_back(6); // 假设这导致了扩容 std::cout *it std::endl; // 未定义行为it已失效。模拟实现push_back时如何考虑失效问题void push_back(const T val) { // 检查容量 if (_finish _end_of_storage) { // 需要扩容这会导致所有现有迭代器失效 size_t new_capacity capacity() 0 ? 4 : capacity() * 2; // 常见的2倍扩容策略 reserve(new_capacity); } // 在_finish位置构造新元素 new (_finish) T(val); // 拷贝构造 _finish; }实操心得扩容策略的选择标准并未规定扩容因子常见实现如GCC的libstdc MSVC通常使用2倍或1.5倍。2倍扩容能保证均摊时间复杂度为O(1)但可能导致内存浪费。1.5倍如某些STL实现能更好地复用之前释放的内存块。在我们的模拟实现中使用2倍是简单明了的选择。关键是要在reserve中处理好异常安全和迭代器失效。如何避免迭代器失效立即更新在可能引起失效的操作如insert,erase之后如果需要继续使用迭代器应该使用这些操作返回的新迭代器。标准库的insert和erase会返回一个指向新位置的迭代器。索引替代如果逻辑允许使用下标size_t而非迭代器来记录位置。下标是相对于_start的偏移量只要_start不变即没扩容它就是安全的。但insert和erase会导致下标变化需要小心计算。操作后重取在可能扩容的操作后重新调用begin()、end()获取新的迭代器。4. 关键操作的模拟实现与深拷贝控制4.1 插入与删除insert 和 erase 的精细实现insert和erase是vector中相对复杂的操作因为它们涉及元素的移动和迭代器失效的逻辑。模拟实现insert(iterator pos, const T val)iterator insert(iterator pos, const T val) { // 检查pos有效性 (简化处理实际STL有更严格的检查) assert(pos _start pos _finish); // 1. 检查容量可能扩容导致所有迭代器失效包括pos if (_finish _end_of_storage) { // 扩容前计算pos的偏移量因为扩容后pos会失效 size_t offset pos - _start; size_t new_capacity capacity() 0 ? 4 : capacity() * 2; reserve(new_capacity); // 扩容后更新pos到新内存的对应位置 pos _start offset; } // 2. 从pos开始将元素向后移动一位 // 注意必须从后向前移动避免覆盖未移动的元素 iterator end _finish; while (end pos) { // 在end位置构造*(end-1)的拷贝 new (end) T(*(end - 1)); // 这里可能抛异常但此时旧元素还在是安全的 // 析构原*(end-1)位置的对象移动后 (end - 1)-~T(); --end; } // 3. 在pos位置构造新元素 new (pos) T(val); // 4. 更新_finish _finish; // 5. 返回指向新插入元素的迭代器 return pos; }注意事项元素移动的顺序与异常安全必须从后向前移动元素。如果从前向后你会先用*(pos1) *pos这会导致pos1位置的原始数据被覆盖丢失。从后向前移动我们是在“未初始化”的内存上构造新对象并析构旧对象。代码中在移动每个元素时都构造了新对象并析构了旧对象这保证了如果T的拷贝构造抛异常我们只破坏了“正在被移动”的那个旧元素而其他元素状态不变属于“基本异常安全”。更优的做法是先用std::move如果noexcept来移动效率更高。模拟实现erase(iterator pos)iterator erase(iterator pos) { // 检查pos有效性 assert(pos _start pos _finish); if (empty()) return end(); // 或者直接assert // 1. 从pos1开始将元素向前移动一位 iterator it pos 1; while (it ! _finish) { // 将*it移动到*(it-1)的位置 // 注意需要先析构目标位置的旧对象 (it - 1)-~T(); new (it - 1) T(std::move(*it)); // 使用移动语义效率更高 it; } // 2. 析构最后一个元素因为已经前移原_finish-1位置的对象是多余的 (_finish - 1)-~T(); // 3. 更新_finish --_finish; // 4. 返回指向被删除元素下一个位置的迭代器如果pos是最后一个元素则返回end() return pos; // 注意此时pos指向的位置已经是原来pos1的元素了 }踩坑记录erase 的返回值erase返回的是指向被删除元素之后那个元素的迭代器。这个设计非常巧妙便于在循环中连续删除元素。例如for(auto it v.begin(); it ! v.end(); ) { if (*it % 2 0) it v.erase(it); else it; }。如果不接收返回值it在erase后就已经失效了。4.2 深拷贝控制拷贝构造、赋值运算符与移动语义要让我们的MyVector像标准vector一样工作必须正确实现拷贝构造函数、拷贝赋值运算符。这遵循C的“Rule of Three”如果需要析构函数那么很可能也需要拷贝构造和拷贝赋值。在C11后还应考虑移动构造和移动赋值Rule of Five。拷贝构造函数深拷贝vector(const vectorT v) : _start(nullptr) , _finish(nullptr) , _end_of_storage(nullptr) { // 先分配足够大的内存 reserve(v.capacity()); // 然后逐个拷贝构造元素 for (const auto e : v) { push_back(e); // push_back会处理构造和_finish更新 } }这里直接利用reserve和push_back代码简洁且异常安全。push_back内部会调用T的拷贝构造函数。拷贝赋值运算符现代写法传统的写法是先检查自赋值然后释放旧资源再分配新资源并拷贝非常繁琐且容易出错。现代C更推崇“拷贝-交换”惯用法copy-and-swap。vectorT operator(vectorT v) { // 注意参数是传值 swap(v); // 交换当前对象和临时对象v的资源 return *this; // 函数结束临时对象v现在持有原对象的旧资源被析构释放 }这个实现非常优雅。它利用了传值参数会调用拷贝构造函数生成一个临时副本v。然后我们交换当前对象和v的内容。函数返回时临时对象v现在装着原对象的旧数据被析构。这自动处理了自赋值并且是强异常安全的如果拷贝构造失败异常会在传参时抛出原对象状态不变。交换函数swap的实现void swap(vectorT v) noexcept { std::swap(_start, v._start); std::swap(_finish, v._finish); std::swap(_end_of_storage, v._end_of_storage); }交换操作只是交换三个指针成本极低且noexcept这为移动操作提供了优化可能。移动构造函数与移动赋值运算符C11// 移动构造函数 vector(vectorT v) noexcept : _start(v._start) , _finish(v._finish) , _end_of_storage(v._end_of_storage) { // 将源对象置于有效但空的状态类似默认构造 v._start v._finish v._end_of_storage nullptr; } // 移动赋值运算符 vectorT operator(vectorT v) noexcept { if (this ! v) { // 释放当前对象的资源 this-~vector(); // 显式调用析构函数清理自身 // 接管源对象资源 _start v._start; _finish v._finish; _end_of_storage v._end_of_storage; // 置空源对象 v._start v._finish v._end_of_storage nullptr; } return *this; }移动操作“窃取”了右值引用参数v的资源然后将v置于可析构的空状态。它们被标记为noexcept非常重要这允许标准库容器在重新分配内存时使用移动而非拷贝来转移元素从而提升性能。5. 完善与测试从功能到健壮性5.1 补充常用接口与运算符重载一个完整的vector还需要许多其他接口这里实现几个最常用的operator[](重载下标访问)T operator[](size_t pos) { assert(pos size()); return _start[pos]; // 指针的天然下标操作 } const T operator[](size_t pos) const { assert(pos size()); return _start[pos]; }front/back/dataT front() { assert(!empty()); return *_start; } T back() { assert(!empty()); return *(_finish - 1); } T* data() { return _start; } // C11引入用于与C API交互clearvoid clear() { // 析构所有元素 iterator it _start; while (it ! _finish) { it-~T(); it; } _finish _start; // 逻辑清空不释放内存 }clear只析构元素不释放内存capacity不变。这是标准行为因为保留内存可以供后续push_back复用避免再次分配。shrink_to_fit(C11)void shrink_to_fit() { if (size() capacity()) { // 分配一块刚好容纳当前元素的新内存 size_t new_capacity size(); T* new_start static_castT*(operator new(new_capacity * sizeof(T))); T* new_finish new_start; // 移动或拷贝元素到新内存 try { for (iterator it _start; it ! _finish; it) { new (new_finish) T(std::move(*it)); // 尝试移动 new_finish; } } catch (...) { // ... 异常处理同reserve ... } // 清理旧内存 for (iterator it _start; it ! _finish; it) { it-~T(); } operator delete(_start); // 更新指针 _start new_start; _finish new_finish; _end_of_storage _start new_capacity; } }这是一个“请求”函数标准不保证capacity一定会减少到size()但我们的实现尽量满足。注意如果size() 0我们通常会将内存释放回到nullptr状态。5.2 编写测试代码验证功能实现完成后必须进行全面的测试。测试应覆盖正常功能、边界条件和异常安全。#include iostream #include cassert #include my_vector.h // 假设我们的类定义在此头文件 void test_basic() { my::vectorint v; assert(v.empty()); assert(v.size() 0); assert(v.capacity() 0); v.push_back(1); v.push_back(2); v.push_back(3); assert(v.size() 3); assert(v[0] 1 v[1] 2 v[2] 3); v.pop_back(); // 需要实现pop_back assert(v.size() 2); assert(v.back() 2); } void test_reserve_resize() { my::vectorint v; v.reserve(10); assert(v.capacity() 10); assert(v.size() 0); // reserve不改变size v.resize(5, 42); assert(v.size() 5); for (size_t i 0; i v.size(); i) { assert(v[i] 42); } v.resize(3); assert(v.size() 3); } void test_copy_and_assign() { my::vectorint v1 {1, 2, 3}; // 需要实现初始化列表构造函数 my::vectorint v2(v1); // 拷贝构造 assert(v2.size() 3); assert(v2[1] 2); my::vectorint v3; v3 v1; // 拷贝赋值 assert(v3.size() 3); assert(v3[2] 3); // 自赋值测试 v3 v3; assert(v3.size() 3); // 应仍然有效 } void test_iterator_invalidation() { my::vectorint v {1, 2, 3, 4}; auto it v.begin() 1; // 指向2 v.push_back(5); // 可能引起扩容 // 此时it已失效不应再使用。测试中我们应避免解引用。 // 正确的做法是重新获取迭代器 it v.begin() 1; assert(*it 2); // 现在使用是安全的 } void test_insert_erase() { my::vectorint v {1, 3, 4}; auto it v.insert(v.begin() 1, 2); // 在3前面插入2 assert(v.size() 4); assert(v[1] 2); assert(*it 2); // insert返回指向新元素的迭代器 it v.erase(v.begin() 2); // 删除元素3 assert(v.size() 3); assert(v[2] 4); assert(*it 4); // erase返回指向被删元素之后的迭代器 } int main() { test_basic(); test_reserve_resize(); test_copy_and_assign(); test_iterator_invalidation(); test_insert_erase(); std::cout All tests passed! std::endl; return 0; }5.3 常见问题排查与性能思考在实现和测试过程中你可能会遇到以下问题内存泄漏最可能发生在拷贝控制函数拷贝构造、赋值和reserve中。确保在任何路径包括异常抛出下已分配的内存最终都被正确释放且每个构造的对象都被析构。使用valgrind或AddressSanitizer等工具进行检测。访问越界在operator[]、front、back、insert、erase中必须对传入的位置参数进行有效性检查使用assert或抛出异常。我们的模拟实现使用了assert在调试模式下能快速发现问题。迭代器失效的隐蔽bug这是逻辑错误编译器不会报错。务必记住导致失效的操作并在编写使用迭代器的循环时格外小心。例如在遍历容器并删除满足条件的元素时必须使用it vec.erase(it)的范式而不是简单的vec.erase(it)后者在vector中可能有问题。异常安全级别我们的reserve实现了强异常安全但insert和erase只实现了基本异常安全。在工业级库中需要更精细的设计来保证所有操作都有最强的异常安全保证。性能热点扩容策略2倍扩容是好的默认选择但对于超大对象或特定场景可能需要不同的策略。元素移动在insert和erase中我们是一个一个元素地移动。对于平凡可复制trivially copyable的类型如int、double可以使用memmove来批量移动内存效率更高。标准库实现通常会做这种优化通过类型特性std::is_trivially_copyable判断。移动语义在C11及以上为你的MyVector实现移动构造函数和移动赋值运算符并在重新分配内存转移元素时如果T的移动构造函数是noexcept的优先使用std::move进行移动构造这可以大幅提升包含string、vector等成员的vector的性能。亲手实现一遍vector你会对“动态数组”、“连续存储”、“迭代器失效”、“深拷贝”、“异常安全”这些概念有刻骨铭心的理解。这远比你读十遍教科书更有效。当你再使用标准库的vector时你会清楚地知道每一次操作背后发生了什么从而写出更高效、更安全的代码。这也是为什么“手撕vector”成为众多C开发者深入理解语言和库设计的必经之路。