从零实现 C++ vector:原理、实现与优化

一. 前言: vector是什么?

在 C++ 的学习中,vector 是最常使用也是最重要的容器之一。它是STL容器家族中的动态数组容器,提供了类似数组的连续存储结构,但又具备自动管理内存、动态扩容、丰富的成员函数和迭代器支持等优势。

简单来说,vector 可以看作是“可自动扩容的数组”,是数组与 STL 容器思想完美结合的产物。

二. vector的使用及模拟实现

模拟实现使用的vector的基本成员:

namespace simulation
{
    template<class T>
    class vector
    {
    public:
        typedef T* iterator;

        iterator begin()
        {
            return _start;
        }

        iterator end()
        {
            return _finish;
        }

        size_t size() const
        {
            return _finish - _start;
        }

        ~vector()
        {
            delete[] _start;
            _start = _finish = _end_of_storage = nullptr;
        }
    private:
        iterator _start = nullptr;  // 存储空间的起始位置 
        iterator _finish = nullptr; // 有效数据的末尾地址
        iterator _end_of_capacity = nullptr; // 存储空间的末尾地址
    }
}

vector的核心实现采用迭代器(指针)作为内部成员,这得益于其连续内存数组的本质特性。使用迭代器既能保证内存空间的连续性,又可实现高效随机访问,同时保持与C语言的兼容性。若采用数组作为内部成员,由于需在编译时确定大小,不仅无法实现动态扩容,还会增加内存管理复杂度,更会丧失与STL迭代器的兼容性

1. 构造函数

1.1 默认构造函数

由于我们的成员变量已经确认了缺省值, 所以默认构造可以什么都不写

vector()
{}

使用方法也非常简单:

vector<int> v;

要注意这里不能写成

vector<int> v();

编译器会误认为用户创建了一个返回值是vector<int>, 参数为空的函数

1.2 指定大小和初始值构造 

创建一个指定大小且元素初值固定的 vector

vector(size_t n, T val = T())
{
    _start = new T[n];
    for(size_t i = 0; i < n; i++)
    {
        _start[i] = val;       // 使用传入值初始化
    }
    _finish = _start + n;      // 指向有效数据末尾位置
    _end_of_storage = _finish; // 由于刚分配时容量等于数据个数, 所以两者相同
}
vector<int> v(10, 100); // 含有10个元素且每个元素值为100的int类型容器
vector<int> v(20);      // 含有20个元素且每个元素使用in 的默认值 (即0) 初始化的容器

1.3 迭代器区间构造

使用现有区间进行初始化,可以是数组、字符串等多种容器类型

template<class InputIterator>
vector(InputIterator first, InputIterator last)
{
    size_t n = first - last;
    _start = new T[n];         // 先预留空间
    size_t i = 0;
    while(first != last)
    {
        _start[i] = *first++;  // 复制传过来的区间的数据
        i++;
    }
    _finish = _start + n;
    _end_of_storage = _finish; // 由于刚分配时容量等于数据个数, 所以两者相同
}

需要注意的是,由于涉及迭代器相减操作,因此要求传入的迭代器必须是随机迭代器。不过在使用std标准库时无需担心这一点,因为其内部实现已经通过distance方法处理了这个问题

int arr[] = {1, 2, 3, 4, 5};
vector<int> v1(arr, arr + 5);         // 使用数组区间来构建
vector<int> v2(v1.begin(), v2.end()); // 使用另一个vector的迭代器区间构建

1.4 拷贝构造

将一个已存在的 vector 所有数据复制到新创建的对象中

vector(const vector<T>& v)
{
    size_t n = v.size();
    _start = new T[n];    // 获取原vector大小并申请相同大小空间
    for(int i = 0; i < n; i++)
    {
        _start[i] = v._start[i]; // 逐元素拷贝数据
    }
    _finish = _end_of_storage = _start + n;
}

1.5 赋值运算符重载

将赋值等号 "=" 的右侧 vector 的元素深拷贝到左侧 vector,并确保两者独立且内容相同。

vector<T>& operator=(const vector<T>& v)
{
    // 避免自己给自己赋值
    if(this != &v)
    {
        reserve(v.size()); // reserve用于预留容量, 下面会详细介绍
        for(int i = 0; i < v.size(); i++)
        {
            _start[i] = v._start[i];
        }
        _finish = _start + v.size();
    }
    return *this;
}

2. 常见容量操作

2.1 reserve : 预留容量

reserve(size_t n) 是 vector 的容量管理函数,用于预分配至少可容纳 n 个元素的内存空间。该操作仅会调整容量,不会改变当前元素数量 (size),仅在请求容量超过当前容量时才会重新分配内存。

当频繁插入元素导致多次扩容时,每次扩容都需要:

  • 分配更大的内存空间
  • 拷贝现有数据
  • 释放原有内存

这种重复操作会产生额外开销。所以使用 reserve 可以预先分配足够空间,通过减少扩容次数来提高批量插入效率。因此在进行大批量数据插入前,我们通常先调用 reserve 一次性分配所需空间。

使用示例:

vector<int> v;
v.reserve(1000);    // 提前预留1000个元素的空间
for(int i = 0; i < 1000; i++)
{
    v.push_back(i); // 不会触发扩容
}

模拟实现

在我们自己实现的 vector 中,只有当 n > capacity 时才会申请新内存, 拷贝数据释放旧内存

void reserve(size_t n)
{
    size_t old_size = size(); // 先记录下原来的有效数据长度

    if(n > capacity())        // 只有当 n > capacity 时才会触发扩容
    {
        // 开辟新空间并拷贝数据
        T* new_start = new T[n]; 
        for(size_t i = 0; i < size(); i++)
        {
            new_start[i] = _start[i];
        }
        delete[] _start;      // 释放旧空间
        _start = new_start;  
        _finish = _start + old_size;
        _end_of_storage = _start + n;
    }
}

迭代器失效

在 reserve 模拟实现代码中,可能有人会疑惑为什么第三行要预先记录有效长度,难道不是直接调用 size() 方法就可以了吗

这其实涉及到 vector 的一个重要特性:迭代器失效问题。下图展示了在扩容前后 vector 内存结构的变化:

可以看到,当 vector 扩容时,会在堆上开辟一块新的、更大的空间,并将原有数据拷贝过去。此时,原先的内存空间已经被释放,但之前指向这些地址的 迭代器、指针依然指向旧的内存位置,此时继续使用它们将导致未定义行为

如果在搬移后调用 size() 来获取长度,等于重新计算 _finish - _start,而此时 _finish 还未被更新,会导致未定义的后果, 所以在申请和释放空间之前需要先记录下数据的长度

2.2 resize : 调整容器大小

resize(size_t n, T val = T()) 是 vector 中用于调整元素个数的函数

  • 如果 n < size, 删除多余元素(实际上是缩短 _finish)
  • 如果 n > size, 扩充元素数量, 多出来的元素使用 val或者是默认值填充, 有可能会触发扩容

和 reserve 只影响容量不同, resize 会真正改变容器中元素的个数

使用示例: 

vector<char> v(1000, 'a'); // 创建一个包含一千个char类型元素'a'的容器
v.resize(100);             // 批量删除第100个之后的元素
v.resize(200, 'b');        // 扩容到100个元素并全部填充'b'

模拟实现:

void resize(size_t n, T val = T())
{
    if(n < size())
    {
        _finish = _start + n;  // 如果n小于当前的size, _finish直接截断即可
    }
    else
    {
        reserve(n);            // 如果n大于当前的size, 先预留好容量
        while(_finish != _start + n)
        {
            *_finish++ = val;  // 填充数据
        }
    }
}

迭代器失效问题

如果是缩小长度, 因为容器容量不变,所以未被删除位置的迭代器依然有效,但是删除位置后的迭代器失效。当扩充长度且触发扩容时, 由于底层搬迁数据,所有迭代器、指针都会失效

2.3 容量相关的辅助接口

除了前面重点讲解的 reserve 和 resize 之外,vector 还提供了一些常用的容量相关接口,这些函数非常简单,一般只是对内部成员变量的访问封装,我们在模拟实现时直接返回对应的数据成员即可

size_t size() const { return _finish - _start; }

size_t capacity() const { return _end_of_storage - _start; }

bool empty() const { return _start == _finish; }

size_t max_size() const { return static_cast<size_t>(-1) / sizeof(T); }

 通常使用 const 修饰 this 指针, 这样我们使用 const vector<T> 时也可以调用这些接口

max_size 返回当前系统允许的最大元素数,基本上不会用到,仅作了解即可。

3. 增删查改

3.1 插入: push_back / insert

使用 push_back 方法时,元素会被高效地追加到容器尾部,操作简单且性能优异。相比之下  insert 方法在某个特定的位置插入数据, 则需要将指定位置之后的元素整体后移才能插元素,因此执行效率明显低于 push_back 。

push_back模拟实现:

void push_back(const T& val)
{
    if(_finish == _end_of_storage)
    {
        // 内存不够的情况下开始扩容
        // 如果是刚创建的的vector没有容量的话就开辟4个空间, 否则就2倍扩容
        reserve(capacity() == 0 ? 4 : 2 * capacity());
    }
    *_finish = val; // 将数据插入到尾部
    _finish++;      // 更新指向有效数据末尾的指针
}

实现思路非常直观, 将元素追加到 _finish 指向的位置,并更新 _finish, 如果空间已满,则先扩容。

通常情况下时间复杂度为 O1 

使用示例:

vector<int> v;
for(int i = 0; i < 10; i++)
{
    v.push_back(i); // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
}

insert模拟实现:

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

    // 扩容操作会导致迭代器pos失效, 所以在调用reserve接口之前要先保存指针
    if(_finish == _end_of_storage)
    {
        size_t offset = _start + pos;
        reserve(capacity() == 0 ? 4 : 2 * capacity());
        pos = _start + offset;
    }

    // 开始移动数据
    iterator it = _finish;
    while(it != pos)
    {
        *it = *(it - 1); // 数据依次向后移动
        it--;       
    }
    *pos = val;          // 填充数据
    _finish++;           // 更新_finish指针

    return pos;
}

当 vector 容器空间不足时,插入操作会触发扩容, 此时,所有现有迭代器都将失效,包括作为参数传入的 pos。所以如果当我们后续还想使用插入位置 pos,就必须使用返回的新的有效指针, 这也正是返回值存在的意义

时间复杂度 On

错误示例:

vector<int> v(10, 1);
vector<int>::iterator pos = v.begin() + 3;
v.insert(pos, 100); // 在第三个位置插入100
cout << *pos;       // 错误:pos 可能已失效,访问已释放的内存

 正确示例:

vector<int>::iterator pos = v.begin() + 3;
pos = v.insert(pos, 100); // 使用返回的有效迭代器
cout << *pos;             // 正常访问插入的新元素

3.2 删除: pop_back / erase / clear

与尾部插入 push_back 相对应的是 pop_back (尾删),它用于从末尾删除一个元素。由于操作简单因此效率极高。而 erase 则与 insert 对应,用于删除某个位置的单个元素或一段区间内的元素。但与 insert 一样,erase 需要对后续元素进行批量移动,因此效率很低。最后,clear 清空所有元素实际上只需将表示末尾的指针回退至起始位置即可,因此也具有极高的效率。

pop_back模拟实现: 

void pop_back()
{
    assert(_finish > _start);
    --_finish; // 只需要将代表末尾有效元素的指针前移即可
}

减少 size, 既不触发扩容也不缩容, 时间复杂度 O1

使用示例:

vector<int> v(5, 10); // 包含5个int类型元素100的容器
v.pop_back();         // 删除尾部一个元素, 此时容器内还剩四个元素

erase模拟实现:

erase(iterator pos) 删除指定位置元素

iterator erase(iterator pos)
{
    iterator cur = pos + 1;
    while(cur != _finish)
    {
        *(cur - 1) = *cur;
        cur++;
    }
    _finish--;
    return pos; // 返回指向删除元素位置的指针防止迭代器失效
}

erase(iterator first, iterator last) 删除指定区间的元素

iterator erase(iterator first, iterator last)
{
    iterator tmp = first, cur = last;
    while(cur != _finish)
    {
        *tmp = *cur;
        tmp++;
        cur++;
    }
    _finish -= (last - first);
    return first; // 防止迭代器失效
}

在这里有人可能会疑惑:既然上述的 erase 模拟实现并没有释放内存,仅仅是通过挪动数据来删除元素,原有的空间依然存在不会导致迭代器失效,为何仍需通过返回指针的方式来预防这种情况?

在真实STL实现中: erase有可能会触发缩容, 也就是说再删除元素后容器为了节省空间, 可能会申请一块更小的内存块把原有的元素搬过去, 然后释放掉原来的大块内存

在这种情况下, 原有的指向旧空间的迭代器全部失效, 

为了避免因忘记使用返回值而导致错误,一些编译器会强制要求 erase 的返回值必须使用。即使在模拟实现中传入的迭代器并未失效,也仍会报错。因此,即使是在我们自己模拟实现的 erase 也应当使用返回值来继续操作 

使用示例:

vector<int> v;
for(int i = 1; i <= 5; i++)
{
    v.push_back(i);               // 1, 2, 3, 4, 5
}
auto it = v.erase(v.begin() + 2); // 删除3,v变为 {1, 2, 4, 5}
cout << *it; // 输出 4

clear模拟实现: 

void clear()
{
    _finish = _start; // 只需要更改指针即可
}

只是清空元素并不会释放内存, 也就是说 _start 到 _end_of_storage 空间并不会释放

使用示例:

vector<int> v(10, 5);      // 5, 5, 5, 5, 5
v.clear();
cout << v.empty() << endl; // 输出 1代表 true

3.2 访问、查找与修改

这一部分我们介绍 vector 中最常用的几个功能操作:

元素访问( operator [ ], at() )、查找元素( find() ),以及修改替换( assign() )

下标访问: operator[] 与 at()

operator [ ] 提供了最直接的访问方式,语法简单,但不进行越界检查,使用时需小心。而 at() 因为内部封装了边界判断,所以访问效率略低,在越界时会抛出 out_of_range 异常,更加安全。

模拟实现:

T& operator[](size_t pos) { return _start[pos]; }

T& at(size_t pos)
{
    if(pos >= size())
        throw std::out_of_range("vector-at"); // 如果越界抛出异常

    return _start[pos];
}

在我们实际使用中, 开发调试阶段推荐使用 at(), 上线后如果需要追求性能可替换为 operator[ ]

查找元素: find

vector 容器本身并不提供成员函数 find(), 但是我们可以结合算法库 algorithm 标准库进行查找

使用示例:

vector<int> v;
for(int i = 1; i <= 5; i++)
{
    v.push_back(i);
}
auto it = std::find(v.begin(), v.end(), 3);
if(it != v.end())
    cout << "找到了: " << *it << endl;

我们也可以尝试模拟实现一下算法库的 find():

template<class InputIterator, class T>
InputIterator find(InputIterator first, InputIterator last, const T& val)
{
    while(first != last)
    {
        if(*first == *last)
            return first;
        ++first;
    }
    return last; // 如果没找到返回last指针
}

修改替换: assign

用于批量替换 vector 中的内容。它会清空原内容并重新赋值,本质上是一个组合了 clear + insert的操作。

模拟实现:

// 迭代器区间赋值
template<class InputIterator>
void assign(InputIterator first, InputIterator last)
{
    clear();
    // 调用标准库计算迭代器之间距离的函数
    size_t len = std::distance(first, last); 
    reserve(len); // 提前预留足够的空间

    iterator cur = _start;
    while(first != last)
        *cur++ = *first++;

    _finish = _start + len;
}

// 指定数据个数赋值
void assign(size_t n, const T& val)
{
    clear();
    reserve(n); // 提前预留足够的空间
    for(int i = 0; i < n; i++)
        _start[i] = val;

    _finish = _start + n;
}

注意 assign 实际上并不会释放原有空间,只会清空并重写已有区域,为避免越界访问,需要预先判断并扩容。

使用示例:

vector<int> first;
vector<int> second;

first.assign(7,100);                // 7个值为100的int类型元素
auto it = first.begin() + 1;
second.assign(it, first.end() - 1); // first容器中间的5个元素 

3.4 swap 和现代写法

swap函数

swap通过交换两个容器内部的数据指针的方式来高效交换两个容器中的资源

void swap(vector<T>& v)
{
    std::swap(_start, v._start);   // 复用标准库中的swap函数 
    std::swap(_finish, v._finish);
    std::swap(_end_of_storage, v._end_of_storage);
}
vector<int> first(3,100);    // 3个值为100的int类型元素
vector<int> second(5,200);   // 5个值为200的int类型元素

first.swap(second);
for(auto& e : first)
    cout << e;
cout << endl;

for(auto& e : second)
    cout << e;

现代写法: 拷贝交换惯用法

为了简化资源管理代码,同时提高异常安全性,现代 C++ 推荐在重载赋值运算符时使用拷贝交换法

具体思路为:

  1. 先构造一个临时副本(调用拷贝构造)
  2. 调用swap和当前对象交换资源
  3. 临时副本销毁, 自动调用析构函数释放资源
vector<T>^ operator=(const vector<T> v) // 注意这里使用传值传参调用拷贝构造创建副本
{
    swap(v);                            // 将生成的临时副本的资源转移过来
    return *this;
}

拷贝构造也可以使用类似方法实现:

vector(const vector<T>& v)
{
    vector tmp(v.begin(), v.end()); // 调用区间构造来创建临时副本
    swap(tmp);                      // 转移临时副本中的资源
}

现代写法并不会提高程序的运行效率,时间复杂度也并未降低,但相比传统写法更简洁清晰,可读性和可维护性显著提升。

三. 完整接口一览表

类别函数名 / 操作符说明
构造函数

vector(), vector(size_t n, const T&)

vector(it1, it2)

默认构造、填充值构造、迭代器区间构造
拷贝构造vector(const vector<T>&)深拷贝构造
赋值操作operator=, assign()拷贝赋值、重置内容
容量管理size(), capacity(), resize(), reserve(), empty()控制大小、容量及状态判断
元素访问operator[], at(), front(), back(), data()访问元素
修改操作push_back(), pop_back(), insert(), erase(), clear()增删元素,清空容器
迭代器begin(), end(), rbegin(), rend()正反向迭代器支持
比较操作==, !=, <, <=, >按元素字典序比较
交换操作swap()与另一个容器交换全部内容

四. 总结

本文通过模拟实现的方式,从构造、容量管理、访问、修改等多个维度,逐步揭示了 vector 的内部结构与核心机制。vector 是一个既简单又强大的容器,掌握它不仅能帮助我们更高效地开发程序,也为理解 C++ 更复杂的数据结构打下坚实基础。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值