【STL】Vector剖析及模拟实现

简介: 【STL】Vector剖析及模拟实现

vector的常用接口


首先贴上:vector的文档介绍,以备查阅使用。

vector的基本框架


29bf9e3388a64556bd4fd761289a695a.png

8107a62b37694fd89e613f808a000f6a.png


vector的成员变量分别是空间首部分的_start指针和最后一个元素的下一个位置的_finish指针,以及指向可用空间末尾的下一个位置的_end_of_storage指针,对于vector来说,它的底层就是由顺序表实现的,所以它的迭代器就是原生指针T*,我们定义const和非const的迭代器,便于const和非const对象的迭代器的调用。


vector的构造函数


vector提供四种构造函数来进行初始化:

333dbc0b390a442fa1b969e3d49b81f2.png


1.对于无参构造,我们利用初始化列表来进行初始化,用nullptr初始化

vector()
  :_start(nullptr)
  ,_finish(nullptr)
  ,_end_of_storage(nullptr)
{}


2.构造并初始化是用n个value值来初始化,因为value既可能是内置类型,也可能是自定义类型,所以如果用引用作参数的话,需要用const引用,也就是常引用来作参数,否则无法接收内置类型的实参。

//vector<int> v(10,5)初始化
    vector(size_t n, const T& val = T()) //这里调用匿名对象的默认构造初始化
    {
      reserve(n);
      for (size_t i = 0; i < n; ++i)
      {
        push_back(val);  //进行深拷贝,不要用memcpy,浅拷贝会带来二次析构等问题
      }
    }


注意匿名对象的生命周期只有其所在那一行,const引用会延长匿名对象的生命周期到引用对象域结束,因为后续调用就是匿名对象,并自动销毁

在实现完n个value构造的构造函数之后,如果我们此时用10个int类型的数字1来构造对象v1,实际会报错,报错的原因其实是由于函数的匹配优先级所导致的实参无法正确匹配相应的构造函数。而使用10个char类型的字符A却不会报错,这是由于函数的匹配优先级决定的

对于这种问题STL源码的解决方式是利用了函数重载来解决了这个问题,多重载了一个类型为int的构造函数


  //int 不能向size_t 类型转换
    vector(int n, const T& val = T())
    {
      reserve(n);
      for (size_t i = 0; i < n; ++i)
      {
        push_back(val);
      }
    }

3.对于拷贝构造函数的实现有个一定需要注意的问题: 深浅拷贝问题 — 对象嵌套深浅拷贝问题。

我们传统写法会是memcpy申请资源并按字节拷贝,这种写法用在string类实现可以,但在vector类实现就肯定不行了,因为vector的模板实例化有int数组对象,string对象、及vector的vector嵌套等等。所以对于自定义类型如果时浅拷贝,会使两个对象指向同一块空间,对象调用析构函数时会造成二次析构!值拷贝很多时候是一种危险的行为。


我们采用现代写法,也就是复用写法,利用形参对象v的迭代器来构造临时对象tmp,然后将对象tmp和* this进行交换,这里交换函数需要我们自己提供,复用库里的swap即可。同时为了防止对象tmp离开函数栈帧销毁时造成野指针的访问问题,所以在调用构造函数时采用了初始化列表的方式将* this的三个成员都初始化为nullptr。

在实现拷贝构造后,我们还需要实现赋值重载,利用传值拷贝构造的临时对象即可,然后调用swap类成员函数即可完成自定义类型的赋值工作。为了符合连续赋值含义,我们利用引用来作为返回值。

vector& swap(vector<T>& v)
{
  std::swap(_start, v._start);
  std::swap(_finish, v._finish);
  std::swap(_end_of_storage, v._end_of_storage);
  return *this;
}
vector(const vector<T>& v)
  :_start(nullptr)
  , _finish(nullptr)
  , _end_of_storage(nullptr)
{
  //复用区间构造
  vector<T> tmp(v.begin(), v.end());
  swap(tmp);//交换的时候就可以体现出拷贝构造函数初始化列表的好处了
}
// v1 = v2;
// v1 = v1;
vector<T>& operator=(vector<T> v)//返回引用,符合连续赋值的含义
{
  swap(v);
  return *this;
}

不复用swap函数,通过复用reserve函数实现,要注意更新_finish和_endofstorge

      vector(const vector<T>& v)
            : _start(nullptr), _finish(nullptr), _endOfStorage(nullptr)
        {
            reserve(v.capacity());
            iterator it = begin();
            const_iterator vit = v.cbegin();
            while (vit != v.cend())
            {
                *it++ = *vit++;
            }
            _finish = _start + v.size();
            _endOfStorage = _start + v.capacity();
        }


小tips:在拷贝构造函数,可以不加模板参数 为了可读性我们推荐加上

4.迭代器区间构造

vector的迭代器区间初始化是个模板 可以传入任意类型,并可以部分初始化,但实际上vector很少用迭代器。

  template<class InputIterator>
        vector(InputIterator first, InputIterator last)
        {
            reserve(last - first);
            while (first != last)
            {
                push_back(*first);
                ++first;
            }
        }


vector iterator — 迭代器


接口 说明
begin + end 获取第一个数据位置的iterator/const_iterator, 获取最后一个数据的下一个位置的iterator/const_iterator
rbegin + rend 获取最后一个数据位置的reverse_iterator,获取第一个数据前一个位置的reverse_iterator



84de5c4c7ea340218a15bfca1ba7b6c0.png


vector 空间函数


721eca9f73f846a5a73596ed75ea32d5.png


1.capacity的代码在vs和g++下分别运行会发现,vs下capacity是按1.5倍增长的,g++是按2倍增长的。
不要固化的认为,vector增容都是2倍,具体增长多少是根据具体的需求定义的。vs是PJ版本STL,g++是SGI版本STL

测试代码

// 测试vector的默认扩容机制
void TestVectorExpand()
{
 size_t sz;
 vector<int> v;
 sz = v.capacity();
 cout << "making v grow:\n";
 for (int i = 0; i < 100; ++i) 
 {
 v.push_back(i);
 if (sz != v.capacity()) 
 {
 sz = v.capacity();
 cout << "capacity changed: " << sz << '\n';
 }
 }
}


2.reserve只负责开辟空间,如果确定知道需要用多少空间,reserve可以缓解vector增容的代价缺陷问题,提高效率。

resize在开空间的同时还会进行初始化,影响size。


reserve函数

1.对于reserve以及resize函数来说,官方并没有将其设定为能够实际缩容的功能,明确规定这个函数在其他情况下,例如预留空间要比当前小的情况下,这个函数的调用是不会引起空间的重新分配的,也就是说容器vector的实际开辟的capacity是不会被影响的。值得注意的是缩容表面看起来是降低了空间的使用率,想要提高程序的效率,但实际上并未提高效率,缩容是需要异地缩容的,需要重新开空间和拷贝数据,代价不小,所以不建议对空间进行缩容

2.shrink_to_fit就是缩容函数,强制性的将capacity的大小降低到适配size大小的值,它的设计理念就是以空间来换时间,但日常人们所使用的手机或者PC空间实际上是足够的,不够的是时间,所以这种函数还是不要使用的为好,除非说你后面肯定不会插入数据了,不再进行任何modify操作,那你可以试着将空间还给操作系统,减少空间的使用率

//开辟空间
    void reserve(size_t n)
    {
      if (n > capacity()) //先检查,避免缩容
      //如果大于原有空间,则重新开辟并挪动数据,否则就不做任何行为
      {
        size_t sz = size();
        T* tmp = new T[n]; //开辟失败会抛异常
        if (_start)
        {
          memcpy(tmp, _start, , sizeof(T) * size());//拷贝旧数据
          delete[] _start;
        }
        //更新
        _start = tmp;
        _finish = _start + sz;
        _end_of_storage = _start + n;
      }
    }

resize函数

1.注意C++是泛型编程的,所以resize提供了参数 T val = T () 用匿名对象默认构造来初始化

  //调整空间和初始化
    void resize(size_t n; T val = T()) //泛型编程 --- T val = T() 即用匿名对象的默认构造来初始化
    {
      if (n < size()) //若小于原空间数据量
      {
        //删除数据,但并不实际上缩小空间
        _finish = _start + n;
      }
      else
      {
        if (n > capacity())
        {
          reserve(n);
        }
        while (_finish != _start + n)
        {
          *_finish = val;
          ++_finish;
        }
      }
    }


vector 增删查改


3e12268a14254f3dabe876d561966abe.png


1.这里push_back和pop_back都是对insert和erase的复用,所以我们主要介绍三个函数的模拟实现。find函数我们无需提供,直接调用算法模块库里的,对于swap我们需要直接实现,因为我们是一个自定义类型,浅拷贝无法实现swap。同时在实现insert和erase函数是我们会遇到第一个经典的迭代器失效问题,就是因为扩容操作,造成了迭代器野指针问题。


insert函数


1.在insert函数模拟实现中,因为插入数据,很多时候我们要对空间扩容,而且基本上是异地扩容,在扩容后迭代器pos指针就会指向已释放的空间,造成了野指针访问的问题,解决就是:更新pos迭代器指针

我们提供返回值 返回pos(使用时需要pos 接受返回值更新pos迭代器指针)

2.而且vector的插入操作如果导致底层空间重新开辟,则迭代器就会失效。如果空间足够,那么迭代也算失效了,因为数据相对位置已经发生改变,他已经不是指向之前的位置了。

3.那我们为什么不能用引用传参, 注意所有的传值返回都是拷贝临时对象,具有常性(不能改变),不能传给左值引用

iterator insert(iterator pos, const T& val)
    {
      assert(pos >= _start);
      assert(pos <= _finish);
      if (_finish == _end_of_storage) //检查扩容
      {
        size_t len = pos - _start; //记录len 后续pos更新
        reserve(capacity() == 0 ? 4 : capacity() * 2);
        //扩容后更新pos,解决迭代器pos失效问题
        pos = _start + len;
      }
      iterator end = _finish - 1;
      while (end >= pos) //遍历挪动数据
      {
        *(end + 1) = *end;
        --end;
      }
      *pos = val;
      ++_finish;
      return pos;
    }

erase函数

1.erase删除pos位置元素后,pos位置之后的元素会往前搬移,没有导致底层空间的改变,理论上讲迭代器不应该会失效,但是:如果pos刚好是最后一个元素,删完之后pos刚好是end的位置,而end位置是没有元素的,那么pos就失效了。因此删除vector中任意位置上元素时,vs就认为该位置迭代器失效了。

2. 在实现函数erase时,erase 之后,认为pos是否失效? 我们认为失效,不要访问,因为行为结果不能确定的,根据具体的编译器实现有关系 ---- 本质是不让pos跳过_finish(本该访问数据) 发生段错误或者结果不对

3. 在使用时,vector 的erase 要配合find实现


f8db3fa140ea4fbdbff16ddb1655da23.png


vs编译器会进行强制检查,erase以后的迭代器,都不能继续访问

// 返回删除数据的下一个数据
    // 方便解决:一边遍历一边删除的迭代器失效问题
        iterator erase(Iterator pos)
        {
            // 挪动数据进行删除
            iterator begin = pos + 1;
            while (begin != _finish)
            {
                *(begin - 1) = *begin;
                ++begin;
            }
            --_finish;
            return pos;
        }


operator[ ]

1.其实vector一般不常用迭代器。常用的stl只有 string和vector 会使用方括号,其他的都会使用迭代器

2.我们要注意越界访问的检查机制,直接断言assert(pos<size)

  //[]
    T& operator [] (size_t pos)
    {
      assert(pos <= size());
      return _start[pos];
    }
    const T& operator [] (size_t pos) const
    {
      assert(pos <= size());
      return _start[pos];
    }


附上:vector模拟实现源码

vector深度剖析

使用memcpy拷贝问题


memcpy是内存的二进制格式按字节拷贝,将一段内存空间中内容原封不动的拷贝到另外一段内存空间中。这是一种妥妥浅拷贝

如果拷贝的是自定义类型的元素,memcpy既高效又不会出错,但如果拷贝的是自定义类型元素,并且

自定义类型元素中涉及到资源管理时,就会出错,因为memcpy的拷贝实际是浅拷贝,会造成对象和拷贝对象指向同一块空间,在调用析构时,会二次析构。


f0024edb57424487b4356192ea8b999c.png


8246e576ed064500b50b6ef80f6ae738.png



  1. 结论:如果对象中涉及到资源管理时,千万不能使用memcpy进行对象之间的拷贝,因为memcpy是
    浅拷贝,否则可能会引起内存泄漏甚至程序崩溃。


迭代器失效问题


1.vector的迭代器是一个原生指针的typedef,所以迭代器失效的本质就是指针失效,换句话说就是野指针访问,对指针指向的无效空间进行访问所导致的问题。

2.在使用insert时,我们需要传某个位置的迭代器,如果在insert中不发生扩容,则这个迭代器在insert之后还是有效的,但只要在insert中发生扩容,则迭代器就会失效,因为reserve进行的是异地扩容,所以原有的迭代器就不能使用,因为原有迭代器指向的是已经释放的旧空间的某个位置,所以如果继续使用,则势必会发生野指针的访问,这正是我们所说的迭代器失效。

3.比如:vector的迭代器就是原生态指针T* 。因此迭代器失效,实际就是迭代器底层对应指针所指向的

空间被销毁了,而使用一块已经被释放的空间,造成的后果是程序崩溃(即如果继续使用已经失效的迭代器,

程序可能会崩溃)

4.对于vector可能会导致其迭代器失效的操作有:会引起其底层空间改变的操作,都有可能是迭代器失效,比如:resize、reserve、insert、assign、push_back等。


迭代器失效解决办法:在使用前,对迭代器重新赋值即可

vector二维数组

构造一个vv动态二维数组,vv中总共有n个元素,每个元素都是vector类型的,每行没有包含任何元素,如果n为5时如下所示:


b44ead0840c74a33999f390aab37f73c.png

vv中元素填充完成之后,如下图所示:


92557de349794c2ea29be2498ca8b99d.png


使用标准库中vector构建动态二维数组时与上图实际是一致的.

相关文章
|
7月前
|
程序员 编译器 C++
【STL】 模拟实现简易 vector
【STL】 模拟实现简易 vector
15 0
|
6天前
|
算法 C++ 容器
【C++/STL】vector(常见接口、模拟实现、迭代器失效)
【C++/STL】vector(常见接口、模拟实现、迭代器失效)
12 0
|
6天前
|
C++
【STL】:vector的模拟实现
【STL】:vector的模拟实现
39 0
|
5月前
|
Linux 编译器 测试技术
【C++】STL之vector类模拟-3
【C++】STL之vector类模拟
21 0
|
5月前
|
C++ 容器
【C++】STL之vector类模拟-2
【C++】STL之vector类模拟
20 0
|
5月前
|
安全 C++ 容器
【C++】STL之vector类模拟-1
【C++】STL之vector类模拟
24 0
|
5月前
|
存储 算法 C++
STL中vector的用法以及模拟实现
STL中vector的用法以及模拟实现
35 0
|
7月前
|
存储 C++ 容器
【C++】STL之vector操作
【C++】STL之vector操作
|
9月前
|
C++
【C++ STL】vector模拟实现
【C++ STL】vector模拟实现
37 0
【C++ STL】vector模拟实现
|
11月前
|
存储 C++ 容器
C++【STL】之vector模拟实现
C++ STL vector类模拟实现,常用接口详细讲解,干货满满!
51 0
C++【STL】之vector模拟实现