C++ vector底层源码三大原生指针封面

把代码写进星轨,用逻辑丈量宇宙。

导航 链接
个人主页 🏠 星轨初途
基础语言专栏 💻 C语言📚 数据结构
C++ 进阶专栏 🏆 C++学习(竞赛类)⚙️ C++专栏(开发类)
刷题实战专栏 🚀 算法及编程题分享

C++ vector源码手撕底层实现引导动图

环境声明:本文涉及的所有 C++ 源码模拟实现与性能压测,均基于 C++11 标准,并在 Visual Studio 2022 (MSVC) / Linux GCC 编译环境下测试通过。


前言

在上一篇文章中,我们详细讲解了【C++进阶】vector 类从入门到精通:核心接口与内存机制实战指南。为了真正把 vector 吃透,做到“知其然更知其所以然”,本篇我们将深入 C++ 标准库的底层,从零开始手动模拟实现一个 vector

一、vector 的基本实现框架

vector 类似于 string 类,本质上是一个动态管理连续内存的顺序表。但在底层设计上,vector 并没有像常规数据结构那样使用 sizecapacity 两个整数来管理大小,而是巧妙地使用了**三个原生指针(迭代器)**来掌控整个连续的内存块:

  • _start:指向内存块的起始位置(即第一个有效元素的地址);
  • _finish:指向有效元素的末尾(即最后一个元素的下一个位置);
  • _end_of_storage:指向底层分配的内存块的末尾(即容量的边界)。

C++

namespace xingguichutu
{
    template<class T>
    class vector
    {
    public:
        // 后续的成员函数实现...
        
    private:
        // 【核心底层的三个大将】
        // C++ 标准库的 vector 本质上并不是靠 size 和 capacity 来管理的,
        // 而是单纯依靠这三个原生指针来掌控整个连续的内存空间。

        iterator _start = nullptr;          // 指向分配的内存空间的起始位置
        iterator _finish = nullptr;         // 指向有效数据的尾部
        iterator _end_of_storage = nullptr; // 指向整个已分配容量的尾部
    };
}

💡 核心提示:模板类的声明与定义

实现 vector 必须使用类模板(template<class T>),因为 vector 需要存放各种不同的数据类型。由于模板的特殊机制,强烈建议不要将声明和定义分离到 .h.cpp 两个文件中,否则极易引发链接错误。具体原因可参考我的另一篇文章:【C++进阶】让编译器替你打工!一文打通泛型编程与 C++ 模板

二、基本功能实现

1. 迭代器支持

由于 vector 的底层是连续的物理空间,因此它的迭代器其实就是原生指针。实现了迭代器,我们就自然而然地支持了范围 for 循环和泛型算法。

C++

public:
    // 在我们手写的 vector 中,迭代器的本质就是原生指针。
    typedef T* iterator;             // 可修改元素的迭代器
    typedef const T* const_iterator; // 只读迭代器,保障 const 对象的安全访问

    iterator begin() { return _start; }
    iterator end() { return _finish; } // 注意:end 指向的是最后一个有效数据的【下一个位置】

    const_iterator begin() const { return _start; }
    const_iterator end()   const { return _finish; }

2. 常见容量接口访问

借助指针的算术运算,我们可以非常优雅地算出容量和大小。

原理科普:在 C++ 中,同类型的两个指针相减,结果并不是它们之间相差的“字节数”,而是它们之间相隔的元素个数

C++

    // 获取当前有效数据个数
    size_t size() const { return _finish - _start; }           
    
    // 获取当前底层的总容量
    size_t capacity() const { return _end_of_storage - _start; }   
    
    // 判空:首尾指针相遇即为空
    bool empty() const { return _finish == _start; }          

三、默认成员函数的实现

1. 默认构造

利用 C++11 提供的 = default 语法,我们可以强制编译器生成默认的无参构造函数。因为我们在 private 声明时已经给三个指针赋予了 nullptr 的缺省值,所以这样写既简洁又安全。

C++

    // 强制编译器生成默认的无参构造函数
    vector() = default;

2. 拷贝构造

拷贝构造的核心在于深拷贝。为了提高效率,我们可以先计算出原对象的大小,提前开辟好足够一次性容纳所有数据的空间,然后再逐个尾插。

C++

    // 拷贝构造函数 (用法示例:vector<int> v2(v1); )
    vector(const vector<T>& v)
    {
        reserve(v.size()); // 提前一次性开好空间,避免 push_back 时频繁触发低效的扩容
        for (auto& u : v)
        {
            push_back(u);  // 逐个尾插,完美复用现成代码
        }
    }

3. 析构函数

C++

    // 析构函数:释放动态开辟的数组空间
    ~vector()
    {
        if (_start)
        {
            delete[] _start;
            _start = _finish = _end_of_storage = nullptr; // 释放后置空,防野指针的好习惯
        }
    }

四、内存管理(重点难点)

1. reserve:仅扩容,不初始化

reserve(n) 的作用是保证底层容量至少为 n。它只负责开辟物理空间,绝不改变有效数据的个数(size 不变)。这在已知数据规模的前提下,是极其重要的性能优化手段。

⚠️ 经典大坑警告:浅拷贝引发的崩溃

如果只是存储 int 这种内置类型,使用 memcpy 搬家是没有问题的。但如果 Tstd::string 等自定义类型,memcpy 只会按字节发生浅拷贝!当执行 delete[] _start 释放旧空间时,旧 string 会调用析构函数释放堆内存,这会导致新空间里的 string 全部变成野指针,程序瞬间崩溃!

// 【经典大坑反例演示:浅拷贝导致的崩溃】
// 如果 T 是内置类型(如 int),这段代码没问题。
// 但如果 T 是自定义类型(如 string),memcpy 只会按字节进行浅拷贝。
// 当执行 delete[] _start 时,旧空间里的 string 会调用析构函数释放堆内存,
// 导致新空间 tmp 里的 string 全部变成指向已被释放内存的野指针!一用就崩溃!
/*
void reserve(size_t n)
{
    if (n > capacity())
    {
        size_t old_size = size();
        T* tmp = new T[n];
        if (_start)
        {
            memcpy(tmp, _start, sizeof(T) * old_size); // 致命错误:浅拷贝
            delete[] _start;
        }
        _start = tmp;
        _finish = tmp + old_size;
        _end_of_storage = tmp + n;
    }
}
*/

正确的实现方式必须使用 for 循环触发对象的深拷贝赋值:

C++

    // 正确的 reserve 实现
    void reserve(size_t n)
    {
        if (n > capacity())
        {
            // 【避坑】:必须提前保存旧元素的个数!
            // 因为下面 _start 会指向新空间,一旦 _start 变了,再调 size() 算出来的就是垃圾值。
            size_t old_size = size();
            T* tmp = new T[n]; // 步骤1:向操作系统申请一块更大的连续新空间

            if (_start)
            {
                // 步骤2:安全搬家。使用 for 循环触发对象自身的赋值重载。
                // 这样当 T 为 string 时,会自动调用 string 的深拷贝,完美避开 memcpy 大坑。
                for (size_t i = 0; i < old_size; ++i)
                {
                    tmp[i] = _start[i];
                }
                delete[] _start; // 步骤3:销毁旧房子
            }

            // 步骤4:更新内部的三个核心指针,让它们指向新房子
            _start = tmp;
            _finish = tmp + old_size; // 利用前面保存的 old_size 准确定位有效数据尾部
            _end_of_storage = tmp + n;
        }
    }

有无reserve提前扩容性能对比

#include <iostream>
#include <vector>
#include <ctime> // 引入传统的时间头文件
using namespace std;

int main() {
    const int N = 100000000; // 插入亿个数据

    // ==========================================
    // 测试 1:不提前开辟空间 (频繁引发扩容与拷贝)
    // ==========================================
    vector<int> v1;
    size_t begin1 = clock(); // 记录开始时间
    
    for (int i = 0; i < N; ++i) 
    {
        v1.push_back(i);
    }
    
    size_t end1 = clock();   // 记录结束时间
    cout << "未 reserve 耗时: " << (end1 - begin1) << " ms\n";

    // ==========================================
    // 测试 2:使用 reserve 提前开辟 (一步到位)
    // ==========================================
    vector<int> v2;
    v2.reserve(N);           // 提前向系统申请好 1000 万个容量
    size_t begin2 = clock(); // 记录开始时间
    
    for (int i = 0; i < N; ++i) 
    {
        v2.push_back(i);
    }
    
    size_t end2 = clock();   // 记录结束时间
    cout << "已 reserve 耗时: " << (end2 - begin2) << " ms\n";

    return 0;
}

Debug 模式压测结果:

Debug模式下vector reserve扩容性能压测数据

Release 模式压测结果:

Release模式下vector reserve扩容性能压测数据

🚀 避坑:为什么一定要在 Release 模式下测性能?

对比上面两张测试图,你会发现一个反直觉的现象:在 Debug 模式下,提前 reserve 似乎没多大用(仅提升约 10%);但在 Release 模式下,性能却直接飙升了 2.5 倍

造成这种巨大视觉反差的核心原因在于:

  1. Debug 模式的“障眼法”:为了方便排查 Bug,编译器在底层强行塞入了大量“迭代器安全检查”且关闭了优化。一千万次循环中,CPU 绝大部分时间都在做检查,真正扩容搬家的时间差反而被严重稀释掩盖了。
  2. Release 模式的“真刀真枪”:开启优化后,卸下包袱的代码真正做到了“裸奔”。此时,未 reserve 的容器频繁申请新空间、搬运数据的巨大底层开销,才被彻底暴露无遗。

💡 终极结论:
永远不要在 Debug 模式下做性能评测!在真实的生产环境中,只要提前预知数据量级,无脑调用 reserve 是一项极其廉价却能带来质变的性能优化。

2. resize:改变 size,可能初始化或销毁元素

reserve 不同,resize 不仅会改变底层的物理容量,还会真实地改变有效元素的个数(size 会变),多出来的部分会用指定的默认值填补。

C++

    // 调整大小接口
    void resize(size_t n, T val = T())
    {
        // 场景 A:想要的大小比现在小,直接“挥刀自宫”截断数据
        if (n < size())
        {
            _finish = _start + n;
        }
        // 场景 B:想要的大小比现在大,需要填补空缺
        else
        {
            reserve(n); // 先确保底层房间足够多
            // 从当前的尾部开始,不断向后填补指定的默认值 val,直到总数达到 n 个
            while (_finish < _start + n)
            {
                *_finish = val;
                ++_finish;
            }
        }
    }

五、增删查改(核心操作)

1. 插入元素

1.1 尾插 push_back

C++

    void push_back(const T& x)
    {
        // 尾插前检查:如果房间满了,就扩容。初始容量为 0 时给 4,后续按 2 倍增长。
        if (_finish == _end_of_storage)
        {
            reserve(capacity() == 0 ? 4 : capacity() * 2);
        }
        *_finish = x; // 在当前尾部写入数据
        ++_finish;    // 尾部指针向后挪动一位
    }

1.2 指定位置插入 insert

📌 高频面试考点:迭代器失效

insert 中如果触发了扩容,底层空间会被回收并重新分配。此时传入的 pos 迭代器依然指向那块被释放的旧空间,这就叫迭代器失效。因此,扩容前必须记录相对偏移量,扩容后及时修复 pos。同时,函数必须返回新插入元素的位置,防止外部调用者的迭代器失效。

C++

    iterator insert(iterator pos, const T& x)
    {
        assert(pos >= _start && pos <= _finish); 

        // 扩容检查与迭代器失效修复
        if (_finish == _end_of_storage)
        {
            size_t len = pos - _start; // 记录偏移量
            reserve(capacity() == 0 ? 4 : capacity() * 2);
            pos = _start + len; // 修复失效的 pos
        }

        // 挪动数据:从后往前挪,避免数据被覆盖
        iterator end = _finish - 1;
        while (end >= pos)
        {
            *(end + 1) = *end;
            --end;
        }

        *pos = x;  // 填入新数据
        ++_finish; // 更新尾指针

        return pos; 
    }

2. 删除元素

2.1 尾删 pop_back

C++

    void pop_back()
    {
        assert(!empty()); // 空容器不能再删了
        --_finish; // 尾删极其简单粗暴,直接把尾指针往前挪一位即可
    }

2.2 指定位置删除 erase

📌 注意erase 同样存在迭代器失效的问题(被删除位置之后的元素全都往前挪动了),因此规范的做法是返回被删除元素的下一个位置的迭代器。

C++

    iterator erase(iterator pos)
    {
        assert(pos >= _start && pos < _finish); // pos 不能等于 _finish,那里没数据

        // 挪动数据:从前往后挪,像推土机一样把要删除的数据盖住
        iterator it = pos + 1;
        while (it < _finish)
        {
            *(it - 1) = *it;
            ++it;
        }
        --_finish; 

        return pos; 
    }

六、运算符重载

1. 重载 [] 运算符实现随机访问

vector 最大的优势就是支持 O ( 1 ) O(1) O(1) 的随机访问。返回引用 T& 是为了让我们可以直接修改里面的值(如 v[0] = 10;)。

C++

    // 像使用普通数组一样使用 vector,加入了越界断言检查,比原生数组更安全
    T& operator[](size_t pos) { assert(pos < size()); return _start[pos]; }
    const T& operator[](size_t pos) const { assert(pos < size()); return _start[pos]; }

2. 赋值运算符重载(现代写法)

传统写法需要判断是否是自己给自己赋值、清理旧空间、开新空间、拷贝数据,非常繁琐。这里我们使用现代 C++ 中极其优雅的 Copy & Swap(拷贝与交换) 惯用法。

补充准备工作:swap 函数

为了实现这种写法,我们需要先在类内提供一个极速交换底层指针的方法:

C++

    void swap(vector<T>& v)
    {
        // 剥削资本家写法:直接交换彼此底层的三个核心指针,实现 O(1) 极速交换
        std::swap(_start, v._start);
        std::swap(_finish, v._finish);
        std::swap(_end_of_storage, v._end_of_storage);
    }

有了 swap 之后,赋值重载就变得极其简单:

C++

    // 注意参数是【按值传递】(vector<T> v),传参时会自动调用拷贝构造生成一个临时的 v
    vector<T>& operator=(vector<T> v)
    {
        // 我们直接把这个临时对象 v 的底层指针和我们自己的指针交换。
        // 相当于我们“偷”走了 v 开辟好的新空间和数据,把我们的旧垃圾给了 v。
        swap(v);
        
        // 函数结束时,局部变量 v 出作用域被自动析构,顺便帮我们干净利落地清理了旧空间的内存!
        return *this;
    }

七、避坑指南:迭代器失效的“隐形炸弹”

什么是迭代器失效?

迭代器失效的本质,是迭代器底层所指向的空间被释放了,或者由于元素的挪动导致该迭代器指向的位置已经失去了原本的意义。继续使用失效的迭代器,就像使用野指针一样,会导致程序崩溃或出现未定义行为。

vector 中,迭代器失效主要分为以下两大经典场景:

1. 扩容导致失效(空间被释放)

所有可能会引起底层空间重新分配的操作(如 reserveresizeinsertpush_back 等),都潜藏着迭代器失效的风险。

💣 踩坑现场:

C++

vector<int> v = {1, 2, 3, 4, 5};
auto it = v.begin();

v.reserve(100); // 这里可能触发扩容,旧空间被 delete[] 释放

// 此时 it 依然指向那块已经被释放的旧内存,变成了野指针!
while(it != v.end()) 
{
    cout << *it << " "; // ❌ 极大概率直接崩溃!
    ++it;
}

✅ 解决方案:

如果在操作后还需要使用迭代器,必须重新获取或更新它(例如在 insert 的内部实现中,我们通过记录相对偏移量 pos - _start 来修正 pos)。对于外部使用者,扩容后请重新 it = v.begin();

2. 删除导致失效(元素挪动错位)

使用 erase 删除元素时,被删除位置之后的元素会整体向前挪动。此时,原来的迭代器虽然没有变成野指针,但它指向的元素已经变了(指向了原本在它后面的那个元素)。如果不加干预直接 ++it,极易发生漏删越界崩溃

💣 踩坑现场(错误删除所有偶数):

C++

auto it = v.begin();
while (it != v.end()) 
{
    if (*it % 2 == 0) {
        v.erase(it); // ❌ 删完之后,后面的元素补上来了,it 已经指向了下一个元素
    }
    ++it; // ❌ 这里再 ++,就会完美错过补上来的那个元素,甚至如果删的是尾部元素,这里直接越界!
}

✅ 解决方案:

这就是为什么 STL 规定 erase 必须返回被删除元素的下一个位置的迭代器。正确的写法如下:

C++

// 🌟 正确删除所有偶数元素的经典范例
auto it = v.begin();
while (it != v.end()) 
{
    if (*it % 2 == 0) {
        it = v.erase(it); // 用返回值更新迭代器!此时 it 已经指向下一个元素了
    } else {
        ++it;             // 只有在没删除的情况下,才手动向后走一步
    }
}

八、总代码和测试

1. vector.h:手写 vector 完整实现

#pragma once
#include <assert.h>
#include <algorithm> // 提供 std::swap

namespace xingguichutu
{
    template<class T>
    class vector
    {
    public:
        // 在我们手写的 vector 中,迭代器的本质其实就是原生指针。
        // 这也是为什么 vector 支持极速随机访问的原因。
        typedef T* iterator;             // 可修改元素的迭代器
        typedef const T* const_iterator; // 只读迭代器,保障 const 对象的安全访问

        // ==========================================
        // 1. 默认成员函数
        // ==========================================

        // 强制编译器生成默认的无参构造函数
        // 因为我们在 private 处给了缺省值 nullptr,所以这里什么都不写也是安全的
        vector() = default;

        // 拷贝构造函数
        // 用法:vector<int> v2(v1);
        vector(const vector<T>& v)
        {
            reserve(v.size()); // 提前一次性开好空间,避免 push_back 时频繁扩容
            for (auto& u : v)
            {
                push_back(u);  // 逐个尾插,复用现成代码
            }
        }

        // 赋值运算符重载(极其精妙的现代 C++ 写法:Copy & Swap)
        // 注意参数是按值传递的(vector<T> v),这意味着传参时会调用拷贝构造生成一个临时的 v
        vector<T>& operator=(vector<T> v)
        {
            // 我们直接把这个临时对象 v 的底层指针和我们自己的指针交换。
            // 相当于我们“偷”走了 v 开辟好的新空间和数据,把我们的旧垃圾给了 v。
            swap(v);
            // 函数结束时,局部变量 v 出作用域被销毁,顺便帮我们自动释放了旧空间的内存!
            return *this;
        }

        // 析构函数:释放动态开辟的数组空间
        ~vector()
        {
            if (_start)
            {
                delete[] _start;
                _start = _finish = _end_of_storage = nullptr; // 防野指针的好习惯
            }
        }

        // ==========================================
        // 2. 迭代器与容量接口
        // ==========================================

        iterator begin() { return _start; }
        iterator end() { return _finish; } // 注意:end 指向的是最后一个有效数据的【下一个位置】

        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 _finish == _start; }          // 判空:首尾指针相遇即为空

        // ==========================================
        // 3. 核心容量管理:扩容与调整大小
        // ==========================================

        // 【经典大坑反例演示:浅拷贝导致的崩溃】
        // 如果 T 是内置类型(如 int),这段代码没问题。
        // 但如果 T 是自定义类型(如 string),memcpy 只会按字节进行浅拷贝。
        // 当执行 delete[] _start 时,旧空间里的 string 会调用析构函数释放堆内存,
        // 导致新空间 tmp 里的 string 全部变成指向已被释放内存的野指针!一用就崩溃!
        /*
        void reserve(size_t n)
        {
            if (n > capacity())
            {
                size_t old_size = size();
                T* tmp = new T[n];
                if (_start)
                {
                    memcpy(tmp, _start, sizeof(T) * old_size); // 致命错误:浅拷贝
                    delete[] _start;
                }
                _start = tmp;
                _finish = tmp + old_size;
                _end_of_storage = tmp + n;
            }
        }
        */

        // 正确的 reserve:只负责开辟物理空间,不填充有效数据(size 不变,capacity 变)
        void reserve(size_t n)
        {
            if (n > capacity())
            {
                // 【避坑1】:必须提前保存旧元素的个数!
                // 因为下面 _start 会指向新空间,一旦 _start 变了,再去调用 size() 算出来的就是垃圾值。
                size_t old_size = size();
                T* tmp = new T[n]; // 步骤1:向操作系统申请一块更大的连续新空间

                if (_start)
                {
                    // 步骤2:安全搬家。使用 for 循环触发对象自身的赋值重载。
                    // 这样当 T 为 string 时,会自动调用 string 的深拷贝,完美避开 memcpy 的大坑。
                    for (size_t i = 0; i < old_size; ++i)
                    {
                        tmp[i] = _start[i];
                    }
                    delete[] _start; // 步骤3:销毁旧房子
                }

                // 步骤4:更新内部的三个核心指针,让它们指向新房子
                _start = tmp;
                _finish = tmp + old_size; // 利用前面保存的 old_size 准确定位有效数据尾部
                _end_of_storage = tmp + n;
            }
        }

        // resize:既开辟物理空间,又填充有效数据(size 会变)
        void resize(size_t n, T val = T())
        {
            // 场景 A:想要的大小比现在小,直接“挥刀自宫”截断数据,容量不变
            if (n < size())
            {
                _finish = _start + n;
            }
            // 场景 B:想要的大小比现在大,需要填补空缺
            else
            {
                reserve(n); // 先确保底层房间足够多
                // 从当前的尾部开始,不断向后填补指定的默认值 val,直到总数达到 n 个
                while (_finish < _start + n)
                {
                    *_finish = val;
                    ++_finish;
                }
            }
        }

        // ==========================================
        // 4. 数据访问接口
        // ==========================================

        // 像使用普通数组一样使用 vector,越界会触发 assert 报错
        T& operator[](size_t pos) { assert(pos < size()); return _start[pos]; }
        const T& operator[](size_t pos) const { assert(pos < size()); return _start[pos]; }

        // ==========================================
        // 5. 增删查改接口
        // ==========================================

        void push_back(const T& x)
        {
            // 尾插前检查:如果房间满了,就扩容。初始容量为 0 时给 4,后续按 2 倍增长。
            if (_finish == _end_of_storage)
            {
                reserve(capacity() == 0 ? 4 : capacity() * 2);
            }
            *_finish = x; // 在当前尾部写入数据
            ++_finish;    // 尾部指针向后挪动一位
        }

        void pop_back()
        {
            assert(!empty()); // 空容器不能再删了
            --_finish; // 尾删极其简单粗暴,直接把尾指针往前挪一位即可(旧数据会在下次插入时被覆盖)
        }

        iterator insert(iterator pos, const T& x)
        {
            assert(pos >= _start && pos <= _finish); // pos 可以等于 _finish,相当于尾插

            // 扩容检查
            if (_finish == _end_of_storage)
            {
                // 【致命大坑:迭代器失效】
                // 扩容会导致底层换新空间,_start 变了,但传入的 pos 还是指向那块已经被 delete 的旧空间!
                // 解决方案:提前算好 pos 距离 _start 的相对距离,扩容后再把 pos 加回去。
                size_t len = pos - _start;
                reserve(capacity() == 0 ? 4 : capacity() * 2);
                pos = _start + len; // 修复失效的 pos
            }

            // 挪动数据:要把 pos 及之后的数据全部往后挪一位,给新数据腾出位置
            // 【细节】:必须从后往前挪(从最后一个有效数据开始挪),否则前面的数据会把后面的数据覆盖掉。
            iterator end = _finish - 1;
            while (end >= pos)
            {
                *(end + 1) = *end;
                --end;
            }

            *pos = x;  // 把新数据塞进腾出来的空位
            ++_finish; // 有效数据加一

            return pos; // 规定:返回新插入元素的位置,方便外部同步更新迭代器,防失效
        }

        iterator erase(iterator pos)
        {
            assert(pos >= _start && pos < _finish); // pos 不能等于 _finish,那里没数据

            // 挪动数据:要把 pos 之后的数据全部往前挪一位,像推土机一样把要删除的数据盖住
            // 【细节】:必须从前往后挪(从 pos+1 开始),依次往前覆盖。
            iterator it = pos + 1;
            while (it < _finish)
            {
                *(it - 1) = *it;
                ++it;
            }
            --_finish; // 有效数据减一

            return pos; // 规定:返回被删除元素的下一个位置(因为后面的数据填补上来了,所以其实还是 pos 的地址)
        }

        void swap(vector<T>& v)
        {
            // 直接交换彼此底层的三个核心指针,不需要拷贝数组里面的数据,实现 O(1) 极速交换
            std::swap(_start, v._start);
            std::swap(_finish, v._finish);
            std::swap(_end_of_storage, v._end_of_storage);
        }

    private:
        // 【核心底层的三个大将】
        // C++ 标准库的 vector 本质上并不是靠 size 和 capacity 两个整数来管理的,
        // 而是单纯依靠这三个原生指针来掌控整个连续的内存块。

        // 1. 指向分配的内存空间的起始位置(即第一个有效元素)
        iterator _start = nullptr;

        // 2. 指向有效数据的尾部(即最后一个有效元素的下一个位置)
        iterator _finish = nullptr;

        // 3. 指向整个已分配容量的尾部(即整个可用空间的下一个位置)
        iterator _end_of_storage = nullptr;
    };
}

到这里,vector.h 已经把容器的核心能力完整串起来了:三大原生指针负责管理连续空间,reserve/resize 负责容量调度,insert/erase 负责处理数据挪动和迭代器失效。接下来再单独放一份 test.cpp,用基础类型和 std::string 两条线验证功能是否稳定,避免读者刚看完实现代码就被另一大段测试代码直接“糊脸”。

2. test.cpp:功能验证与深拷贝测试

#include <iostream>
#include <string>
#include "vector.h" 
using namespace std;

// 写一个通用的打印函数,顺便测试 const 迭代器和范围 for 循环
template<typename T>
void print_vector(const xingguichutu::vector<T>& v, const string& name)
{
    cout << name << ": ";
    for (auto e : v)
    {
        cout << e << " ";
    }
    cout << "\n";
}

void test_basic()
{
    cout << "========== 1. 测试基础功能 (int) ==========" << endl;

    xingguichutu::vector<int> v1;

    // 测试尾插和扩容
    v1.push_back(1);
    v1.push_back(2);
    v1.push_back(3);
    v1.push_back(4);
    print_vector(v1, "v1 (尾插1,2,3,4)");

    // 测试尾删
    v1.pop_back();
    print_vector(v1, "v1 (尾删一次)");

    // 测试 insert (头部插入)
    v1.insert(v1.begin(), 0);
    print_vector(v1, "v1 (头插 0)");

    // 测试 erase (删除中间的 2)
    v1.erase(v1.begin() + 2);
    print_vector(v1, "v1 (删除第三个元素 2)");

    // 测试拷贝构造
    xingguichutu::vector<int> v2(v1);
    print_vector(v2, "v2 (拷贝自 v1)");

    // 测试赋值重载
    xingguichutu::vector<int> v3;
    v3.push_back(100);
    v3 = v1;
    print_vector(v3, "v3 (赋值自 v1)");
}

void test_string_deep_copy()
{
    cout << "\n========== 2. 测试深拷贝避坑 (std::string) ==========" << endl;

    xingguichutu::vector<string> vs;
    vs.push_back("Hello");
    vs.push_back("World");

    // 故意循环多次尾插,强行触发底层的 reserve 扩容逻辑
    for (int i = 0; i < 5; ++i)
    {
        vs.push_back("C++");
    }

    print_vector(vs, "vs");
    cout << "当前 size: " << vs.size() << ", capacity: " << vs.capacity() << endl;
    cout << "--> 如果你看到了这行字且程序没有崩溃,恭喜你!深拷贝避坑成功!" << endl;
}

int main()
{
    test_basic();
    test_string_deep_copy();

    return 0;
}

测试效果:

手写vector完整功能测试运行结果

九、总结与思考

通过手动实现 vector 的核心骨架,我们完成了从“仅仅会调用 API”到“彻底洞悉底层运作”的跨越:

  1. 核心原理vector 并不是靠整数下标管理的,而是通过 _start_finish_end_of_storage 三个原生指针游刃有余地掌控着连续的堆内存。
  2. 避坑直觉:深刻理解了深浅拷贝在自定义类型中的致命影响,以及迭代器失效的两种根本原因。以后在编码时,更新迭代器会成为你的肌肉记忆。
  3. 设计哲学:STL 将算法(如 find)与容器分离,通过迭代器作为桥梁,展现了极致的解耦合之美;而赋值重载的 Copy & Swap 现代写法,则把 C++ 出作用域自动析构的特性“白嫖”到了极致。

“造轮子”的意义从来不是鼓励在工程里重复造低效的工具,而是通过拆解和重构,搞懂“工具为什么这么设计”。当你能给别人解释清楚“为什么拷贝构造要用深拷贝”、“为什么 insert 要返回迭代器”时,你就真正掌握了 C++ 的底层思维。

后续我们还将继续拆解 list(链表)、map(红黑树)等更多容器的实现,从“顺序存储”跨越到“链式存储”再到“树形存储”,一步步打通 STL 的核心脉络,敬请期待!

更多推荐