【C++/STL】:vector容器的底层剖析&&迭代器失效&&隐藏的浅拷贝

简介: 【C++/STL】:vector容器的底层剖析&&迭代器失效&&隐藏的浅拷贝

💡前言

点击跳转到文章vector容器的基本使用

上篇文章已经介绍了vector容器的基本使用,这篇文章主要选择vector中一些核心的,基本的接口进行模拟实现。

注意:由于我们模拟实现时使用了类模板所以不建议进行文件分离,不然会产生链接错误所以我们把函数都写在.h文件中,在Test.cpp文件中进行测试

首先我们先给出vector类:

#include <assert.h>
#include <vector>
#include <iostream>
using namespace std;
template<class T>
class vector
{
public:
  // Vector的迭代器是一个原生指针
  typedef T* iterator;
  typedef T* const_iterator;
  
  //......
  
private:
  iterator _start = nullptr;//指向开始位置的指针
  iterator _finish = nullptr;//指向最后一个位置的下一个位置的指针
  iterator _end_of_storage = nullptr;//指向存储容量的尾
};

一,构造函数

在vector文档中,构造函数分为好几个类型,下面分别进行介绍:

1 . 强制编译器生成默认构造

无参构造

vector() = default;

2 . 拷贝构造

//v2(v1);
vector(const vector<T>& v)
{
  //提前预开空间,避免边尾插边扩容
  reserve(v.capacity());
  for (auto e : v)
  {
    //this->push_back(e);
    push_back(e);
  }
}

3. 用迭代器区间初始化

(1) 一个类模版的成员函数可以写成函数模版

(2) 若使用iterator做迭代器,会导致初始化的迭代器区间[first,last)只能是vector的迭代器,重新声明迭代器,迭代器区间[first,last)可以是任意容器的迭代器

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

4. 用n个val值构造

vector(size_t n, const T& val = T())
{
  reserve(n);
  for (size_t i = 0; i < n; i++)
  {
    push_back(val);
  }
}
vector(int n, const T& val = T())
{
  reserve(n);
  for (size_t i = 0; i < n; i++)
  {
    push_back(val);
  }
}

具体使用:

//初始化3个空
vector<string> v1(3);
//初始化为3个xx
vector<string> v2(3, "xx");
//初始化为3个1
vector<int> v3(3, 1);//err 非法的间接寻址.参数匹配问题
vector<int> v3(3u, 1);//ok
//如果非要这样传参数,就需要再重载一个构造
vector<int> v3(3, 1);

(1) 为什么要重载两个类似的构造

理论上将,提供了vector(size_t n, const T& value = T())之后vector(int n, const T& value = T())就不需要提供了,但是对于:vector< int > v(10, 5);

编译器在编译时,认为T已经被实例化为int,而10和5编译器会默认其为int类型, 就不会走vector(size_t n, const T& value = T())这个构造方法, 最终选择的是:vector(InputIterator first, InputIterator last)。因为编译器觉得区间构造两个参数类型一致,因此编译器就会InputIterator实例化为int,但是10和5根本不是一个区间,编译时就报错了,故需要增加该构造方法。

(2) T()是什么

T()是缺省值,注意这里不能给0,因为T可能为自定义类型,当T为内置类型的时候,也ok。因为C++对内置类型进行了升级,也有构造,为了兼容模版。

5. initializer_list 的构造

(1) C++11新增的类模板initializer_list,方便用花括号初始化

(2) 这里不需要传引用&,因为不涉及拷贝问题,它的内部其实有两个指针,一个指向第一个值的位置,一个指向最后一个值的下一个位置,并且支持迭代器

vector(initializer_list<T> il)
{
  reserve(il.size());
  for (auto e:il)
  {
    push_back(e);
  }
}

具体使用:

支持被花括号括起的任意个数的值给 initializer_list

auto il1 = { 1,3,4,5,6,7 };
initializer_list<int> il2 = { 1,2,3 };
//这里的隐式类型转换不一样,参数个数不固定
vector<int> v4 = { 7,8,9,4,5 };//隐式类型转换
vector<int> v5({ 4,5,6 });//直接构造

二,析构函数

~vector()
{
  if (_start)
  {
    delete[] _start;
    _start = _finish = _end_of_storage = nullptr;
  }
}

三,关于迭代器

iterator begin()
{
  return _start;
}
iterator end()
{
  return _finish;
}
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;
}

五,交换函数swap

与string类类似,依然调用库里的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);
}

六,赋值拷贝

与string类的现代写法相同。

//赋值拷贝
//v3 = v1;
vector<T>& operator=(const vector<T> v)
{
  swap(v);
  return *this;
}

七,[ ]运算符

//[]运算符
T& operator[](size_t pos)
{
  //断言,避免下标越界
  assert(pos < size());
  return _start[pos];
}

八,预留空间(扩容)

void reserve(size_t n)
{
  //由于开空间后_start指向改变,所以要提前记录原来的有效数据个数
  //以便在新空间中更新_finish的位置
  size_t  old_size = size();
  if (n > capacity())
  {
    T* tmp = new T[n];//开新空间
    if (_start)
    {
      //memcpy(tmp, _start, sizeof(T) * old_size);
      for (size_t i = 0; i < old_size; i++)
      {
        //此时这里的赋值调用的是string的赋值,肯定是深拷贝
        tmp[i] = _start[i];
      }
      delete[] _start;//释放旧空间
    }
    _start = tmp;//改变指向,指向新空间
    //在新空间里更新_finish,_end_of_storage的位置
    _finish = _start + old_size;
    _end_of_storage = _start + n;
  }
}

8.1 使用memcpy拷贝问题(重点)

假设模拟实现的vector中的reserve接口中,使用memcpy进行的拷贝,以下代码会发生什么问题?

int main()
{
   bite::vector<bite::string> v;
   v.push_back("2222");
   v.push_back("2222");
   v.push_back("2222");
 
  return 0;
}

问题分析:

(1) memcpy是内存的二进制格式拷贝,按字节拷贝将一段内存空间中内容原封不动的拷贝到另外一段内存空间中

(2) 如果拷贝的是自定义类型的元素,memcpy既高效又不会出错,但如果拷贝的是自定义类型元素,并且自定义类型元素中涉及到资源管理时,就会出错,因为memcpy的拷贝实际是浅拷贝

图解如下:

当把原空间里的"2222"拷贝进新空间后,delete会先对原空间的每个对象调用析构函数,再把原空间销毁,但是此时tmp仍指向那块空间,变成野指针了

复用赋值进行修改后

此时这里的赋值调用的是string的赋值,肯定是深拷贝

九,尾插和尾删数据

//尾插数据
void push_back(const T& x)
{
  if (_finish == _end_of_storage)
  {
    size_t newcapacity = capacity() == 0 ? 4 : capacity() * 2;
    reserve(newcapacity);
  }
  *_finish = x;
  ++_finish;
}
//尾删
void pop_back()
{
  //断言,确保有数据可删
  assert(size() > 0);
  --_finish;
}

十,插入数据 insert

//void insert(iterator pos, const T& x)
iterator insert(iterator pos, const T& x)
{
  //断言,判断下标的有效性
  assert(pos >= _start && pos <= _finish);
  //判断是否扩容
  if (_finish == _end_of_storage)
  {
    size_t len = pos - _start;
    size_t newcapacity = capacity() == 0 ? 4 : capacity() * 2;
    reserve(newcapacity);
    //insert函数的内部迭代器失效问题:类似于野指针问题
    //扩容后pos位置失效,需要重新计算pos
    pos = _start + len;
  }
  //挪动数据再插入
  iterator end = _finish - 1;
  while (end >= pos)
  {
    *(end + 1) = *end;
    --end;
  }
  *(end + 1) = x;
  ++_finish;
  //返回新插入那个位置的迭代器
  return pos;
}

十一,删除数据 erase

iterator erase(iterator pos)
{
  assert(pos >= _start && pos < _finish);
  iterator end = pos + 1;
  while (end != _finish)
  {
    *(end - 1) = *end;
    ++end;
  }
  --_finish;
  //返回删除位置的下一个位置的迭代器
  return pos;
}

十二,insert和erase的迭代器失效问题(重点)

1. 什么是迭代器失效?

迭代器的主要作用就是让算法能够不用关心底层数据结构,其底层实际就是一个指针,或者是对指针进行了封装,比如:vector的迭代器就是原生态指针T* 。因此迭代器失效,实际就是迭代器底层对应指针所指向的空间被销毁了,而使用一块已经被释放的空间,造成的后果是程序崩溃(即如果继续使用已经失效的迭代器,程序可能会崩溃)。

2. insert 的迭代器失效

2.1 insert 内部pos位置的失效

当刚好执行了扩容操作这一步时,由于要开辟新空间,拷贝数据,释放空间,改变空间指向。但是此时pos位置还在原空间,这就使pos变成了野指针,如果对该位置进行访问,就会导致程序崩溃

所以在函数内部,扩容后我们要更新pos迭代器

2.2 insert 以后外部的实参失效

演示代码如下

void vector_test2()
{
  vector<int> v1;
  v1.push_back(1);
  v1.push_back(2);
  v1.push_back(3);
  v1.push_back(4);
  for (size_t i = 0; i < v1.size(); i++)
  {
    cout << v1[i] << " ";
  }
  cout << endl;
  //输入x,查找是否存在,如果存在,在该位置前插入10
  int x;
  cin >> x;
  vector<int>::iterator it = find(v1.begin(), v1.end(), x);
  
  //用返回值接收
   it = v1.insert(it, 10);
  //建议失效后的迭代器不要访问,除非使用返回值。
  cout << *it << endl;
  for (size_t i = 0; i < v1.size(); i++)
  {
    cout << v1[i] << " ";
  }
  cout << endl;
}

insert以后it这个实参会不会失效呢?答案是:会的

在扩容的时候一定会导致迭代器失效。因为虽然在insert内部形参修正了,但是形参的改变不影响实参

迭代器失效的建议是:不要使用失效的迭代器

如果非要访问此时插入的那个位置,必须使用 insert 的返回值,返回的是新插入那个位置的迭代器

3. erase 以后的迭代器失效

erase 以后的迭代器失效问题比较复杂,它与平台相关。

代码演示如下

int main()
{
   int a[] = { 1, 2, 3, 4 };
   vector<int> v(a, a + sizeof(a) / sizeof(int));
   
   // 使用find查找3所在位置的iterator
   vector<int>::iterator it = find(v.begin(), v.end(), 3);
   
   // 删除it位置的数据,导致it迭代器失效。
   //v.erase(it); // err
  
   it = v.erase(it); // ok
   cout << *it<< endl; // 此处会导致非法访问
   
   return 0;
}

erase 以后it这个实参会不会失效呢?答案是:会的

erase删除pos位置元素后,it位置之后的元素会往前搬移,没有导致底层空间的改变,理论上讲迭代器不应该会失效,但是:如果it刚好是最后一个元素,删完之后it刚好是end的位置,而end位置是没有元素的,那么it就失效了或者说某个编译器有这样的机制:当删除到一定的数量时,会进行缩容处理,此时it也就相当于野指针了因此删除vector中任意位置上元素时,vs就认为该位置迭代器失效了,后面元素会牵扯到移动数据,因此删除元素后面的迭代器也会失效

如果非要访问这个位置,也需要用返回值重新赋值,返回的是删除数据的下一个位置的迭代器!

以下代码的功能是删除vector中所有的偶数:

int main()
{
 vector<int> v{ 1, 2, 3, 4 };
 auto it = v.begin();
 
 while (it != v.end())
 {
   if (*it % 2 == 0)
   //v.erase(it); // err
   it = v.erase(it); // ok
 
  ++it;
 }
 
  return 0;
}

这个代码在VS平台下一定会运行崩溃!因为删除后it位置已经失效了,此时再对it进行访问(++操作或是解引用操作都是)会程序报错

但是在Linux下,g++编译器对迭代器失效的检测并不是非常严格,处理也没有vs下极端,所以正常运行

注意:

  • 与vector类似,string在插入+扩容操作+erase之后,迭代器也会失效,但是string的插入一般由下标控制,虽然也重载了迭代器版本,但是并不常用

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

目录
相关文章
|
8天前
|
算法 C语言 C++
【c++丨STL】list的使用
本文介绍了STL容器`list`的使用方法及其主要功能。`list`是一种双向链表结构,适用于频繁的插入和删除操作。文章详细讲解了`list`的构造函数、析构函数、赋值重载、迭代器、容量接口、元素访问接口、增删查改操作以及一些特有的操作接口如`splice`、`remove_if`、`unique`、`merge`、`sort`和`reverse`。通过示例代码,读者可以更好地理解如何使用这些接口。最后,作者总结了`list`的特点和适用场景,并预告了后续关于`list`模拟实现的文章。
22 7
|
26天前
|
存储 编译器 C语言
【c++丨STL】vector的使用
本文介绍了C++ STL中的`vector`容器,包括其基本概念、主要接口及其使用方法。`vector`是一种动态数组,能够根据需要自动调整大小,提供了丰富的操作接口,如增删查改等。文章详细解释了`vector`的构造函数、赋值运算符、容量接口、迭代器接口、元素访问接口以及一些常用的增删操作函数。最后,还展示了如何使用`vector`创建字符串数组,体现了`vector`在实际编程中的灵活性和实用性。
51 4
|
27天前
|
C语言 C++ 容器
【c++丨STL】string模拟实现(附源码)
本文详细介绍了如何模拟实现C++ STL中的`string`类,包括其构造函数、拷贝构造、赋值重载、析构函数等基本功能,以及字符串的插入、删除、查找、比较等操作。文章还展示了如何实现输入输出流操作符,使自定义的`string`类能够方便地与`cin`和`cout`配合使用。通过这些实现,读者不仅能加深对`string`类的理解,还能提升对C++编程技巧的掌握。
62 5
|
11天前
|
存储 编译器 C语言
【c++丨STL】vector模拟实现
本文深入探讨了 `vector` 的底层实现原理,并尝试模拟实现其结构及常用接口。首先介绍了 `vector` 的底层是动态顺序表,使用三个迭代器(指针)来维护数组,分别为 `start`、`finish` 和 `end_of_storage`。接着详细讲解了如何实现 `vector` 的各种构造函数、析构函数、容量接口、迭代器接口、插入和删除操作等。最后提供了完整的模拟实现代码,帮助读者更好地理解和掌握 `vector` 的实现细节。
22 0
|
27天前
|
存储 编译器 C语言
【c++丨STL】string类的使用
本文介绍了C++中`string`类的基本概念及其主要接口。`string`类在C++标准库中扮演着重要角色,它提供了比C语言中字符串处理函数更丰富、安全和便捷的功能。文章详细讲解了`string`类的构造函数、赋值运算符、容量管理接口、元素访问及遍历方法、字符串修改操作、字符串运算接口、常量成员和非成员函数等内容。通过实例演示了如何使用这些接口进行字符串的创建、修改、查找和比较等操作,帮助读者更好地理解和掌握`string`类的应用。
48 2
|
1月前
|
存储 编译器 C++
【c++】类和对象(下)(取地址运算符重载、深究构造函数、类型转换、static修饰成员、友元、内部类、匿名对象)
本文介绍了C++中类和对象的高级特性,包括取地址运算符重载、构造函数的初始化列表、类型转换、static修饰成员、友元、内部类及匿名对象等内容。文章详细解释了每个概念的使用方法和注意事项,帮助读者深入了解C++面向对象编程的核心机制。
94 5
|
1月前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
83 4
|
1月前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
96 4
|
2月前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
31 4
|
2月前
|
编译器 C语言 C++
【C++打怪之路Lv4】-- 类和对象(中)
【C++打怪之路Lv4】-- 类和对象(中)
32 4