1. 先想清楚要做什么:模拟vector的核心思路
1.1 为什么拿vector练手
我接触C++大概一年多的时候,发现一个很奇怪的现状——写了几百道题、调了不少接口,但真要说清楚vector里到底发生了什么,脑子里其实是一片空白。后来跟着项目代码看的时候,发现很多性能问题、诡异崩溃和unsigned char到底存了什么根本对不上号,归根结底就是“会用但不理解”。
拿vector做练手项目是我见过性价比最高的选择。它不像map那样要考虑红黑树旋转变色,也不像shared_ptr那样牵扯控制块和原子操作,vector的底层核心其实就三件事:一块连续内存、一个记录有效元素个数的变量、一个记录最多能放几个的变量。把这三个东西管明白了,整个容器就落地了一大半。
更关键的是,模拟实现vector能让你把“值语义”这个概念彻底变成肌肉记忆。C++最容易被黑的地方就是隐式拷贝、临时对象和资源泄漏,而这些坑在vector实现里几乎全都能踩一遍。我自己写完这个项目之后,再看普通的业务代码,哪里会隐式拷贝、哪里会悬垂,一眼就能扫出来,这种感觉三百道练习题都给不了。
1.2 三个指针一亩地:存储结构设计
正经的vector实现里,三个指针基本是标配:
start:指向数组起始位置finish:指向当前有效元素的末尾,也就是最后一个元素的下一个位置end_of_storage:指向整个容量空间的末尾
size就是finish - start,capacity就是end_of_storage - start。这个做法不只是为了省两个成员变量,而是让迭代器可以直接退化成裸指针。你想想,如果迭代器是一个封装好的类对象,每次遍历都走迭代器的++重载函数,即便编译优化开了也没法保证百分之百内联,性能损耗是实打实的。
使用原生指针作为vector迭代器还有一个隐藏好处:能直接和C语言接口对接。比如你要把vector的数据传给一个void*参数的老牌C库函数,直接传&vec[0]就好。这个特性在新手写代码时特别容易被忽略,等到实际工程里要接第三方库的时候才发现,当初设计的迭代器结构帮忙省了不少事。
1.3 从使用端倒推接口清单
动手写代码之前,我习惯先把平时用vector的习惯列一个清单:
- 构造空vector,然后不断加数据
- 用下标随机访问某个元素
- 用范围for遍历所有数据
- 取首尾元素、获取元素个数
- 在指定位置插入一条数据
- 删除末尾或者中间某个位置的数据
- 预先知道大概容量时直接reserve,避免频繁扩容
从这份清单倒推回去,我至少需要实现这些成员:
template <typename T> class my_vector { public: // 类型别名 using value_type = T; using iterator = T*; // 原生指针即迭代器 using const_iterator = const T*; using reference = T&; using const_reference = const T&; using size_type = size_t; // 构造与析构 my_vector(); my_vector(size_type n, const T& val = T()); my_vector(const my_vector& other); my_vector& operator=(const my_vector& other); ~my_vector(); // 容量相关 size_type size() const; size_type capacity() const; bool empty() const; void reserve(size_type n); void resize(size_type n, const T& val = T()); // 元素访问 reference operator[](size_type i); const_reference operator[](size_type i) const; reference front(); reference back(); // 修改操作 void push_back(const T& val); void pop_back(); iterator insert(iterator pos, const T& val); iterator erase(iterator pos); void clear(); // 迭代器 iterator begin(); iterator end(); const_iterator begin() const; const_iterator end() const; };这个清单不算长,但每一个接口背后都有坑。比如resize和reserve很多人分不清,insert的迭代器参数在插入之后为什么会失效,这些我都会在后面的实现细节里展开说。
2. 从零手写:成员变量与基础骨架
2.1 模板参数与三个指针的定义
模板参数这里,我直接用template <typename T>,先不做allocator模板参数的适配。为什么?因为std::allocator<T>本身又会对operator new和operator delete包一层,如果不理解底层分配逻辑,直接照着实现反而更糊涂。先从裸内存开始,等跑通基础功能后再回头研究定制化分配器,顺路还能理解内存池是怎么回事。
三个指针的定义如下:
template <typename T> class my_vector { private: T* start_; T* finish_; T* end_of_storage_; };从一个空vector的角度思考,三个指针全为nullptr是合理的初始状态。但有一个细节值得一提——当vector为空时,begin()应该等于end(),而且end()返回的仍是合法指针。因此很多实现会把空vector的指针统一指向同一块占位地址,而不是全部置nullptr。我自己的简易版本直接在构造函数里统一设为nullptr,保证begin() == end()成立,也就是空容器满足end() - begin() == 0,多数场景下够用。
finish_指向的是最后一个元素的下一个位置,这个设计决定了很多操作的写法:
size() == finish_ - start_end() == finish_push_back时先写数据,再让finish_向后移动一位
理解这个“半开区间”的语义,后面写insert和erase才不会晕。
2.2 构造与析构:RAII思想
构造主要分两种情况。一种是无参构造空vector,另一种是给定初始大小并填充默认值。如果已经给了初始大小n,就得一次性分配n个元素的空间:
template <typename T> my_vector<T>::my_vector(size_type n, const T& val) : start_(nullptr), finish_(nullptr), end_of_storage_(nullptr) { if (n > 0) { start_ = new T[n]; finish_ = start_ + n; end_of_storage_ = finish_; for (size_type i = 0; i < n; ++i) { start_[i] = val; } } }这里有个很多新手会踩的坑:局部变量T temp在堆上创建是有代价的,如果T是一个重量级类型,比如一个内部持有vector的类,那么每次填充都要触发一次临时构造和拷贝。在标准库的vector(size_type n, const T& val)实现里,这种问题通常用uninitialized_fill_n配合placement new解决——只调用拷贝构造而不产生额外的中间临时变量。不过在简易版本里先用赋值初始化没毛病,把逻辑先跑通才是重点。
析构函数则是释放整块数组:
template <typename T> my_vector<T>::~my_vector() { delete[] start_; start_ = finish_ = end_of_storage_ = nullptr; }直接delete[] start_就能正确释放,是因为new T[n]和delete[]彼此配对。如果当初用malloc分配的内存就不能这么释放,得手动调用析构函数再free。这里其实预告了一个大问题:标准库vector为了支持不完整类型、异常安全和自定义分配器,用的是allocator::allocate加placement new的组合,而不是简单的new T[n]。等你想让模拟vector支持reserve之后插入元素而不默认构造对象时,就会逼着自己去了解placement new。
2.3 拷贝控制:深拷贝与赋值运算符
拷贝构造其实是一个思维分水岭。浅拷贝的写法就是把三个指针直接赋值,几秒钟就写完了,但这种做法会让新旧两个vector指向同一块堆内存。接下来任何一个对象的析构函数先执行,另一个对象的指针就成了悬垂指针,使用它直接引发未定义行为。
正确实现深拷贝的代码如下:
template <typename T> my_vector<T>::my_vector(const my_vector& other) { if (other.empty()) { start_ = finish_ = end_of_storage_ = nullptr; return; } size_type n = other.size(); start_ = new T[n]; for (size_type i = 0; i < n; ++i) { start_[i] = other.start_[i]; } finish_ = start_ + n; end_of_storage_ = finish_; }赋值运算符可以走一个经典写法——拷贝并交换。先构造一个临时对象,再把它和当前对象交换,因为临时对象析构时会释放旧资源,这样既保证了异常安全(构造临时对象失败时当前对象不变),代码量还特别少:
template <typename T> my_vector<T>& my_vector<T>::operator=(const my_vector& other) { if (this != &other) { my_vector temp(other); swap(temp); } return *this; } template <typename T> void my_vector<T>::swap(my_vector& other) noexcept { using std::swap; swap(start_, other.start_); swap(finish_, other.finish_); swap(end_of_storage_, other.end_of_storage_); }这里的swap为什么不能偷懒直接赋值?因为如果先释放当前资源再赋值,一旦分配新内存失败,当前对象已经处于半损坏状态,异常安全性就被破坏了。而先构造temp再swap,保证“要么全部成功,要么保持原样”,这是C++异常安全里强保证实现的基础思路。
3. 核心操作实现与细节
3.1 push_back与扩容机制
push_back的实现逻辑非常直观:如果还有剩余容量就直接放进去,容量不足就扩容。
template <typename T> void my_vector<T>::push_back(const T& val) { if (finish_ == end_of_storage_) { size_type old_capacity = capacity(); size_type new_capacity = old_capacity == 0 ? 1 : old_capacity * 2; reserve(new_capacity); } *finish_ = val; ++finish_; }扩容为什么是翻倍而不是固定加一个固定值?这涉及均摊时间复杂度的经典判断。假设初始容量为1,每次容量不够时扩大到2倍,那么插入第n个元素时总共的需要拷贝次数是1 + 2 + 4 + ... + n/2,等比级数求和大约是2n,均摊下来每次push_back的时间复杂度还是O(1)。但如果固定加10个,拷贝次数就会是10 + 20 + 30 + ...,均摊是O(n)。
我在项目里实际试过:当数据量达到几十万级别时,固定增量扩容比倍增扩容慢了一个数量级,这就是为什么标准库选择指数增长而非线性增长。不过现实中大多数标准库也不会一直翻倍,而是用1.5倍这种更保守的因子来缓解内存碎片和浪费,但原理殊途同归。
扩容时要注意元素搬移。很多人直接写memcpy,这碰到内置类型比如int、double没问题,但碰到string或自定义类就是灾难——memcpy是逐字节浅拷贝,会导致两个对象指向同一块内部缓冲,析构时造成double free。正确的做法是循环赋值,让拷贝赋值运算符来介入。代码里也可以用uninitialized_copy来干这件事,那是标准库的底层函数,后面几节我会单独提。
3.2 reserve的核心逻辑
reserve的作用是提前分配足够的内存空间,避免多次扩容的拷贝成本。它的实现逻辑是:
template <typename T> void my_vector<T>::reserve(size_type n) { if (n <= capacity()) return; T* new_start = new T[n]; size_type old_size = size(); for (size_type i = 0; i < old_size; ++i) { new_start[i] = start_[i]; } delete[] start_; start_ = new_start; finish_ = start_ + old_size; end_of_storage_ = start_ + n; }你留意到没有,我这里的做法用了new T[n],意味着那n个元素全部被默认构造了。对内置类型来说几乎没有成本,但对自定义类型来说,哪怕你只往里塞一个元素,都要白做n次默认构造——这是一种浪费。
标准库的做法是:先用allocator::allocate分配一块裸内存,然后在目标位置用placement new调拷贝构造,跳过默认构造这一步。这部分我在后面“常见问题”里细说,因为它直接关系到你能不能处理那些无法默认构造的类型。
3.3 insert插入与元素搬移
insert的实现是所有操作里最容易翻车的。它分为两步:先判断容量够不够,不够就扩容;然后把pos到finish_之间的元素依次后移一位,最后在pos处赋值新元素。
template <typename T> typename my_vector<T>::iterator my_vector<T>::insert(iterator pos, const T& val) { if (pos < start_ || pos > finish_) { throw std::out_of_range("invalid insert position"); } if (finish_ == end_of_storage_) { size_type new_capacity = capacity() == 0 ? 1 : capacity() * 2; size_type offset = pos - start_; // 记录偏移 reserve(new_capacity); pos = start_ + offset; // 扩容后修正指针 } for (iterator it = finish_; it != pos; --it) { *it = *(it - 1); } *pos = val; ++finish_; return pos; }这段代码里最容易忽略的一个细节是:在reserve之后,原来的pos指针已经失效了——旧内存被delete,新内存已经分配完毕,原指针指向的地址是垃圾数据。所以必须提前记录offset,扩容完成后用start_ + offset重新定位。
还有一个细节要说明:for循环向前搬移数据时,是从后往前搬的。如果你从前往后搬,就会把后一个元素覆盖掉还没搬走的前一个元素,整个数组就乱了。这类细节只靠阅读代码很容易漏掉,自己动手时出一次bug,就再也不会忘了。
这里再强调一个点:insert时的插入位置其实可以是end()位置,即“尾巴后面”,这时候pos == finish_,循环体直接跳过,再执行*finish_ = val; ++finish_,这其实就是一次push_back。所以insert本身是一个比push_back更泛化的操作,但直接调insert的频繁往复里,显然不如专门的push_back实现简洁高效。
3.4 erase删除与元素搬移
erase操作的核心是“把后面的元素往前搬”,然后让finish_减一。
template <typename T> typename my_vector<T>::iterator my_vector<T>::erase(iterator pos) { if (pos < start_ || pos >= finish_) { throw std::out_of_range("invalid erase position"); } for (iterator it = pos; it != finish_ - 1; ++it) { *it = *(it + 1); } --finish_; return pos; }这个版本有几个问题要斟酌:第一,被删除位置的元素析构?其实不用,因为finish_减一后,最后那个元素只是“逻辑上”被移出了容器——它的老值还活在那一格内存里,但容器根本碰不到它,也不会主动释放它。等整体析构时,delete[]会把整块内存回收,这时候那个格的析构只触发一次,不需要特殊处理。
第二,T本身可能有内部资源,比如string对象的堆缓冲,如果我一直往前搬,旧缓冲还在吗?只要析构函数正确执行,搬运过程中产生的临时对象和最后被覆盖的对象都会自动释放它们持有的资源。这里要特别留意代入自定义类实战时的“自赋值”现象——比如*it = *(it + 1)里两个位置不是同一对象时没问题,但自己对自己赋值时,某些实现可能会先释放右侧的buffer,导致右侧数据先被毁掉,之后是从未定义行为里拷贝的。标准库的string::operator=通常有自赋值保护,但你自己写的类可未必有。所以如果目标类型是自定义且自带operator=,重载时最好也写成if (this != &rhs)保护一下。
3.5 operator[]与迭代器接口
operator[]的实现非常简单:
template <typename T> T& my_vector<T>::operator[](size_type i) { return start_[i]; }返回引用是必须的,否则你无法通过vec[0] = x来修改元素。const版本返回const T&,保护数据不被修改。
迭代器接口就更有意思了:
template <typename T> typename my_vector<T>::iterator my_vector<T>::begin() { return start_; } template <typename T> typename my_vector<T>::iterator my_vector<T>::end() { return finish_; }因为我把迭代器定义为裸指针,所以范围for循环、反向迭代器、std::sort函数模板都天然支持。std::distance这类泛型算法直接在指针上调就行,不需要额外适配。这也是vector迭代器和list迭代器最大的差别:vector的迭代器一旦失效,所有依赖它的算法全都白搭;list的操作则相对独立。
4. 常见问题与踩坑实录
4.1 迭代器失效规则:必须刻进脑子里的记忆点
迭代器失效是模拟vector过程中绕不开的核心问题。翻翻cppoerator的文档就知道,push_back或insert一旦触发扩容,整个迭代器全部失效;pop_back会让指向末尾元素的迭代器失效;insert会在插入点之后失效;erase会让被删位置及之后的迭代器全部失效。
我用一个非常惨痛的实际案例来记住这条规则。当时我的项目代码里有个循环:
for (auto it = vec.begin(); it != vec.end(); ++it) { if (condition(*it)) { vec.erase(it); } }这段代码在Debug模式下时好时坏,Release模式必崩,典型未定义行为。问题是erase之后it已经失效,再执行++it就是拿失效指针自增,这是内存破坏的来源。正确写法是:
for (auto it = vec.begin(); it != vec.end();) { if (condition(*it)) { it = vec.erase(it); } else { ++it; } }因为erase返回的是删除位置的下一个元素位置,这样就能继续迭代。
我建议你自测时写一个小测试:边push_back边记录一个迭代器,扩容后打印迭代器指向的内容,你会发现数据全都乱了。这个过程印象远胜于背规则。
4.2 扩容时内存搬移为什么不能无脑memcpy
这里必须把memcpy和循环赋值的区别讲透。
memcpy做的是逐字节拷贝,不调用任何构造函数、赋值运算符。对内置类型来说完全没问题,因为int、double的表示就是一个比特序列,拷贝出来就是合法值。但T如果是std::string呢?string内部包含一个指向堆内存的指针和长度等信息,memcpy会把指针值原样复制一份,结果就是新旧两个string对象指向同一块字符缓冲区。接着旧string析构释放缓冲区,新string就悬了;新string析构再释放一次,直接double free,程序崩得莫名其妙。
// 错误的做法:直接memcpy搬移元素 // memcpy(new_start, start_, sizeof(T) * old_size); // 正确的做法:逐个调用拷贝赋值 for (size_type i = 0; i < old_size; ++i) { new_start[i] = start_[i]; }说到底,C++对象是有构造和析构语义的,但memcpy是C语言时代的思维,它只认字节。只有通过专门的std::is_trivially_copyable<T>::value判断之后,才能在某些场景下安心memcpy。标准库的std::vector在实现时通常会做类型萃取,对平凡可拷贝类型走memcpy路径,对复杂类型走构造赋值路径。这样性能和安全才能兼得。
4.3 无法默认构造的元素类型
标准库的实现可以在reserve时只分配裸内存不构造对象,这个“魔法”依赖的是allocator::allocate和placement new的组合。假如你没有这一步,模拟vector就有一个隐藏限制:元素类型必须可以默认构造。
写过Qt或某些特殊框架的开发者应该能体会这个问题——有些类型根本没有默认构造函数,只有定了参数才能构造。你写new T[n]时,编译器要求T有默认构造函数才能分配内存,否则代码直接编译不过。标准库vector就没有这个限制,因为它在reserve阶段不构造任何元素。
更严谨的处理方式是引入std::allocator和placement new:
#include <memory> template <typename T> class my_vector { std::allocator<T> alloc_; T* start_; T* finish_; T* end_of_storage_; }; // reserve 重新实现 T* new_start = alloc_.allocate(n); // 只分配内存,不构造对象 for (size_type i = 0; i < old_size; ++i) { alloc_.construct(new_start + i, start_[i]); // placement new 构造 } // 释放旧内存之前,先逐个析构旧元素 for (size_type i = 0; i < old_size; ++i) { alloc_.destroy(start_ + i); } alloc_.deallocate(start_, old_capacity);alloc_.construct实际上就是在指定地址上调用::new((void*)p) T(args...)。这就是标准的placement new用法。它允许你在一块未初始化的裸内存上显式构造对象。整个流程相当于“挖地基→砌砖→装修→拆房”,而不是“边盖房边决定要几层”。
我把这个步骤放在最后讲,是因为如果你直接学习标准库版本的实现,很容易被allocator、construct、destroy这些术语劝退。先写简单版跑通逻辑,再升级内存管理模型,尤其适合做这个项目来渐进式理解C++抽象层次。
4.4 自赋值与异常安全
赋值运算符很容易被忽略的边界情况是vec = vec自己给自己赋值。如果实现里先释放旧内存再拷贝,自己给自己赋值时会直接释放掉自己的数据,再拷贝就是空拷贝。这种问题在代码审查时不仔细看根本发现不了,只有数据量大、触发特定分支时才会露头。
我实现operator=时的策略是“拷贝后交换”,前面已经展示过了。这不仅是自赋值安全的,在构造临时对象失败时也能保证当前对象数据不丢。C++社区管这个叫“copy-and-swap”,是异常安全的标准范式。
顺带提醒一句:reserve时需要先构造新内存,再删除旧内存,这个顺序不能反过来。否则一旦循环赋值时抛出异常,旧数据已经没了,对象就处于半更新状态。所以reserve里的顺序永远是:申请新数组→逐个赋值→删除旧数组。
4.5 验证方案和最终完整代码
写完后建议建一个测试用例集,数据量从1到几万依次压测,每次增删后打印size、capacity和全部元素:
void print(my_vector<int>& v) { std::cout << "size=" << v.size() << " cap=" << v.capacity() << ": "; for (auto x : v) std::cout << x << ' '; std::cout << '\n'; }测试用例至少覆盖这些场景:
- 空vector的begin/end、front/back是否越界
- push_back触发扩容后,旧迭代器是否失效(预期:失效)
- insert到begin、end、中间位置三种情况
- erase到头、尾、中间三种情况
- 拷贝构造、赋值运算符在数据量很大时是否正确
- 用自定义类如
std::string测试元素搬移,观察是否出现double free
我实际跑下来发现,写测试花费的时间和写实现差不多,但帮助非常大。因为很多边界情况和断言失败只有在实测中才能暴露出来,也正因如此,我才真正理解了为什么标准库会把“迭代器失效”写得那么细。
整个项目做完后,可以继续扩展的方向还有:右值引用版本的push_back和构造函数、移动语义支持、emplace_back函数、对自定义分配器的支持、反向迭代器适配。每一步都是新的深入点,但最核心的地基始终是这三件事:连续内存的管理、三个指针的维护、元素生命周期控制。把这三件事吃透,再回头看linux内核链表、Rust的Vec实现甚至Go的slice设计,都会有一种豁然开朗的亲近感。