前言
其实很早之前,我就有去看STL源码的想法了。只是一直没有一个好的机会。最久空闲时间比较多,也正是这样一个好机会去阅读源码。为了事半功倍,不走弯路,我在网上查了很多资料。目前来说,C++ STL的源码相关的中文书籍还停留在侯捷老师的2002年出版的STL源码剖析。虽然侯捷老师确实是非常厉害的,很有教学能力的一位C++老师,但是还是要提,在计算机技术日新月异的环境下,一本二十多年前的书籍确实已经有点落后了。当然,书籍中的思想还是值得学习的,但于我来说,我更希望去阅读更接近现实的STL的源码,一开始我是看的MS的STL,因为它非常简单就能找到。我只要在VS中随便创建一个vector,再右键查看定义,就可以直接看到vector的源码了。但是,当我兴致勃勃地准备入手时,却发现vector的源码内满是条件编译,各种宏,非常难以理解的变量命名规则以及非常复杂的调用关系,这无疑给我浇了一盆冷水。于是我去网上搜索,发现和我同样想法的不止我一人。网上说,MS的STL设计的时候并没有考虑可读性,而为了兼容不同的编译器以及旧版的标准,同时它还要思考如何避免与用户定义的变量重名,于是又搞出了各种奇奇怪怪的命名。总之,如果你想阅读STL的源码的话,看MS真的是太痛苦了。没有办法,我只能去网上搜索。好在Github上有各种前辈又写各自的STL源码学习经验,并且推荐了SGI STL,于是我开始结合Github上的资料阅读STL,好好地从底层理解一下STL的设计思想。当然,我的博客不一定正确,希望能给大家一点帮助吧~
2024年3月17日22点53分
vector的使用
vector是非常常用的STL容器,也是我刷Leetcode最常用的容器,对于vector的使用我可以说是比较熟悉了。
本文不会详细介绍,更多内容查询官方文档:Standard library header <vector> - cppreference.com
vector的构造
vector() //默认构造
vector(size_t typen,const vaule_type& val = vaule_ type()) //n个vaule_type
vector(const vector& x) //拷贝构造
vector& operator=(const vector&x);//复制构造
vector(vector&& x);//移动构造
vector(inputlterator first,inputlterator last); //迭代器构造
....
vector的内置函数
template<typename T>
push_back(T& x);//末尾添加元素
pop_back();//末尾移除元素
insert(const iterator* pos,T&& X);//指定位置添加元素
earse(const iterator* pos);//指定位置删除元素
swap(vector&);//互换
clear();//清除数组
bool empty();//检查是否为空
size();//返回数组元素数量
capacity();//返回容器大小
resize(int n);//改变容器大小
reserve(int n);//预分配capacity大小
template<typename T>
T& operator[](int n);//随机访问
T& front();//访问尾元素
T& back();//访问首元素
Vector源码解析
接下来我们来看vector的源码,我这里的使用的是vscode编译器进行代码阅读。因为vscode有比较良好的代码高亮显示以及定义,引用转移,可以帮助我更好地阅读代码。而VS外部导入的文件不方便使用定义转移,我不知道为什么,可能需要先编译一遍。但是STL的代码独立编译起来又十分的繁琐,所以还是用vscode吧,vscode的定义/引用转移是基于文本的,而VS好像是基于代码逻辑的,我猜是这个原因;
先进到vector,发现vector仅仅是对几个头文件的引用,内部实现的文件是stl_vector.h
进入stl_vector.h
,我们看到iterator迭代器在vector里面是T*,这令我有些惊讶,我以为所有的容器的iterator都是一个类实现的呢。
基础部分
上面定义了三个iterator,分别是start,finish和end_of_storage。而下面的allocate_and_fill
内部是调用了data_allocater::allocate()
,该函数在stl_config.h文件里,内部调用了一个operator new;可见STL内部这里是通过opearator new动态开辟了一段大小为n的内存,并返回给start指针;
再来看下面这段,不得不说CGI STL写的是真的简单易懂,变量名没有复杂的下划线,能直接内联实现的就内联实现,还省去了一些编译器会默认加上的如inline的声明,并且整体代码书写非常清晰。大家对比看下VS STL
可以看到我们常用的函数基本上都是依靠start,finish和end_of_storage来实现的,实现逻辑也十分简单;其中finish指向的是最后一个元素的下一个,也就是我们end()返回的迭代器。并且通过operator[]重写了[]操作符,而在size()中的实现则是通过end()和begin()的函数调用来实现的计算大小。
我们来看看析构函数的代码,析构函数分为destry
和deallocate
,往内部深挖都是在allocator.h中实现的,具体来说析构时会先检查析构对象是否是平凡对象,如果是平凡对象就调用__destroy_aux简单遍历地对指针一个一个destroy,最后这个~_Up()
我没找到定义,但可以想象其应该是对内容进行置空;不过这部分的内容涉及到分配器的代码了,这里就不过多地深入;而deallocate()则是对end_of_storage~start调用了一个全局的::operator delete(p)
,条件编译的部分好像又涉及了BOOST库,就不去深究了。
在reserve调用中,我们可以看到reserver是先将原本的vector数组保存下来,再重新创建一个容量为n的容器,并将原先的iterator指向新的vector。所以显而易见的,如果我们显示地获得一个iterator迭代器后再调用reserve,那么可能会导致迭代器失效;因为原来的数组已经被释放掉了。
push_back
再来看push_back的实现,push_back可以说是我们使用vector最常用的函数操作了;这里push_back只有根据finish
和end_of_storage
两个简单的if else,内部分别是construct
全局函数和insert_aux
两个函数,我们来分别看看:
construct是stl_construct.h中定义的函数,非常地朴实无华,new一个T1类型的指针,传入一个T2类型的引用;也就成功创建了一个值加入vector数组中;我们再来看看insert_aux
函数:
可以看到,当空间不够时,vector内部会根据原先的size()重新分配一块新的内存,大小为原先的2倍(如果size() != 0的话),并将原先的内容复制uninitialized_copy
到新的内存空间内,并将原先的vector释放并清理掉;最后将指针指向new_start
和new_finish
;很显而易见的地,这个过程会非常地耗时,其中uninitialized_copy
的核心实现是下面这两行(C++11之后使用了完美转发)
C++11之前呢?答案是老老实实地一个一个通过构造来创建,如果是简单的内置类型也许还好,但如果是复杂的class,那么它的性能表现是灾难级别的,万一再遇上超大数据(比如上亿上兆之类等)的vector,那代码运行到这里就可以去看部电影再回来了(Just Kidding~)
同时,我们通过源码也可以看出,如果你在定义了迭代器之后,再继续添加数据,而恰好遇到vector内部扩容,那么你的迭代器指针又会指向一个被销毁的内存空间,也就会导致程序崩溃;不信的话大家可以运行一下下面这段代码,在VS中就会导致程序崩溃,但不幸的是,这种错误编译器是不会告诉你的,而如果你的代码非常复杂,有成千上万行,那么排查这种错误就会痛苦了。所以建议大家使用迭代器的时候一定要谨慎,如果一个作用域里你的代码非常地多的话,合理考虑如何处理迭代器;
#include<iostream>
#include<vector>
using namespace std;
void test()
{
cout << "失效场景1:底层扩容导致迭代器失效\n_________________________________________" << endl;
vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
v.push_back(4);
v.push_back(5);
vector<int>::iterator it = v.begin();
/*加下面这两行编译器就会报错,因为it迭代器失效了;这是因此底层触发扩容,就会重新创建一块内存
调用拷贝构造复制数据,而迭代器本质是个指针,原先指向的内存失去访问权限于是报错*/
v.push_back(6);
v.push_back(7);
while (it != v.end())
{
cout << *it << endl;
it++;
}
}
int main()
{
test();
return 0;
}
这里可以看到vector内部的swap
函数是调用的std::swap
的特化版本,这部分内容可以看一下effective C++的 item 25
erase
而像pop_back() , erase() 的内容就比较简单了。在我们的常规认知中,erase()会删除指定位置的元素,该位置后面的所有元素都会前移一个位置,以填补被删除的元素位置;但是大家可以看到,实际上被删除的并不是该位置的元素,真正的实现是将该位置后面的元素全部前移一格,最后删除末尾的元素。
这样完成之后容器的大小会减少一个,并且被删除位置后的所有元素的索引减1。copy的调用也挺复杂,因为copy的使用在整个STL中使用很多,于是STL对copy做了全泛化的模板优化,而这里的特化底层逻辑如下(非常地符合我们的常识啊哈哈,感觉这段我来写和SGI的工程师们也写的大差不差)
需要注意的是,erase()也可能会导致迭代器失效的问题,而这往往是我们使用不规范导致的,erase()在删除某个位置的元素后,会指向该位置下一个元素(当然看了上面的源码后我们知道它指向的位置并没有变化,只是原先指向的元素被覆盖了);
大家来看这段代码:
int main()
{
std::vector<int> v;
v.push_back(1);
v.push_back(2);
auto it = v.begin();
v.erase(it);
std::cout << *it << std::endl;
return 0;
}
大家认为他会不会报错?答案是:在VS2022中,这段代码会出现如下错误:
但是,看了我们上面的源码,它怎么会报错呢?不是后面把前面的覆盖,删除后面的迭代器吗?就算报错,也应该是下面这种代码这种报错呀,也就是指向finish的指针失效,为什么会前面的失效呢?
int main()
{
std::vector<int> v;
v.push_back(1);
v.push_back(2);
auto it = v.begin() + 1;
v.erase(it);
std::cout << *it << std::endl;
return 0;
}
说实话,我也一直不理解,随后我想起VS2022毕竟不是用的CGI,而g++用的是CGI呀,于是我去Linux用VIM写了一个:
编译运行,正确地输出了:
所以,应该是VS的STL版本是直接将erase指定的pos迭代器直接删除了,然后返回了pos + 1的迭代器,所以为了代码的健壮性,建议用erase的时候,永远用一个it来接收它,不然可能就会导致你在g++平台能正常运行的代码,搬到VS2022上就直接报错,而你永远不知道为什么。
更正:g++并不是使用的SGI,而是使用的libstdc++,所以这里之所以g++不会报错的原因应该是g++编译器保留了被删除部分的元素值。
insert
通过insert的实现,我们可以清楚地看到,vector会将vector插入的位置后面部分进行复杂移动,所以insert对于vector来说也是一个非常耗时的操作,能不用就不用;同时insert也要考虑vector扩容问题;
结束语
通过SGI STL的源码我们发现其实vector的实现并不困难,下一章我们自己模拟实现一个vector