
1. 项目概述为什么我们要“手撕”一个vector如果你正在学习C尤其是学到STL标准模板库这一块那么“模拟实现vector”几乎是一个绕不开的经典练习。这就像学木工光看别人做精美的榫卯结构没用你得自己拿起刨子、凿子亲手做一遍才能真正理解木头纹理的走向、榫头的松紧度。模拟实现vector就是C学习路上的“木工活”。vector是STL中最基础、最常用的序列容器它背后封装了动态数组的复杂逻辑——自动扩容、元素搬移、迭代器管理、异常安全等等。很多面试官喜欢问vector的底层原理比如“插入元素时发生了什么”、“迭代器为什么会失效”。如果你只是停留在调用push_back、begin的层面这些问题会让你一头雾水。但如果你亲手实现过一个简陋但五脏俱全的MyVector这些问题就会变得异常清晰因为每一个坑你都亲手踩过每一个边界条件你都反复斟酌过。这个项目就是带你从零开始构建一个属于你自己的MyVector类。我们将从三个指针start,finish,end_of_storage开始一步步实现构造、析构、增删改查、迭代器并直面令人头疼的迭代器失效和异常安全问题。最终你得到的不仅是一个可以运行的代码更是一份对C内存管理、模板编程、RAII资源获取即初始化等核心概念的深刻理解。无论你是为了夯实基础、应对面试还是纯粹享受“造轮子”的乐趣这都是一趟值得投入的旅程。2. 核心架构设计理解vector的“三指针”模型在开始写代码之前我们必须先想清楚vector在内存中长什么样。标准库的实现细节各编译器略有不同但核心思想一致用一段连续的堆内存来存储元素并用三个指针或等价物来标记这块内存的使用状态。2.1 “三指针”模型详解这是模拟实现vector最核心、最经典的设计模式务必理解透彻。_start指向已申请内存块storage的起始位置。这是数组的“头”。_finish指向当前已存储的最后一个元素的下一个位置。也就是说[_start, _finish)这个左闭右开区间内存放着所有有效元素。_finish - _start就等于当前容器的大小size()。_end_of_storage指向已申请内存块的末尾的下一个位置。[_start, _end_of_storage)表示整个容器的容量capacity()。为什么是三个指针两个不行吗比如一个指针两个表示大小和容量的整数当然可以但指针方案在计算地址偏移、进行内存操作时更为直观和高效。例如判断是否需扩容就是比较_finish和_end_of_storage插入元素就是在_finish处构造新对象并移动_finish。templateclass T class MyVector { public: // ... 成员函数 private: T* _start nullptr; // 指向内存块开始 T* _finish nullptr; // 指向最后一个有效元素的下一个位置 T* _end_of_storage nullptr; // 指向内存块末尾的下一个位置 };2.2 关键设计决策与权衡迭代器类型我们选择原生指针T*作为迭代器类型。这是因为对于连续存储的vector其迭代器本质就是指针支持随机访问,-,[]。这简化了实现也符合标准std::vectorT::iterator通常就是T*的别名。如果为了教学你也可以封装一个类来实现迭代器以理解迭代器类的设计模式如重载,*,-等运算符。扩容策略这是vector性能的关键。常见的策略是“倍增”geometric growth即每次容量不足时新容量 旧容量 * 2或1.5。为什么是2这是一个经验值在内存使用效率和减少重新分配次数之间取得了较好的平衡。倍数太小如1.2会导致频繁扩容搬移元素开销大倍数太大如3可能导致内存浪费。标准库并未规定具体倍数但主流实现如GCC的libstdc MSVC通常使用2或1.5。在我们的实现中我们将采用经典的2倍扩容。异常安全这是一个高级话题但必须考虑。我们的目标是提供“基本异常安全”basic exception safety保证如果操作因异常失败容器应保持在一个有效状态且不会发生资源泄漏如内存泄漏。例如在push_back中如果构造新元素时抛出异常我们需要确保_finish指针没有前移容器大小不变已申请的内存正确释放。3. 基础框架与资源管理有了清晰的设计图我们就可以开始打地基了。第一步是搭建类的骨架并处理好最根本的资源——内存的获取与释放。3.1 类模板声明与成员变量vector必须是模板类以存储任意类型的元素。// MyVector.h #pragma once #include cassert // 用于断言检查 #include algorithm // 用于std::copy等算法后续自己实现 namespace my { templateclass T class vector { public: // 类型别名增加可读性并与STL风格保持一致 typedef T* iterator; typedef const T* const_iterator; // 构造函数系列 vector(); explicit vector(size_t n, const T val T()); // 防止隐式转换 templateclass InputIterator vector(InputIterator first, InputIterator last); // 迭代器范围构造 // 拷贝控制拷贝构造、赋值运算符、析构函数 vector(const vectorT v); vectorT operator(vectorT v); // 注意这里使用“拷贝交换”技法参数为值传递 ~vector(); // 迭代器 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; } void reserve(size_t n); // 重点扩容函数 void resize(size_t n, const T val T()); // 调整大小 // 元素访问 T operator[](size_t pos); const T operator[](size_t pos) const; T front() { return *_start; } T back() { return *(_finish - 1); } // 修改操作 void push_back(const T x); void pop_back(); iterator insert(iterator pos, const T x); iterator erase(iterator pos); void clear(); void swap(vectorT v); private: iterator _start nullptr; iterator _finish nullptr; iterator _end_of_storage nullptr; }; }3.2 构造函数、析构函数与初始内存分配构造函数负责初始化状态析构函数负责清理资源。这是RAII思想的直接体现。默认构造函数最简单将所有指针置为nullptr表示一个空容器。templateclass T vectorT::vector() : _start(nullptr), _finish(nullptr), _end_of_storage(nullptr) {}带大小和初始值的构造函数这里有一个初学者极易踩坑的地方。注意第二个参数const T val T()提供了一个默认值T()即类型T的默认构造值。对于内置类型如intint()就是0。templateclass T vectorT::vector(size_t n, const T val) { _start new T[n]; // 第一步申请原始内存 _finish _start n; _end_of_storage _finish; // 第二步在已申请的内存上构造n个对象值为val for (size_t i 0; i n; i) { // 使用“placement new”在指定地址构造对象 // 这是关键直接赋值(_start[i] val)对于非平凡类型可能不安全。 new(_start i) T(val); } }注意这里直接使用了new T[n]。对于拥有非平凡构造/析构函数的类类型这实际上已经调用了n次默认构造函数。然后我们又用placement new和拷贝构造去覆盖它们这存在一定的效率浪费。更精细的实现可以先用operator new分配原始字节内存再循环placement new。但为了代码清晰和教学目的我们采用这种稍显简单的方式并假设T的默认构造函数开销不大。这是一个重要的取舍点。迭代器范围构造函数这是一个模板函数允许用任何输入迭代器范围来初始化vector非常强大。templateclass T templateclass InputIterator vectorT::vector(InputIterator first, InputIterator last) { // 计算范围大小注意InputIterator不一定支持减法所以需要遍历 size_t n 0; InputIterator it first; while (it ! last) { n; it; } // 分配内存 _start new T[n]; _finish _start n; _end_of_storage _finish; // 拷贝元素 iterator dest _start; for (InputIterator src first; src ! last; src, dest) { new(dest) T(*src); // placement new 拷贝构造 } }析构函数必须手动调用每个有效元素的析构函数然后释放内存。templateclass T vectorT::~vector() { if (_start) { // 1. 析构所有有效对象 for (iterator i _start; i ! _finish; i) { i-~T(); // 显式调用析构函数 } // 2. 释放内存块 delete[] _start; _start _finish _end_of_storage nullptr; } }关键点为什么不能直接delete[] _start因为对于类类型delete[]会先调用每个元素的析构函数再释放内存。我们这里显式调用析构是为了和构造函数中使用的placement new配对并且逻辑更清晰。实际上对于用new T[n]分配的内存直接delete[]是安全的。但为了保持模式统一特别是如果我们后续改用operator new分配原始内存显式析构是更好的习惯。4. 核心功能实现增删改查与迭代器基础框架搭好后我们来实现最让vector“好用”的那些功能。4.1 扩容机制 reserve() 的实现这是vector动态性的核心。当size()即将超过capacity()时就需要扩容。templateclass T void vectorT::reserve(size_t n) { if (n capacity()) { // 1. 申请新的、更大的内存块 T* new_start new T[n]; size_t old_size size(); // 2. 将旧数据“移动”到新内存拷贝构造 // 这里必须用拷贝构造而不是简单的内存拷贝(memcpy)。 // 因为对于某些类其拷贝构造函数和赋值运算符可能有特殊语义。 for (size_t i 0; i old_size; i) { new(new_start i) T(_start[i]); // placement new 拷贝构造 } // 3. 析构旧对象并释放旧内存 for (size_t i 0; i old_size; i) { _start[i].~T(); } delete[] _start; // 4. 更新指针 _start new_start; _finish new_start old_size; _end_of_storage new_start n; } // 如果n capacity()则什么都不做 }踩坑记录我曾在这里犯过一个错误试图用memcpy(new_start, _start, sizeof(T) * old_size)来搬运数据。对于像int、double这样的平凡类型POD这确实可以。但对于包含指针、动态内存的类如std::stringmemcpy进行的是浅拷贝会导致新旧两个对象内部的指针指向同一块内存析构时同一块内存被释放两次引发程序崩溃。必须使用拷贝构造或移动语义来转移资源。4.2 push_back() 与 pop_back()push_back在尾部添加元素是vector最常用的操作。templateclass T void vectorT::push_back(const T x) { // 检查是否需要扩容 if (_finish _end_of_storage) { // 计算新容量如果当前容量为0则扩容到1否则倍增。 size_t new_capacity capacity() 0 ? 1 : capacity() * 2; reserve(new_capacity); } // 在_finish指向的位置构造新对象 new(_finish) T(x); // placement new _finish; // 更新大小 }pop_back删除尾部元素相对简单。templateclass T void vectorT::pop_back() { assert(!empty()); // 必须确保容器非空 --_finish; _finish-~T(); // 显式调用析构函数销毁对象 }4.3 随机访问 operator[] 与 at()operator[]不进行边界检查效率高但需要调用者自己保证安全。templateclass T T vectorT::operator[](size_t pos) { assert(pos size()); // 使用断言在调试期检查 return _start[pos]; } templateclass T const T vectorT::operator[](size_t pos) const { assert(pos size()); return _start[pos]; }标准库的vector::at()会进行边界检查越界时抛出std::out_of_range异常。我们可以类似实现templateclass T T vectorT::at(size_t pos) { if (pos size()) { throw std::out_of_range(vector::at); } return _start[pos]; }4.4 insert() 与 erase() 及其引发的迭代器失效问题这是模拟实现中最复杂、最体现功力的部分因为它们直接导致了经典的“迭代器失效”问题。insert在指定位置插入元素templateclass T typename vectorT::iterator vectorT::insert(iterator pos, const T x) { // 断言检查pos是否在有效范围[_start, _finish]内 assert(pos _start pos _finish); // 1. 检查扩容 if (_finish _end_of_storage) { // 关键步骤扩容会导致原有内存被释放pos指针失效 // 我们必须记录pos相对于_start的偏移量。 size_t offset pos - _start; size_t new_capacity capacity() 0 ? 1 : capacity() * 2; reserve(new_capacity); // 扩容后更新pos为新内存中的正确位置 pos _start offset; } // 2. 将pos及其后的所有元素向后移动一位 // 必须从后往前移动避免覆盖未移动的元素 iterator end _finish; while (end pos) { // 在end位置构造一个由前一个位置移动而来的对象 // 使用移动语义(std::move)可以避免不必要的拷贝提高效率。 new(end) T(std::move(*(end - 1))); // 析构前一个位置的旧对象其资源已被移走 (end - 1)-~T(); --end; } // 3. 在pos位置构造新对象 new(pos) T(x); // 4. 更新_finish _finish; // 5. 返回指向新插入元素的迭代器 return pos; }迭代器失效详解注意代码中的注释。如果在插入前发生了扩容reserve那么pos这个迭代器本质是指针指向的是旧内存地址。旧内存被释放后pos就成了一个“野指针”dangling pointer。后续对pos的解引用或操作都是未定义行为通常会导致程序崩溃。我们的解决方案是在扩容前计算pos相对于容器头部的偏移量offset pos - _start扩容后用新的_start加上这个偏移量得到在新内存中对应的正确位置并更新pos。这就是为什么标准规定在vector中插入元素可能导致所有迭代器失效的原因。即使不扩容插入点之后的迭代器也会因为元素移动而失效。erase删除指定位置元素templateclass T typename vectorT::iterator vectorT::erase(iterator pos) { assert(pos _start pos _finish); // pos不能等于_finish // 1. 将pos1及其后的元素向前移动一位覆盖pos位置的元素 iterator it pos 1; while (it ! _finish) { // 在it-1位置构造一个由it位置移动而来的对象 new(it - 1) T(std::move(*it)); // 析构it位置的旧对象 it-~T(); it; } // 2. 更新_finish --_finish; // 3. 返回指向被删除元素下一个位置的迭代器 // 注意如果删除的是最后一个元素则返回end() return pos; }迭代器失效详解对于erase被删除元素及其之后的所有迭代器都会失效。因为后面的元素都向前移动了。标准库的erase会返回一个新的迭代器指向被删除元素原来所在位置的下一个元素移动后的新元素。这个设计是为了方便在循环中连续删除元素// 正确用法使用erase的返回值更新迭代器 for (auto it v.begin(); it ! v.end(); ) { if (*it % 2 0) { it v.erase(it); // it指向下一个元素 } else { it; } } // 错误用法在循环中直接it会导致迭代器失效 for (auto it v.begin(); it ! v.end(); it) { if (*it % 2 0) { v.erase(it); // 错误erase后it失效再it行为未定义 } }4.5 resize() 调整容器大小resize用于增大或缩小容器。如果新大小n大于当前size()则用val填充多出的位置如果小于则丢弃尾部的元素。templateclass T void vectorT::resize(size_t n, const T val) { if (n size()) { // 需要增大 if (n capacity()) { // 如果新大小超过容量需要扩容 reserve(n); } // 在[_finish, _startn)区间内构造新元素 for (iterator i _finish; i ! _start n; i) { new(i) T(val); } _finish _start n; } else if (n size()) { // 需要缩小 // 析构尾部多余的元素 for (iterator i _start n; i ! _finish; i) { i-~T(); } _finish _start n; } // n size() 时什么都不做 }5. 进阶实现拷贝控制与“拷贝交换”技法让我们的MyVector能够像内置类型一样正确地进行拷贝和赋值是成为合格容器的关键。这里我们实现一个高效且异常安全的方案。5.1 传统的拷贝构造与赋值重载拷贝构造函数深拷贝。为新对象申请一块独立的内存并将原对象的所有元素拷贝过来。templateclass T vectorT::vector(const vectorT v) { _start new T[v.capacity()]; // 按v的容量申请内存 _finish _start v.size(); _end_of_storage _start v.capacity(); // 拷贝元素 for (size_t i 0; i v.size(); i) { new(_start i) T(v._start[i]); // 拷贝构造 } }赋值运算符重载传统写法templateclass T vectorT vectorT::operator(const vectorT v) { if (this ! v) { // 防止自赋值 // 1. 释放当前资源 for (iterator i _start; i ! _finish; i) { i-~T(); } delete[] _start; // 2. 申请新资源并拷贝 _start new T[v.capacity()]; _finish _start v.size(); _end_of_storage _start v.capacity(); for (size_t i 0; i v.size(); i) { new(_start i) T(v._start[i]); } } return *this; }传统写法的问题在于代码重复和拷贝构造类似且不是异常安全的。如果在申请新内存或拷贝元素过程中抛出异常*this对象原有的资源已被释放对象将处于一个无效状态。5.2 更优雅的“拷贝交换”技法Copy-and-Swap Idiom这是一种现代C中非常优雅的实现方式能自动提供强异常安全保证并且代码简洁。templateclass T vectorT vectorT::operator(vectorT v) { // 注意参数是值传递会调用拷贝构造函数 swap(v); // 交换*this和临时对象v的内容 return *this; // 函数结束临时对象v被销毁其析构函数会释放原先*this持有的旧资源。 }工作原理vectorT v是值参数传入时会调用拷贝构造函数生成一个原对象的完整副本。如果拷贝构造失败比如内存不足异常会在修改*this之前抛出*this保持不变——强异常安全。调用swap函数交换*this和临时对象v的内部指针。这个操作不会抛出异常交换指针是简单的赋值。函数返回临时对象v现在持有*this的旧数据被析构自动清理旧资源。我们需要实现一个高效的swap成员函数templateclass T void vectorT::swap(vectorT v) { // 只需交换三个指针效率极高 std::swap(_start, v._start); std::swap(_finish, v._finish); std::swap(_end_of_storage, v._end_of_storage); }同时我们还可以在命名空间作用域提供一个非成员函数的swap重载这符合STL的惯例并且能被一些标准库算法更高效地使用。namespace my { templateclass T void swap(vectorT lhs, vectorT rhs) { lhs.swap(rhs); } }6. 调试、测试与常见问题实录代码写完了但让它正确运行才是真正的挑战。下面分享一些我在调试这个MyVector时遇到的典型问题和解决方法。6.1 基础功能测试用例编写全面的测试是保证代码质量的关键。以下是一些核心测试场景// test_myvector.cpp #include MyVector.h #include iostream #include string using namespace my; using std::cout; using std::endl; void Test1_Basic() { cout Test 1: 基础构造与push_back endl; vectorint v1; cout size: v1.size() , capacity: v1.capacity() endl; // 0, 0 v1.push_back(1); v1.push_back(2); v1.push_back(3); cout size: v1.size() , capacity: v1.capacity() endl; // 3, 4 (假设初始扩到1然后2然后4) for (size_t i 0; i v1.size(); i) { cout v1[i] ; // 1 2 3 } cout endl; } void Test2_IteratorAndRangeFor() { cout \n Test 2: 迭代器与范围for endl; vectorstd::string v2; v2.push_back(Hello); v2.push_back(World); v2.push_back(!); // 使用迭代器 for (vectorstd::string::iterator it v2.begin(); it ! v2.end(); it) { cout *it ; } cout endl; // 使用范围for (依赖于begin()和end()) for (const auto s : v2) { cout s ; } cout endl; } void Test3_InsertEraseAndIteratorInvalidation() { cout \n Test 3: Insert/Erase与迭代器失效 endl; vectorint v3; for (int i 0; i 5; i) v3.push_back(i); // 0 1 2 3 4 auto it v3.begin() 2; // it指向元素2 cout *it before insert: *it endl; // 插入可能导致扩容 v3.insert(v3.begin() 1, 99); // 在位置1插入99 - 0 99 1 2 3 4 // 此时it可能失效不能直接使用。 // 正确做法是使用insert返回的新迭代器或者重新计算 it v3.begin() 3; // 重新获取指向原来元素2现在在位置3的迭代器 cout *it after insert: *it endl; // 应该是2 // 测试erase it v3.erase(v3.begin()); // 删除第一个元素0it指向新的第一个元素99 cout After erase begin, *returned_it: *it endl; for (auto num : v3) cout num ; // 99 1 2 3 4 cout endl; } void Test4_CopyAndSwap() { cout \n Test 4: 拷贝构造与赋值 endl; vectorint v4; v4.push_back(10); v4.push_back(20); vectorint v5(v4); // 拷贝构造 cout v5 (copy of v4): ; for (auto n : v5) cout n ; // 10 20 cout endl; vectorint v6; v6 v5; // 赋值运算符 cout v6 (assigned from v5): ; for (auto n : v6) cout n ; // 10 20 cout endl; // 测试自赋值 v6 v6; cout After self-assignment, v6: ; for (auto n : v6) cout n ; // 应该还是 10 20 cout endl; } int main() { Test1_Basic(); Test2_IteratorAndRangeFor(); Test3_InsertEraseAndIteratorInvalidation(); Test4_CopyAndSwap(); return 0; }6.2 典型问题与排查技巧程序崩溃Segmentation Fault可能原因1访问越界。在operator[]、at、front、back、insert、erase中没有对传入的位置参数进行有效性检查。解决在调试版本中使用assert在at中使用异常。可能原因2迭代器失效后仍在使用。最常见于insert/erase之后或者在循环中修改容器。解决牢记insert/erase后相关迭代器失效的规则使用函数返回的新迭代器。可能原因3内存管理错误。new和delete不匹配如用new[]分配却用delete释放或对已释放的内存进行操作。解决确保new[]对应delete[]并在析构函数和reserve中正确配对placement new和显式析构调用。内存泄漏Memory Leak可能原因析构函数没有正确释放内存或者在reserve、resize、赋值运算符中申请了新内存但忘记释放旧内存。解决使用ValgrindLinux或Dr. MemoryWindows等工具检测。确保每个new[]都有对应的delete[]并且在重新分配前释放旧资源。元素值错误或重复析构可能原因在reserve或拷贝时错误地使用了memcpy导致浅拷贝。对于管理资源的类如std::string、std::vector这会导致多个对象内部指针指向同一块内存一个对象析构后其他对象内部的指针就悬空了。解决永远不要对非平凡non-trivial类型使用memcpy进行“拷贝”。坚持使用拷贝构造或移动语义。扩容策略导致的性能问题现象在大量push_back时程序速度很慢。排查在reserve函数中加入打印语句观察扩容频率。如果初始容量为0插入N个元素采用倍增策略大约会发生log2(N)次扩容和元素搬移。如果觉得性能可以接受则策略合理。如果对性能有极致要求可以考虑在构造时用reserve预分配足够大的空间避免中间多次扩容。6.3 性能优化思考我们实现的只是一个教学版的vector离标准库的实现还有距离。以下是一些可以继续优化的方向移动语义C11为类添加移动构造函数和移动赋值运算符当传入右值如临时对象时可以“窃取”其资源避免深拷贝大幅提升效率。vector(vector v) noexcept; // 移动构造 vector operator(vector v) noexcept; // 移动赋值同时push_back可以重载一个接受右值引用的版本void push_back(T x)使用std::move构造新元素。完美转发C11emplace_back函数可以直接在容器尾部构造对象无需先创建临时对象再拷贝/移动。这需要用到可变参数模板和完美转发。template class... Args void emplace_back(Args... args) { // 检查扩容... new (_finish) T(std::forwardArgs(args)...); // 原地构造 _finish; }使用std::allocator标准库的容器通常不直接使用new/delete而是通过一个“分配器”allocator来分配和释放内存。这提供了更大的灵活性可以将内存分配与对象构造分离也支持自定义内存池。更精细的异常安全我们的实现提供了基本异常安全。标准库的实现通常追求“强异常安全”strong exception safety即操作要么成功要么完全失败容器状态保持不变。这需要更复杂的资源管理技巧如“先拷贝后交换”。亲手实现一遍vector就像完成了一次对C核心机制的深度解剖。你不再是一个STL的普通用户而是成为了它的“知情者”。下次当你再使用std::vector时你脑海中会清晰地浮现出那三个指针的舞动理解每一次push_back背后可能发生的扩容拷贝并对迭代器失效保持本能的警惕。这份从底层构建起来的认知是阅读任何教科书都无法替代的。