STL中list是一个带头双向循环链表。经过string、vector的学习之后,它的接口函数的学习成本就低了很多,因此我的重点不在于list使用的学习,而是它底层的学习。
迭代器是否失效的问题:
![](https://i-blog.csdnimg.cn/blog_migrate/4540c2119d0cdd9f39dee09d8ccc655e.png)
请问insert以后pos位置失效吗?
在这里是不失效的,原因就是失效的前提是空间的挪动或者是扩容改变旧空间创建出了新空间。而这里list每个数据所占据的空间是独立的,就没有造成空间挪动或者是扩容创建新空间,因此总结出一句话,list中使用insert不存在迭代器失效的问题。
请问insert以后pos位置失效吗?
显然易见是失效的,因为erase以后整个结点都被删除了,那么erase后的位置必然是失效的。
链表可以使用算法库中的sort吗?
首先我们先看看算法库中的sort如何使用:
![](https://i-blog.csdnimg.cn/blog_migrate/570d742144572ab975aea35d3d1c05fc.png)
在sort中我们可以传两个迭代器区间,当我们传入list的两个迭代器时发生了报错是什么原因呢?
因为在算法的底层它会用迭代器去做一个减法,本质上是一个快速排序,所以当string或者vector使用算法中的sort时,它们迭代器相当于原生指针,可以做减法获得数据的个数。但是list的迭代器是封装过的,不能做减法。
![](https://i-blog.csdnimg.cn/blog_migrate/6d3b46d907408533f5560148f75e720a.png)
解决的方法就是调用它自己的sort:
![](https://i-blog.csdnimg.cn/blog_migrate/f46798935b1f229de1fae1917ed8e716.png)
list中的去重函数unique:
使用去重函数的前提是要将链表进行排序,如果不排序就会出现问题:
void list_test2()
{
list<int> a;
a.push_back(50);
a.push_back(30);
a.push_back(30);
a.push_back(50);
a.push_back(10);
a.push_back(70);
a.unique();
for (auto e : a)
{
cout << e << " ";
}
cout << endl;
}
运行结果:
![](https://i-blog.csdnimg.cn/blog_migrate/7e90061884049c4573111fc2d4a37f46.png)
运行结果错误,当我们先进行链表的排序在去重,这样问题就得到了解决:
![](https://i-blog.csdnimg.cn/blog_migrate/88ca219ea3156c4daa7fe694adfbc2fd.png)
sort排序的效率比较:
vector和list相比,当数据访问量比较大的时候,list的sort效率比vector要低很多,我们宁愿现在vector里面先排序,然后再将数据拷贝到list中的效率都比直接在list排序的效率高。因此list中的sort我们很少用,原因就是效率不太高。
list底层的模拟实现:
首先我们将大框架写出来:
namespace my_list
{
template<class T>
struct Listnode
{
Listnode* _next;
Listnode* _prev;
T _data;
};
template<class T>
class list
{
typedef Listnode<T> Node;
public:
list()
{
_head = new Node;
_head->_next = _head;
_head->_prev = _head;
}
private:
Node* _head;
};
}
push_back:
尾插的逻辑很简单,用_head->prev找到最后一个节点,再创建一个新节点并把要插入的数据放在这个新节点里面。先将最后一个节点和新结点互相连接起来,最后再将新节点和_head互相连接起来。
void push_back(const T& x)
{
Node* newnode = new Node(x);
newnode->_data = x;
Node* tail = _head->_prev;
tail->_next = newnode;
newnode->prev = tail;
newnode->_next = _head;
_head->_prev = newnode;
}
迭代器:
首先我们需要理解一个迭代器需要哪些特性:
能支持解引用。
能支持++或--。
string和vector的迭代器可以是原生指针就是因为它们原本的结构(数组)能够支持这两个特性。但是list就不行了,list的每个节点的地址是不固定的的,如果用原生指针Node*就不能支持迭代器的两大特性。因此我们可以用类对list的迭代器进行分装,再用运算符重载实现这两大特性:
template<class T>
struct _list_iterator
{
typedef Listnode<T> Node;
_list_iterator(Node* p)
:_pnode(p)
{}
_list_iterator<T>& operator ++()
{
_pnode=_pnode->_next;
return *this;
}
_list_iterator<T>& operator --()
{
_pnode = _pnode->_prev;
return *this;
}
bool operator !=(const _list_iterator<T>& p)
{
return _pnode != p._pnode;
}
T& operator *()
{
return _pnode->_data;
}
Node* _pnode;
};
当我们用封装是结点指针具备了迭代器的特性以后,接下来就可以在list完善迭代器的一些接口函数:
class list
{
typedef Listnode<T> Node;
public:
typedef _list_iterator<T> iterator;
iterator begin()
{
return iterator(_head->_next);
}
iterator end()
{
return iterator(_head);
}
}
拷贝构造(传统写法):
如果我们不写拷贝构造,编译器默认形成的是浅拷贝,在使用析构函数的时候同一空间会析构两次。所以我们需要写一个深拷贝的拷贝构造:
list(const list<T> lt)
{
//创建出头节点
_head = new Node(T());
_head->_next = _head;
_head->_prev = _head;
//用范围for遍历尾插
for (auto e : lt)
{
push_back(e);
}
}
首先让我们看上面的代码,看看有什么缺陷?
当我们使用范围for的时候会把*lt的数据传给e,(不是把结点给它),因为数据类型是泛型,可能是sting类型,可能是vector类型,数据可大可小,如果我们不加引用拷贝的成本会大很多。
因为lt是const类型,使用迭代器的时候必须要用const迭代器。push_back中也会用到end(),一个const对象不可以用非const,因为传this指针权限放大。
3.参数如果不加&时,当调用拷贝构造时传入参数是会无限进行拷贝构造变成死循环,程序崩溃。
赋值重载(传统写法)
和拷贝构造一样不写就是浅拷贝,问题多多:
list<T>& operator =(list<T>& lt)
{
if (this != <)
{
//先清除原本数据
clear();
//范围for迭代尾插
for (auto& e : lt)
{
push_back(e);
}
}
return *this;
}
参数不能加const,因为我们还没有实现const迭代器,话不多说我们现在就去搞搞const迭代器:
const迭代器:
首先我们得理解什么是const迭代器中const修饰的是什么?
![](https://i-blog.csdnimg.cn/blog_migrate/8cbb28bd7c8a7ccb45f40f05ec04c62e.png)
很明显答案是第一个,const修饰的是迭代器中的数据不能被修改而不是迭代器本身,所以const迭代器不是简单的认为只是在普通迭代器的前面加上const,一下是错误示范:
![](https://i-blog.csdnimg.cn/blog_migrate/1a253a3d18ea9286b7b2ffa46a1ee5d1.png)
当我们明白了这个道理,接下来我们制定三种方案进行实现:
方案一:
因为const迭代器不能修改迭代器所指向的值,所以我们可以可以在返回值上做出修改:
![](https://i-blog.csdnimg.cn/blog_migrate/902ec3b6fe03d61f874fc52fde04fb2b.png)
这样做很显然还是不可以,原因和上面一样,你想调用下面的const函数你必须使用用const修饰的迭代器,而迭代器前面加const就不能进行++等操作了,所以这种方案不可行。
方案二:
我们可以重新创建一个 _list_const_iterator类,其他的运算符重载和 _list_iterator类中的一样。
template<class T>
struct _list_const_iterator
{
typedef Listnode<T> Node;
_list_const_iterator(Node* p)
:_pnode(p)
{}
_list_const_iterator<T>& operator ++()
{
_pnode = _pnode->_next;
return *this;
}
_list_const_iterator& operator --()
{
_pnode = _pnode->_prev;
return *this;
}
bool operator !=(const _list_const_iterator<T>& p)
{
return _pnode != p._pnode;
}
const T& operator *() //此处修改了返回值,满足了const迭代器的特性
{
return _pnode->_data;
}
Node* _pnode;
};
然后我们在list类中声明一下,并把函数重载搞搞好:
const_iterator begin() const
{
return const_iterator(_head->_next);
}
const_iterator end()const
{
return const_iterator(_head);
}
iterator begin()
{
return iterator(_head->_next);
}
iterator end()
{
return iterator(_head);
}
注:在成员函数后面加const,const修饰this指针所指向的对象,也就是保证调用这个const成员函数的对象在函数内不会被改变。构成了函数重载又加强了保护。
方案三:
方案二是普通人写的代码,方案三是大佬写的,大佬认为如果只是改变一个返回值却要大费周章的创建出一个类,实在是显得代码非常的冗余,所以大佬用了模板参数解决了这个问题:
template<class T,class Ref>
struct _list_iterator
{
typedef Listnode<T> Node;
typedef _list_iterator<T, Ref> Self;
_list_iterator(Node* p)
:_pnode(p)
{}
Self& operator ++()
{
_pnode=_pnode->_next;
return *this;
}
Self& operator --()
{
_pnode = _pnode->_prev;
return *this;
}
bool operator !=(const Self& p)
{
return _pnode != p._pnode;
}
Ref& operator *()
{
return _pnode->_data;
}
Node* _pnode;
};
然后在list中用不同的模板参数实例出不同的类:
class list
{
typedef Listnode<T> Node;
public:
typedef _list_iterator<T,T&> iterator;
typedef _list_iterator<T,const T&> const_iterator;
}
拷贝构造(现代写法)
拷贝构造的现代写法的精髓就在于赋用构造函数,首先我们得写出一个合适的构造函数(迭代器区间):
template <class InputIterator>
list(InputIterator first, InputIterator last)
{
empty_initialize();
while (first != last)
{
push_back(*first);
++first;
}
}
然后再写出我们的拷贝构造:
![](https://i-blog.csdnimg.cn/blog_migrate/939124f946fd5b210a2206f43d6104e5.png)
但是以上代码是存在问题的,原因就是没有被初始化(连头节点都没有),当它和tmp交换了以后,局部变了出了作用域会被销毁,tmp会调用它的析构函数,析构函数会清除所有数据并销毁头结点,而tmp交换过以后没有头节点,程序崩溃。以下是正确写法:
void swap(list<T>& lt)
{
std::swap(_head, lt._head);
}
list(const list<T>& lt)
{
empty_initialize();
list<T> tmp(lt.begin(), lt.end());
swap(tmp);
}
赋值重载(现代写法)
list<T>& operator =(const list<T> lt)
{
swap(lt);
return *this;
}
参数没有加引用是为了调用拷贝构造,使之成为一个深拷贝。然后直接swap,使原本的数据丢弃交给lt,lt为局部变量出了作用域就销毁,简称完美。
类名与类型的小结:(不是重点但要了解)
为什么在上面的代码中,参数要加<T>,构造函数不用加?这涉及到类名与类型的区别:
在普通的类中,类名等价于类型。如:vector s1,s1的类型是vector
在类模板中,类名不等价于类型。只有当类名实例化以后才能等价于类型。
如:template<class T>
class vector
{
........
};
vector<T>是类型,vector是类名,它们俩之间不等。
在类模板中,可以用类名代替类型(c++官网上有的代码就是这么写的),但是我们不建议这么做,我们就应该类名是类名,类型是类型区分开来。
重载->运算符:
我们先创建一个结构体Pos,在我们刚刚完成的链表中一个个尾插Pos:
![](https://i-blog.csdnimg.cn/blog_migrate/4de4b7b2227e2f5b096332aed4c41d41.png)
但发现结果并没有很好的输出出来,原因就是我们并没有重载运算符。那如何不通过重载运算符<<让数据很好的输出呢?
方法一:
![](https://i-blog.csdnimg.cn/blog_migrate/544e78b449f68583ddc35b7dc845f1e7.png)
it是迭代器,*it是迭代器中的数据,也就是Pos这个结构体,然后通过点进行结构体中数据的访问。
方法二:
我们可以重载一个运算符->
![](https://i-blog.csdnimg.cn/blog_migrate/045e75f7e125de10d38d6c3c9f21b4a0.png)
![](https://i-blog.csdnimg.cn/blog_migrate/54638dcc89f4a2b6d3b46b3033bbaefb.png)
有人会困惑,运算符重载返回的是数据的地址,如果想要访问Pos中的数据不应该是
it->->_row吗?这时编译器在这个方面做了一个优化,本来是两个箭头优化成了一个箭头,提高了代码的可读性。接下来我们来验证const变量用上面的运算符是否可行:
![](https://i-blog.csdnimg.cn/blog_migrate/475e213345ef15b902b6c5ce5b898ce7.png)
我们发现const变量中的数据竟然可以被修改,所以我们重载的运行符不合格。不合格的原因依旧是返回值的问题,可以继续沿用模板参数来解决:
![](https://i-blog.csdnimg.cn/blog_migrate/7ba8fb06c779aee3c3d619153f0d4624.png)
![](https://i-blog.csdnimg.cn/blog_migrate/23ff2e4f9e3c26b54aee4834a539e34b.png)
![](https://i-blog.csdnimg.cn/blog_migrate/6c6d994b6d1e62df607fa135072a06e7.png)
总结:方案二比方案一的代码更加清晰明了,到后面的智能指针等会经常用到->运算符,它这次算是跟大家初次见面了。
类和对象的总结:
没有写析构函数的类,它的拷贝构造和赋值重载都不需要写。原因:不写拷贝构造和赋值重载,编译器默认是浅拷贝,浅拷贝对于没有析构函数的类已经够用了。而有了析构函数,如果不写拷贝和赋值,两个对象就会指向同一块空间,等出了作用域调用各自的析构函数时程序会崩溃。
string、vector、list的相关总结:
vector和list性能的对比:
![](https://i-blog.csdnimg.cn/blog_migrate/9dc3d9ce55ef87e79fdcd870ff14f9e4.png)
有人会有疑问删除数据要先找数据啊,那为什么list删除数据是O(1)呢?因为这里的删除数据不包括找数据,默认数据的位置已经有了只需要删除即可。
迭代器失效的问题:
![](https://i-blog.csdnimg.cn/blog_migrate/03d0ea088330def3865fd4a47c04eea4.png)
string内部存储结构:
首先我们得需要知道所有的字符并不是储存在string对象中,是存储在堆空间里,对象只是相当于一个指针能够找到开辟的空间。
string内部是由以下成员变量组成的:
![](https://i-blog.csdnimg.cn/blog_migrate/3d73cc49b7e704fbe986123a892a56c2.png)
在vs2013下,字符小于16就存储在一个buff数组中,大于等于16个就存储在开辟的对空间中。
![](https://i-blog.csdnimg.cn/blog_migrate/160bd76a422be537c62fb0a9416ec459.png)
buffer里面存储数据
![](https://i-blog.csdnimg.cn/blog_migrate/ea34946eb73b0197c82ef1d130a9fc7d.png)
堆空间中存储数据
在g++下面,string的存储更是离谱:
![](https://i-blog.csdnimg.cn/blog_migrate/aeb268f9b81eccb159258e851dba48f8.png)
在vs下string拷贝构造走的是深拷贝:
![](https://i-blog.csdnimg.cn/blog_migrate/bde53e678bee0b648d0540f3bedebeb7.png)
在g++下如果我们不对数据进行修改等写入操作,它的拷贝构造走的是浅拷贝:
![](https://i-blog.csdnimg.cn/blog_migrate/78144b3a52647fa03c844f1c5a797f3f.png)
那么它是如何解决多次析构同一块空间产生的问题呢?
g++下面string的结构中有一个引用计数,如果有多个对象指向同一块空间,引用计数的值会增加,等到只有一个对象指向一个空间时,即引用计数的值为一,才会调用它的析构函数。
![](https://i-blog.csdnimg.cn/blog_migrate/67e82d990375a434af9c3eb06a15823f.png)
在g++下当我们对数据进行写入的时,拷贝构造走的就是深拷贝了:
![](https://i-blog.csdnimg.cn/blog_migrate/50c4ae2d2adc4cfbc0d43246a4876ed4.png)
总结的来说,g++下的string是用写时拷贝完成的。写时拷贝就是一种拖延症,是在浅拷贝的基础之上增加了引用计数的方式来实现的。但是g++下的string在特定的场景也会产生很多的问题,有兴趣的话可以去看看大佬的博客:写时拷贝 写时拷贝在读取时的缺陷
到这里list相关知识就全部结束,感谢阅读和支持!