【C++】vector

一. vector是什么?

1. vector的定义与概念

vector是 C++ 标准模板库(STL)中的动态数组容器,支持动态调整大小、快速随机访问和灵活的插入与删除操作。可以将vector看成是一个动态顺序表或者是可变长数组。不过,与数据结构中的顺序表不同的是,vector支持不同类型的vector容器的对象同时存在。而它能支持这个功能是因为vector是底层是一个模板类。

cpp 复制代码
template < class T, class Alloc = allocator<T> > class vector; // generic template

2. vector的实际意义

vector在实际中的应用很广泛,无论是工程项目,还是算法竞赛都会频繁的使用到vector。vector的主要的应用场景就是顺序的存储数据,例如:用户信息的存储,算法竞赛中测试数据的存放等等。

二. vector的基本用法

1. vector的构造和遍历

1.1 vector的构造

vector的构造方法共有4个(这里主要讲解C++98版本的vector),其构造方式如下图

⚠ 注意:vector的成员函数中有关于allocator_type的参数代表的都是与内存池和空间配置器相关,而C++标准库中对vector等STL的容器都设有默认的空间配置器,也就是缺省值,所以我们不需要关注allocator_type类型的参数。

  1. 默认构造

    vector的默认构造其实就是构造了一个没有任何数据的vector容器

    cpp 复制代码
    // 默认构造
    vector<int> v1;
    vector<double> v2;
  2. 初始时构造n个元素

    vector在构造时可以直接初始化为具有n个数据的容器,同时这n个数据的值也可以被设定,默认数值为value_type()其实也就是0,其构造方式如下

    cpp 复制代码
    // v1初始化构造了10个元素,默认的初始值都是0
    vector<int> v1(10);
    // v2初始化也构造了10个元素,默认的初始值都是1
    vector<int> v2(10, 1);
  3. 迭代器区间构造

    vector可以使用相同类型的vector对象的迭代器区间进行构造,其构造效果就是将这段迭代器区间的值顺序插入在vector中

    cpp 复制代码
    // v1中有10个3
    vector<int> v1(10, 3);
    // v2中有5个3
    vector<int> v2(v1.begin() + 5, v1.end());
  4. 拷贝构造

    vector中还有封装好的拷贝构造,能进行深拷贝

    cpp 复制代码
    vector<int> v1(5, 1);
    vector<int> v2(v1);

1.2 vector的遍历

vector的遍历方式基本上和string的遍历方式一摸一样,在宏观上看只有两种遍历方式,分别是使用for循环+operator[]遍历迭代器遍历。而使用迭代器遍历又分为两种方式,一种是直接使用iterator遍历,另外一种是使用范围for进行遍历,下面以v1进行演示

cpp 复制代码
vector<int> v1;
v1.push_back(1);   //在v1的尾部插入元素
v1.push_back(2);
v1.push_back(3);
  • for循环+operator\[\]

    cpp 复制代码
    for(size_t i = 0; i < v1.size(); i++)
    {
        //cout << v1.operator[](i) << endl;
        cout << v1[i] << " ";
    }
    // 输出:1 2 3
  • 迭代器

    • 使用iterator遍历

      cpp 复制代码
      vector<int>::iterator it = v1.begin();
      while(it != v1.end())
      {
          cout << *it << " ";
          ++it;
      }
      // 输出:1 2 3
    • 范围for

      cpp 复制代码
      for(auto& e : v1)
      {
          cout << e << " ";
      }
      // 输出:1 2 3

2. vector的增删查改

在C++98中,vector较为常用的修改元素的成员函数有push_backpop_backinserterase,这些函数我们在string的使用部分都见过了,它们的功能和使用方法都大同小异(后文会讲解)。而C++11又引入了两个新的修改相关的成员函数emplace_backemplace其作用和push_back,insert差不多,但是底层原理和涉及的知识却非常的多,这里两个成员函数我们在之后的篇章进行详细的讲解。


  • push_back/pop_back

    这两个成员函数分别对应着尾插和尾删,它们的功能和使用和string中的push_back,pop_back完全一样,下面进行演示

    cpp 复制代码
    vector<int> v1;
    v1.push_back(1);   //尾部插入1
    v1.push_back(2);   //尾部插入2
    v1.pop_back();   //尾部删除2
    v1.pop_back();   //尾部删除1
  • insert

    insert的函数功能就是在vector的任意位置插入一个数据或者是一段区间范围的数据。vector的insert(之后用vector::insert)与string的insert(之后用string::insert)不同的是string::insert是通过传递插入位置的下标或下标区间进行插入数据,而vector::insert是通过传递插入位置的迭代器或迭代器区间进行插入数据。vector::insert函数重载图如下

    vector::insert使用演示

    cpp 复制代码
    vector<int> v1;
    v1.insert(v1.begin(), 10);   //传递初始位置的迭代器,相当于是头插
    v1.insert(v1.end(), 100);   //传递末尾位置的迭代器,相当于是尾插
    v1.insert(v1.begin() + 1, 10, 2);     //在v1[1]位置插入10个1
    vector<int> v2;
    v2.insert(v2.begin(), v1.begin(), v1.end());   //往v2的起始位置插入一段迭代器区间的值
    // v1中的数据:10 2 2 2 2 2 2 2 2 2 2 100
    // v2中的数据:10 2 2 2 2 2 2 2 2 2 2 100

    ⚠ 注意:使用vector::insert时,传递的插入位置的迭代器是有范围限制的,其只能传递[begin(), end()]区间的迭代器,超出这个区间,程序会断言报错。

  • erase

    erase的使用和insert的使用一样,都是使用迭代器或迭代器区间来确定函数需要作用的位置。如果传递一个有效的迭代器,则erase会删除这个迭代器指向位置的元素,如果传递的是一个迭代器区间,则erase会删除这个迭代器区间的所有的元素。需要注意的是,erase删除的迭代器区间范围是[first, last),last指向位置的元素并不会被删除。所以,如果想删除vector容器中所有的值,erase的调用逻辑应该是erase(v.begin(), v.end()),而不是erase(v.begin(), v.end() - 1)

    erase使用演示

    cpp 复制代码
    vector<int> v1(10, 2);
    v1.erase(v1.begin());   //只删除begin指向位置的元素,相当于头删
    v1.erase(v1.begin(), v1.end());   //删除v1中所有的数值

3. vector的拓展用法

  • initializer_list

    vector的构造方法不仅仅有上面讲的那4种方式,还有一种较为常见的构造方法,使用initializer_list容器构造。其构造方法如下。不难发现,使用initializer_list的构造方法和静态数组的初始化非常相似(int a[] = {1,2,3};),而这是因为initializer_list本身就像是一个静态数组一样的东西,下面我们来看一下std::initializer_list的定义。

    cpp 复制代码
    // 使用initializer_list构造初始化vector
    vector<int> v1({1,2,3,4,5});
    for(auto& e : v1)
    {
        cout << e << " ";
    }
    cout << endl;
    // 输出:1 2 3 4 5

    initializer_list


    从标准库中给出的initializer_list的说明(which is a list of elements of type const T)得出,它其实就是一个可以存放任意类型列静态数组,比起普通数组,只是initializer_list被封装成了类模板,但是其不存在什么扩容机制。所以,vector的初始化可以使用{数据}的样式,其本质还是构造了一个initializer_list的临时对象来初始化vector。


  • emplace_back

    emplace_back我们暂且可以将他当作和push_back一样的东西,但是它和push_back还是有些差异的,这些差异主要体现在对于自定义类型的插入数据的操作。比较明显的差异有两个。

    cpp 复制代码
    struct A
    {
    	A(int a1, int a2)
    		:_a1(a1)
    		,_a2(a2)
    	{
    		cout << "A(int a1, int a2)" << endl;
    	}
    	A(const A& aa)
    		:_a1(aa._a1)
    		,_a2(aa._a2)
    	{
    		cout << "A(const A& aa)" << endl;
    	}
    	int _a1;
    	int _a2;
    };
    1. 使用方法方面

      在【C++】类和对象(下)讲过,对于具有单参数构造函数的类类型,可以直接使用参数隐式类型转换为该类型,如果要使用多参数的构造函数进行隐式类型转换,多个参数需要使用花括号将参数按顺序括起来({参数1,参数2......参数n})。所以,对于一个存放A类型的vector,push_back的插入方式需要使用花括号,但是emplace_back不能使用花括号将参数括起来

      cpp 复制代码
      vector<A> v1;
      int a1 = 1, a2 = 2;
      // 使用多参数的构造进行隐式类型转换,参数需要使用{}括起来
      v1.push_back({a1, a2});
      // emplace_back的特殊性
      v1.emplace_back(a1, a2);
    2. 效率方面

      既然emplace_back在使用上弄了特殊,那肯定是有原因的,这原因就体现在了emplace_back插入数据的效率上。我们可以创建如下场景进行检验。从输出结果可以发现,使用emplace_back(3, 4)的效率最高,只有一次拷贝构造。

      cpp 复制代码
      vector<A> v1;
      A aa(10, 20);
      // push_back;
      v1.push_back(aa);
      cout << endl;
      v1.push_back(A(1, 2));
      cout << endl;
      v1.push_back({ 3,4 });
      cout << endl << endl;
      // emplace
      v1.emplace_back(aa);
      cout << endl;
      v1.emplace_back(A(1, 2));
      cout << endl;
      v1.emplace_back(3, 4);

注意:使用emplace_back插入数据并不是所有的场景下都会有效率的提升。例如内置类型的插入数据就没有什么实质性的效率提升,而自定义类型也不是任意时候都有效率提升,上面代码中的效率提升仅仅是限于在那个特殊的场景。

三. vector的模拟实现

1. vector源码的简单分析

前面我们说了,vector本质上可以看成数据结构中的动态顺序表,那么vector的底层实现是否就是实现这么一个简单的顺序表呢?我们暂时先不盖棺定论,可以先看vector的源码,分析vector其底层是如何实现的。而分析源码上也有一些技巧,我把它分为了3个步骤

注:源码来源于侯捷老师的《STL源码剖析》

  1. 先找到核心的类的实现,再看类的核心------成员变量

    • 首先是在源码的各个文件中找到核心的类的实现的文件,以vector源码为例,这里我们可以通过文件名直接锁定到stl_vector.h文件中
    cpp 复制代码
    #ifndef __SGI_STL_VECTOR
    #define __SGI_STL_VECTOR
    
    #include <stl_algobase.h>
    #include <stl_alloc.h>
    #include <stl_construct.h>
    #include <stl_uninitialized.h>
    #include <stl_vector.h>   // 核心类的实现
    #include <stl_bvector.h>
    
    #endif /* __SGI_STL_VECTOR */
    
    // Local Variables:
    // mode:C++
    // End:
    • 接着就在stl_vector.h中vector类的核心,也就是成员变量

  2. 锁定成员变量后,连蒙带猜的理解/确定成员变量的意思

    通过源码我们发现,这里的成员变量的定义并不是我们预想的那样(动态顺序表的成员),而是3个迭代器,那这里3个迭代器又代表了什么意思呢?前面我们说了vector本质上是一个动态顺序表,那么不管它的底层是如何实现的,源码的版本是什么,其成员变量一定要能表示出动态顺序表的基础:数组/容器( _array),元素个数( _size),容量( _capacity)

    所以,这里我们就可以猜测,start是指向数组/容器的起始位置的迭代器,finish是指向容器最后一个有效元素下一个位置的迭代器,end_of_storage是指向容器最后一个有效空间下一个位置的迭代器。那么如何进行验证呢?我们可以通过成员函数begin(), end(), capacity()来验证猜测。

    从begin和end函数可以直接确定start和finish的猜测是正确的,因为指向容器最后一个有效空间下一个位置的迭代器 - 容器起始位置的迭代器 = 容器的总容量,所以可以确定end_of_storage的猜测正确。

  3. 大概理解类中核心成员函数的实现,弄懂这个类的核心作用

    有了上面的铺垫,到这一步其实已经很轻松了,上面确定了的成员变量的各个含义,那么容器的大小size可以通过finish - start得到,容器的容量capacity可以通过end_of_storage - start得到。因为动态顺序表的各种操作都是基于size和capatity实现的,所以这里不难推断出vector成员函数的实现是基于3个成员变量间相互的运算。知道了这个规律,vector的底层实现就可以类比动态顺序表的实现,代码编写也就变得容易了。

⚠ 注意:我们这里分析vector的源码这么轻松是因为我们提前就知道了vector的作用且这里只有一个核心的类,但是实际的项目情况远没有这么简单,项目中可能会有上百个类,它们各自都对应着你不知道的功能。所以,我们对于源码的阅读能力还需要慢慢加强,精进。最后要记住,读源码时切记不要死磕细节,不要拿到源代码后按顺序一点一点的去看,主要是理解大框架与核心

2. vector的模拟实现

vector类的主体框架
成员变量 含义 直接相关函数
iterator _start 指向数组/容器的起始位置的迭代器 begin() = _start
iterator _finish 指向容器最后一个有效元素下一个位置的迭代器 end() = _finish
iterator _end_of_storage 指向容器最后一个有效空间下一个位置的迭代器 capacity() = _end_of_storage - _star
cpp 复制代码
// vector.h
namespace yzx
{
    template<class T>
    class vector
    {
    public:
        typedef T* iterator;   //为了框架易懂,这里提前透露迭代器的底层`^`
        vector() {}
        成员函数......
    private:
        iterator _start;
        iterator _finish;
        iterator _end_of_storage;
    };
}

前面有了【C++】string的模拟实现(感兴趣的读者可以去阅读,那里讲过的很多基础的这里就不会再讲了)的基础后,这里命名空间的作用就不再多说了。唯一需要注意的是,因为vector是类模板,而类模板的声明和定义不能分离到不同的文件 ,所以vector的成员函数的实现全都要写在vector.h中。

构造函数的实现

构造函数的目的就是需要对成员变量进行初始化,但是这里的3个成员变量都是迭代器,那要如何初始化呢?前面我们说了,迭代器是像指针一样的东西,但是并不一定是指针(vs2022编译器下vector的迭代器就不是指针)。但是,这里为了方便起见,就直接使用T类型的指针作为迭代器的底层。

cpp 复制代码
vector()
    :_start(nullptr)
    ,_finish(nullptr)
	,_end_of_storage(nullptr)       
{}

但是这样实现其实还是有些问题,如果不做修改的话,之后每写一个构造函数,都要显式的使用初始化列表将3个迭代器赋值为nullptr,而这样实现太麻烦了,这里可以使用【C++】类和对象(下)讲的内容,使用缺省值初始化成员变量。如下所示

cpp 复制代码
class vector
{
public:
    vector(){}
private:
    iterator _start = nullptr;
    iterator _finish = nullptr;
    iterator _end_of_storage = nullptr;
};

使用initializer_list构造

为了后面测试方便,这里我们先实现使用initializer_list对象做参数的构造函数。当然,这里就需要提前透支一下push_backreserve了,文章后面会实现的^ _ ^

cpp 复制代码
vector(initializer_list<T>& li)
{
    reserve(li.size());
    for(const auto& e : li)
    {
        push_back(e);
    }
}

注:这里先只实现两个构造函数的重载,其它的构造函数等后面实现了一些成员函数后,直接复用成员函数,实现起来会更加方便。

迭代器相关函数的实现
  • iterator

    cpp 复制代码
    iterator begin()
    {
        return _start;
    }
    cpp 复制代码
    iterator end()
    {
        return _finish;
    }
  • const_iterator

    iterator只能用于普通的vector对象的遍历,当遇到const修饰的vector对象时,就需要const迭代器,也就是const_iterator。因为const迭代器不能修改其指向的位置的值(简单来讲就是不能修改*const迭代器),所以const_iterator的实质为const T*

    cpp 复制代码
    typedef const T* const_iterator;
    const_iterator begin() const
    {
        return _start;
    }
    const_iterator end() const
    {
        return _finish;
    }
  • 泛化编程(模板遍历)

    实现了迭代器之后,我们就可以使用范围for来遍历vector容器,为了方便后续测试成员函数,可以将遍历vector容器的功能封装成一个函数Print,如下所示

    cpp 复制代码
    void Print(const yzx::vector<int>& v)
    {
        for(const auto& e : v)
        {
            cout << e << " ";
        }
        cout << endl;
    }

    封装完这样一个简单的遍历函数,看似是没有任何问题了,但是,其实这个函数透露出很多不方便的地方。这个Print函数用于yzx::vector类型的对象遍历是足够了,那如果后续我需要遍历的是std::vector类型的对象要怎么办呢?就不说不同的容器,这个函数甚至连yzx::vector类型的对象都遍历不了。所以,这就可以看出这个函数的缺陷了,就是耦合性很高,很不方便。

    当编写一个程序时,应该尽可能的让程序耦合性降低,让一个函数可以被多种类型使用,一个类可以同时实例化多个不同类型的对象,尽可能的让代码更具有通用性,也就是泛化编程。那要如何改造这个Print函数使得它更具有通用性,可以被其它类型的容器使用呢?很简单,使用函数模板即可,代码如下所示。将Print改成函数模板后,只要是有迭代器的容器,就都可以使用这个函数进行遍历了。

    cpp 复制代码
    template<class container>
    void Print(const container& con)
    {
        for(const auto& e : con)
        {
            cout << e << " ";
        }
        cout << endl;
    }
增删查改的函数实现

以下主要模拟实现vector核心的增删查改的成员函数,在实现增删查改的成员函数时,会带出一些相关的其它成员函数,如:reserve, size, capacity, resize等。

扩容导致_finish位置出错问题
  • size和capacity

    这里先实现两个最基本的成员函数sizecapacity方便后面的函数调用。

    cpp 复制代码
    size_t size() const
    {
        return _finish - _start;
    }
    size_t capacity() const
    {
        return _end_of_storage - _start;
    }
  • push_back和reserve

    push_back就是简单的在vector容器末尾位置插入一个元素,函数的逻辑很简单(对比string::push_back的实现都简单很多)这里就不再啰嗦了。不过,需要注意的是,在插入元素前需要判断容器有没有满,如果满了的话就需要扩容,所以这里还需要写一个扩容的函数reserve

    cpp 复制代码
    // vector.h
    void reserve(size_t n)
    {
        if(n > capacity())
        {
            T* tmp = new T[n];
            if(_start)
            {
                memcpy(tmp, _start, sizeof(T) * size());
                delete[] _start;
            }
            _start = tmp;
            _finish = _start + size();
            _end_of_storage = _start + n;
        }
    }
    void push_back(const T& val)
    {
        if(_finish == _end_of_storage)
        {
            reserve(capacity() == 0 ? 4 : 2 * capacity());
        }
        
        *_finish = val;
        ++_finish;
    }
    cpp 复制代码
    // test.cpp
    int main()
    {
        yzx::vector<int> v1;
        v1.push_back(1);
        v1.push_back(2);
        v1.push_back(3);
        v1.push_back(4);
        Print(v1);
        return 0;
    }
    // 输出:1 2 3 4

    我们通过插入4个数据的结果得出来这个push_back和reserve函数的正确性没有什么问题,但是这里真的没有问题吗?其实你可以尝试一下插入5个数据,问题自然就出现了。而问题就在reserve中(其实从4个数据没有问题而5个就有问题的状况也可以猜出)。我们来看reserve中的这两行代码。

    当tmp扩容完成且将_start的数据拷贝到tmp中后,此时将tmp赋值给 _start ,这个 _start就变成新的 _start了。而 _finish是用新的 _start + 有效数据元素个数,这里逻辑上没有问题,但是size()确出现问题了 。我们知道,当函数执行到第2行时 _start已经是新的 _start了,此时size的计算逻辑是旧的_finsh - 新的_start,所以这里的size计算出来的结果肯定是有问题的。

    cpp 复制代码
    _start = tmp;
    _finish = _start + size();

    这里就可以得出,_finish的值不能使用实时的size()计算,解决方案就是提前记录好有效元素的个数old_size,后面都使用old_size更新结果。

    cpp 复制代码
    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 = _start + old_size;
            _end_of_storage = _start + n;
        }
    }
  • pop_back和empty

    cpp 复制代码
    bool empty() const
    {
        return _start != _finish;
    }
    void pop_back()
    {
        assert(!empty());
        --_finish;
    }
扩容导致迭代器位置错误问题
  • insert

    insert和之前唯一不同的点就是,vector::insert是传递插入数据位置的迭代器,迭代器的范围要在[_start, _finish]中。

    cpp 复制代码
    void insert(iterator pos, const T& val)
    {
        assert(pos >= _start);
        assert(pos <= _finish);
        // 判断是否需要扩容
        if(_finish == _end_of_storage)
        {
            reserve(capacity() == 0 ? 4 : 2 * capacity());
        }
        // 挪动数据
        iterator end = _finish;
        while(end > pos)
        {
            *end = *(end - 1);
            --end;
        }
        *pos = val;
        ++_finish;
    }

    这里直接给出结论,这段代码是有问题的,且无法预期输出结果。这里的问题其实和上面的问题类似,都是扩容引发的问题。我们知道,容器扩容之后,整个容器的内存地址会改变,也就是在新地址上了,而上面实现的insert函数中的pos迭代器指向的还是旧的内存地址。从底层来看,此时pos就是一个野指针,从表现上看,这个pos就是一个无效的迭代器。而使用pos进行的各种操作肯定都是有问题的。

    这里的解决方案和前面的差不多,就是提前记录是要在哪个位置插入数据,在扩容完之后,将更新pos更新成有效的迭代器即可。

    cpp 复制代码
    void insert(iterator pos, const T& val)
    {
        assert(pos >= _start);
        assert(pos <= _finish);
        // 判断是否需要扩容
        if(_finish == _end_of_storage)
        {
            size_t old_pos = pos - _start;   //扩容前记录好是要在第几个位置插入数据
            reserve(capacity() == 0 ? 4 : 2 * capacity());
            pos = _start + old_pos;   //使用新的_start更新pos
        }
        // 挪动数据
        iterator end = _finish;
        while(end > pos)
        {
            *end = *(end - 1);
            --end;
        }
        *pos = val;
        ++_finish;
    }
  • erase

    erase的实现没有那么多坑,但是判断pos需要注意,因为_finish指向的位置是没有有效元素的,所以pos要在区间[_start, _finish)中。

    cpp 复制代码
    void erase(iterator pos)
    {
        assert(pos >= _start);
        assert(pos < _finish);   // 注意这里不能使用<=
        iterator end = pos + 1;
        while(end < _finish)
        {
            *(end - 1) = *end;
            ++end;
        }
    }
insert/erase导致迭代器失效问题(重点)

由上面我们知道了,扩容会导致原先的迭代器指向的位置失效,那来看下面这段代码。问:这里it作为insert的参数过后失效了吗?

cpp 复制代码
yzx::vector<int> v1 = { 1,2,3,4,5 };
auto it = find(v1.begin(), v1.end(), 3);
v1.insert(it, 10);

或许有的人会说有可能失效,主要是看insert有没有发生扩容。实际情况也确实是这样,但是,这里我们必须认为它失效了,且如果没有给it赋新的值,就不能再使用它了。++而由insert使得it失效了的现象就叫做迭代器失效++。

注:这里给出it一定失效的理由。不同的编译器的扩容机制是不一样的,不说你有没有计算当时容器中有效的元素个数,就算知道了有效元素的个数也不能确定编译器在什么时候会扩容,所以,这里统一的认为,在insert过后,it一定失效。

  • 删除所有偶数情景

    现在再看一个情景:删除v1中的所有偶数。如下代码所示。这里迭代器it作为erase的参数之后还继续使用了,而且得出来的结果还是正确的结果,或许有人会觉得这是因为erase不会涉及到扩容,得出erase不会使得迭代器失效的结论。但是,这里迭代器it也失效了,不管结果如何,我们都必须认为it失效了。我们可以通过vs 2022验证这个it是否失效。

    cpp 复制代码
    yzx::vector<int> v1 = { 1,2,3,4,5 };
    auto it = v1.begin();
    while(it != v1.end())
    {
        if(*it % 2 == 0)
        {
            erase(it);
        }else{
            ++it;
        }
    }
    Print(v1);
    // 输出:1 3 5
  • vs2022下迭代器失效的严格检查

    在vs 2022下对于迭代器失效具有严格的检查,而要触发这个检查,就需要使用std::vector完成上面一系列的操作。

    cpp 复制代码
    std::vector<int> v1 = { 1,2,3,4,5 };
    auto it = v1.begin();
    while(it != v1.end())
    {
        if(*it % 2 == 0)
        {
            erase(it);
        }else{
            ++it;
        }
    }
    Print(v1);
    // 结果:程序崩溃

由std::vector引发的结果就可以得出,erase也会使得迭代器失效。那万一真的需要继续使用插入/删除位置的迭代器要怎么办?C++标准库给出了解决方案。

  • 迭代器失效解决方案:函数返回值

    因为迭代器在作insert/erase的参数之后被严格规定了迭代器失效,如果遇到需要继续使用该位置的迭代器的情况就会很麻烦,所以,C++标准库规定,将插入位置/删除位置的有效的迭代器作为insert/erase的返回值(即返回it的位置的有效的迭代器)。这个其实可以通过标准库中insert和erase的函数接口验证。

所以,我们上面实现的insert和erase函数也需要略微的修改,如下所示

cpp 复制代码
iterator insert(iterator pos, const T& val)
{
    assert(pos >= _start);
    assert(pos <= _finish);
    // 判断是否需要扩容
    if(_finish == _end_of_storage)
    {
        size_t old_pos = pos - _start;   //扩容前记录好是要在第几个位置插入数据
        reserve(capacity() == 0 ? 4 : 2 * capacity());
        pos = _start + old_pos;   //使用新的_start更新pos
    }
    // 挪动数据
    iterator end = _finish;
    while(end > pos)
    {
        *end = *(end - 1);
        --end;
    }
    *pos = val;
    ++_finish;
    return pos;
}
iterator erase(iterator pos)
{
	assert(pos >= _start);
    assert(pos < _finish);   // 注意这里不能使用<=
    iterator end = pos + 1;
    while(end < _finish)
    {
        *(end - 1) = *end;
        ++end;
    }
    return pos;
}
默认构造做缺省参数
  • resize

    resize可以调整容器有效元素个数,如果是增加有效元素的个数,可以指定添加的元素的初始值,而如果没有指定添加的元素的初始值,就使用默认值进行初始化。使用默认值初始化的这个默认值也就是缺省值,那么问题来了,如何设定缺省值呢?因为我们写的vector是一个类模板,所以缺省参数val可以是任意的类型,那是否有什么值能适配所有类型的初始化呢?

    cpp 复制代码
    void resize(size_t n, const T& val = ?);

    答案当然是没有的,这里也不使用那"万能的数值",而是使用T类型的默认构造做缺省值。这里有的人会有些疑问,自定义类型一定会有默认构造能理解,那内置类型哪里来的默认构造函数啊?

    cpp 复制代码
    void resize(size_t n, const T& val = T());

    其实,C++对内置类型进行了升级,内置类型也具有构造函数。这里使用以下几种初始化方式进行验证。

    cpp 复制代码
    int a(2);
    int b = int();
    int c(a);
    cout << a << " " << b << " " << c;
    // 输出:2 0 2
    cpp 复制代码
    void resize(size_t n, const T& val = T())
    {
        if(n > size())
        {
            reserve(n);
            while(_finish < _start + n)
            {
                push_back(val);
            }
        }
        else
        {
            _finish = _start + n;
        }
    }
再探构造函数
  • 拷贝构造

    cpp 复制代码
    vector(const vector<T>& v)
    {
        reserve(v.capacity());
        for(const auto& e : v)
        {
            push_back(e);
        }
    }
  • 迭代器区间构造

    前面使用std::vector讲过,vector是支持使用一段迭代器区间进行初始化的,在这里,我们也可以写一个使用迭代器区间进行构造的函数,如下所示。

    cpp 复制代码
    vector(iterator first, iterator last)
    {
        assert(first < last);
        size_t len = last - first;
        reserve(len);
        while(first != last)
        {
            push_back(*first);
        }
    }

    现在,yzx::vector是支持使用同类型的迭代器区间构造初始化了,但是它有一个缺陷就是,仅仅只能使用同类型的对象进行初始化。上面写的使用迭代器区间进行构造的函数,对于yzx::vector类型,只能使用yzx::vector类型进行构造,如果想使用yzx::vector,string等任意的其它的类型构造都不行。

    cpp 复制代码
    yzx::vector<int> v1 = { 1,2,3,4,5 };
    yzx::vector<int> v2(v1.begin() + 1, v1.end() - 1);
    cpp 复制代码
    string s("change world");
    yzx::vector<int> v3(s.begin(), s.end());   // 编译器会报错

    如果仅仅是这样的迭代器区间构造,那这个函数就太low了。所以,这里就需要使用前面所说的泛化编程。因为我们想要的功能是可以使用任意类型的迭代器区间进行构造,而不管什么类型的迭代器在遍历容器时都是一样的步骤,所以我们可以将这个使用迭代器区间构造的函数写成一个函数模板,让这个函数更具有通用性,函数模板的实现如下。

    模板参数中enable_if_t<_Is_iterator_v<InputIterator>, int> = 0是为了防止当前这个构造函数和使用n个数据直接构造的函数在调用时发生歧义,加上这段语句后,就只有当使用迭代器区间对vector初始化时,才会调用这个构造函数。

    cpp 复制代码
    template<class InputIterator, enable_if_t<_Is_iterator_v<InputIterator>, int> = 0>
    vector(InputIterator first, InputIterator last)
    {
        assert(first < last);
        size_t len = last - first;
        reserve(len);
        while(first != last)
        {
            push_back(*first);
        }
    }

    其实,标准库中的迭代器区间构造的函数写的也是函数模板,如下所示

  • n个数据直接构造

    cpp 复制代码
    vector(size_t n, const T& val = T())
    {
        reserve(n);
        for(size_t i = 0; i < n; i++)
        {
            push_back(val);
        }
    }
析构函数与赋值运算符重载
  • 析构函数

    cpp 复制代码
    ~vector()
    {
        if(_start)
        {
            delete[] _start;
            _start = _finish = _end_of_storage = nullptr;
        }
    }
  • 赋值运算符重载

    cpp 复制代码
    // 成员函数swap
    void swap(vector<T>& v)
    {
        std::swap(_start, v._start);
        std::swap(_finish, v._finish);
        std::swap(_end_of_storage, v._end_of_storage);
    }
    // 赋值运算符重载的现代写法
    vector<T>& operator=(const vector<T>& v)
    {
        vector<T> tmp(v);
        swap(tmp);
        return *this;
    }
深层次的深浅拷贝问题(重点)

到这里,vector的模拟实现已经完成的差不多了,各个函数的功能也都没有什么问题,但是这里其实还隐藏一个大问题,我们来看下面这段程序。按我们之前实现的vector的逻辑来看,因为vector是类模板,可以支持任意的类型实例化,所以这段代码应该是没有任何问题的。可以运行一下这段代码进行测试,结果如下图,程序直接崩溃了。

cpp 复制代码
yzx::vector<string> v1 = { "12345","12345","12345","12345" };
Print(v1);
v1.push_back("12345");
Print(v1);

这里程序为什么会崩溃?这里直接给出答案,其实就是深层次的深浅拷贝问题,对于这段代码来说,就是string和string之间的拷贝出了问题。通过测试会发现,使用string实例化vector,当插入4个数据程序没有任何的问题,但是插入5个数据程序就会崩溃,所以这里不难想到,又是reserve出了问题。那么问题是什么呢?来看下面reserve拷贝的逻辑的核心代码。

cpp 复制代码
// reserve函数内部代码片段
size_t old_size = size();
T* tmp = new T[n];
if(_start)
{
    memcpy(tmp, _start, sizeof(T) * old_size);
    delete[] _start;
}

代码中,tmp申请了新的空间之后,需要将_start中的数据拷贝过来,但是这里拷贝数据使用的是memcpy,而问题就出在这里。我们知道,memcpy的拷贝逻辑是一个字节一个字节的拷贝,那这里等于是直接将string中的指针_str进行值拷贝,而并没有_str指向的字符数组开辟空间进行深拷贝,所以导致拷贝完之后,tmp中的string和 _start中的string指向的是同一块内存空间,如下图所示。这就导致string和string之间进行的是浅拷贝。

注意,这里不是vector和vector之间是浅拷贝,而是string和string之间是浅拷贝,是更深层次的深浅拷贝问题。

这个问题的解决方案就是使用string的operator=,利用string的赋值运算符重载具有深拷贝的特性进行string和string之间的深拷贝。reserve最终版的代码如下。

cpp 复制代码
void reserve(size_t n)
{
	if (n > capacity())
	{
		size_t old_size = size();
		T* tmp = new T[n];
		// 拷贝
		if (_start)
		{
			// 使用这个如果遇到本身具有动态内存的类作为类型程序会崩溃
			// 因为使用memcpy对于容器内部的类型来说是浅拷贝
			// memcpy(tmp, _start, sizeof(T) * old_size);
			for (size_t i = 0; i < size(); i++)
			{
				tmp[i] = _start[i];
			}
			delete[] _start;
		}

		_start = tmp;
		_finish = _start + old_size;
		_the_end_of_storage = _start + n;
	}
}
相关推荐
Lhappy嘻嘻19 小时前
网络(四)|全网最细网络层原理:IP 协议、IP 地址分类、子网划分、路由转发、NAT 穿透详解
java·笔记·网络协议·计算机网络
小刘学技术19 小时前
AI人工智能决策树分类器:原理、实现与应用
开发语言·人工智能·python·算法·决策树·机器学习·数据挖掘
脱胎换骨-军哥19 小时前
C++ 嵌入式编程实例:从寄存器操作到底层驱动开发
开发语言·c++·驱动开发
double_eggm20 小时前
uniapp.3
开发语言·前端·javascript
白玉cfc20 小时前
熟悉Objective-C
开发语言·ios·objective-c
吃着火锅x唱着歌20 小时前
Effective C++ 学习笔记 条款38 通过复合塑模出has-a或“根据某物实现出”
c++·笔记·学习
syagain_zsx20 小时前
库制作与原理 · 链接知识笔记
c语言·c++·笔记·动态库·静态库
qz5zwangzihan120 小时前
题解:Atcoder Beginner Contest abc467 A~D
c++·题解·atcoder·abc467
caimouse20 小时前
mm学习笔记_04:VAD树算法与地址空间分配
笔记·学习·算法·reactos