注:文中列出的实现代码都是简化版,仅仅实现了基本功能,于STL来说,是萤烛与太阳的差别。STL使用traits对大量的函数偏特化出了各种版本用来提升性能,我所使用的代码都是仿照最泛化的版本提取出的最基本的行为。
在本章中,我将分析顺序容器的底层行为,并且简单的实现vector和deque的迭代器。
通过vector的实现,我想表现的是容器对于内存的操作过程
通过deque的迭代器的实现,我想表现的是容器迭代器对容器行为的影响
注意:所有的容器都定义于命名空间std中。
所谓的顺序容器,并不是说他在内存中是顺序存储的,虽然类似于vector等在内存中确实是顺序存储的,但是List或者heap却不是。顺序容器的顺序指的是是访问方式为顺序访问的容器,或者说是没有一个键值与之相关的容器。
STL中定义的基本顺序容器有:
数组(array): 顺序存储、支持下标访问、不可扩增
向量(vector): 顺序存储、支持下标访问、可再尾部扩增
双向队列(deque): 非顺序存储,支持下标访问,可双向扩增
双向链表(list): 非顺序存储,不支持下标访问,可再任意位置扩增
堆(heap): 利用完全二叉树存储,不支持下标访问,可在尾部扩增
至于另外一些常见的如栈(stack)、单向队列(quque)、单向链表(forward_list)、优先队列(priority_queue)等等,都只是基本容器的适配器。
下面我们利用一张图来看看顺序容器:
这里的Array是在C++11之后新加入的容器,而堆(heap)由于是完全二叉树,他的实现有自己的一套方法,我就不再这里列出了。
array是在C++11之后才加入STL之中的,因为C++本身就有一个原生的数组,array的用法也和它完全相同,但是array可以使用STL的泛型算法,而原生数组因为没有定义迭代器,所以不能融入STL之中。
在C++11之后,我们可以通过以下方法去使用array
#include //包含array的头文件
//创建一个array
//模板参数1是元素的类型,2是元素的个数,3是分配器,默认为std::allocator
array<int, 5> arr1 = { 1,2,3,4,5 };
int arr2[] = { 6,7,8,9,0 };//创建一个原生数组
//输出 1 2 3 4 5
for_each( arr1.begin(), arr1.end(), [&](const int& a) {cout << a << " "; } );
cout << endl;
for (auto a : arr2) { cout << a << " "; }//输出6 7 8 9 0
这里由于arr1是一个容器,所以可以使用STL中的算法for_each()去遍历整个数组;而arr2是原生的数组,所以只能使用for循环去遍历。
这里非成员函数中,各种比较操作符(除了==之外)在C++20标准下都被移除了重载,因为C++20标准出现了一个新的运算符<=>,只重载这个运算符就相当于重载了之前移除的运算符。但是在实际的使用中,使用方法和之前版本的相同。
和原生的数组相同,array的储存结构也是使用连续的存储。在实现方法上,更是直接使用原生的数组作为底层的容器,只不过外附了一个array的迭代器,使之可以融入STL之中。他的迭代器的类型是随机迭代器。
vector对比于array来说,他的优点在于可以在尾后进行扩增,也就是说他的元素个数是可变的,用法如下:
// 创建含有整数的 vector
std::vector<int> v = {7, 5, 16, 8};
// 添加二个整数到 vector
v.push_back(25);
v.push_back(13);
// 迭代并打印 vector 的值
for(int n : v) {
std::cout << n << '\n';
在实现vector的基本功能之前,我们需要先了解vector的行为。
连续存储很容易实现,和原生数组一样,在申请内存的时候一次性申请一大片内存,之后进行分割使用就可以实现。
在尾端进行扩增这个行为就要求我们在进行增加元素的操作时进行判定,若已经超出了已有的内存空间,就另外开辟一个新的空间存储全新的vector,并释放原空间
接着我们来考虑下vector的迭代器:
由于vector在内存中是连续存储,且他的行为和原生数组类似,所以我们没有必要为他而外设计一个迭代器,直接使用原生指针作为vector的迭代器就可以。
在这个实现中,我是用的分配器是在之前的文章中放出的分配器,有兴趣的可以参考我之前的文章:C++STL详解二:萃取器与分配器
我们首先来看MyVector的定义:
//STL重点vector此处有第二模板参数,代表需要使用的分配器
//默认为std::allocator
//由于我只使用一种分配器,此处就省略了
template<typename T>
class MyVecotr
{
public:
//标准接口声明
typedef T value_type;
typedef T* pointer;
typedef const T* const_pointer;
typedef T& reference;
typedef const T& const_reference;
typedef size_t size_type;
typedef ptrdiff_t differemce_type;
typedef value_type* iterator;
protected:
//数据成员的定义
iterator start;//正在使用的头
iterator finish;//正在使用的尾
iterator end_of_storage;//当前空间的尾
}
在栈中,vector只定义了三个迭代器(或者说是指针):
为什么这里有两个指向结尾的指针呢?因为vector在申请内存的时候并不是需要多少就申请多少,他往往会额外申请一定的空间。在空间不足需要扩增时,会申请已占用的空间的二倍。
接下来我们看他的构造和析构:
public:
//构造析构函数
MyVecotr() :start(0), finish(0), end_of_storage(0) {};
MyVecotr(size_type n, const T& value) { fill_initialize(n, value); }
MyVecotr(int n, const T& value) { fill_initialize(n, value); }
MyVecotr(long n, const T& value) { fill_initialize(n, value); }
~MyVecotr() {
destroy(start, finish);//调用析构函数
deallocate();//释放内存
}
在这里我只列出了一种构造函数,也就是填充为N个value,其他构造函数的思想相同,我就不列出了
在构造函数中会调用fill_initialize()进行实际的空间构造,这个函数定义如下:
void fill_initialize(size_type n, const T& value)
{//初始化n个value
start = allocate_and_fill(n, value);//申请空间并填入值
finish = start + n;//调整迭代器
end_of_storage = finish;
}
它首先申请n个value的空间,之后调整三个迭代器的位置,使他们指向正确的位置,申请空间的函数是:
iterator allocate_and_fill(size_type n, const T& value)
{//申请空间并填入值
//iterator res = std::allocator().allocate(n);//调用标准分配器分配内存空间
iterator res = (iterator)alloc.allocate(n * sizeof(T));//调用MyAllocator
uninitialized_fill_n(res, n, value);//填充初值
return res;
}
这里我列出了两种申请空间的方式,一种是使用标准分配器std::allocator的;另一种是使用我自己定义的分配器alloc的。由于这时第一次申请,所以我们只申请足够的空间就行了,当需要扩增时,再去进行申请。
这里由于我自定义的分配器为单例模式,它需要为全部的容器服务,所以他所交付出的内存的指针是void*的类型,需要强制类型转换。但是无论是哪一种方式分配出的内存,都是一整块大的内存,所以我们不能直接为他填入初值,这里需要调用另外一个函数uninitialized_fill_n():
template<typename ForwardIterator, class Size , class T>
inline ForwardIterator uninitialized_fill_n(ForwardIterator first, Size n, const T& x)
{
ForwardIterator cur = first;
for (; n > 0; --n, ++cur)
_construct(&*cur, x);
return cur;
}
template<typename T1, typename T2>
inline void _construct(T1* p, const T2 value) {
new(p) T2(value);
}
在uninitialized_fill_n()中,我们依次跳转指针,在每个空间上通过new()填入一个值。new()在我之前的文章 C++内存分配详解一:分配工具概述 中有详细的描述。
这里值得一提的是,在STL中为了提升性能,它使用了traits对这一步进行了优化,对于不同的value_type类型,偏特化出了很多不同版本的uninitialized_fill_n()和_construct(),我这里使用的是最泛化的版本。
对于析构函数,需要分两步进行,一是调用所有成员的析构函数,二是释放全部内存
调用析构函数的函数如下:
template<class T>
inline void destroy(T frist, T last) {//对一个范围中的所有成员调用析构函数
for (; frist != last; frist++)
{
destroy(&*frist);
}
}
template<class T>
inline void destroy(T* ptr) {//对一个成员调用析构函数
ptr->~T();
}
这里值得一提的是,并不是所有类型的成员都需要调用析构函数。在STL库中,这里会利用traits进行一次判断,只有当该成员需要调用析构函数时才会去调用,这样可以提高效率。具体做法是,在成员中定义自己的析构函数类型,然后通过traits去询问。我这里为了突出容器的行为,就只列出了最泛化的版本。
释放内存的函数:
void deallocate()
{//释放vector空间
if (start)
//std::allocator().deallocate(start, (end_of_storage - start));//调用标准分配器
alloc.deallocate(start, (end_of_storage - start)*sizeof(T));//调用MyAllocator
}
这里首先要进行判断,只有当vector中有元素时才会进行内存的回收。至于回收内存的动作,就交给分配器去做了。
这些函数都比骄傲简单,我就不做过多的解释了
pointer begin() const { return start; }
pointer end() const { return finish; }
size_type size() const { return size_type(end() - begin()); }
size_type capacity() const{ return size_type(end_of_storage - begin()); }
bool empty() const{ return end() == begin(); }
reference operator[](size_type i){ return *(begin() + i); }
reference front() { return *begin(); }
reference back() { return *(end()-1); }
下面是两个比较重要的成员函数:
void push_back(const T& x) {
if (finish != end_of_storage)//如果还有备用空间
{
_construct(finish, x);//构造对象
finish++;//调整迭代器
}
else//若没有备用空间
inster_aux(end(), x);
}
void pop_back()
{
destroy(finish);
finish--;
}
在push_back中,有可能需要进行内容的扩增,这里进行一次判断,若有重组的空间就直接构造,若没有则交给另一个函数inster_aux进行处理,插入函数inster也是使用这个函数
void inster_aux(iterator position, const T& x)
{//插入一个元素
if (finish != end_of_storage)//若有备用空间,这里是插入函数调用时才可能进行
{
_construct(finish, *(finish - 1));//在尾指针处初始化一块空间
finish++;//调整迭代器
//将position到finish-2的内容向后移一格
std::copy_backward(position, finish - 2, finish - 1);
*position = x;//在插入处赋值
}
else//若无备用空间
{
const size_type old_size = size();
const size_type len = (size() != 0) ? 2 * old_size : 1;
//申请新内存
//iterator new_start = std::allocator().allocate(new_size);//调用标准分配器
iterator new_start = (iterator)alloc.allocate(len*sizeof(T));//调用MyAllocator
iterator new_finish = new_start;
//将position之前的内容拷贝到新位置
new_finish = uninitialized_copy(start, position, new_start);
_construct(new_finish, x);//将position位置的内容创建
new_finish++;//调整迭代器
//将position之后的内容也拷贝到新位置
new_finish = uninitialized_copy(position, finish, new_finish);
destroy(begin(), end());//析构原空间
deallocate();
//设置迭代器
start = new_start;
finish = new_finish;
end_of_storage = new_start + len;
}
我们可以看到,这个函数会将整个数组分成两段,插入点前和插入点后,当push_back调用时,这个插入点就是尾后指针。这个函数在内存不足时,先去申请已有内存两倍的内存,然后将插入点前的元素拷贝进去,再将插入点拷贝进去,之后将插入点后拷贝进去。在拷贝结束后析构原空间。
而当空间充足却调用了这个函数时,就意味着是插入函数调用的,此时就将插入点之后的元素向后拷贝一格,然后将插入点拷贝进去。
在拷贝时调用的函数如下:
template<class InputIterator, class ForwardIterator>
ForwardIterator
uninitialized_copy(InputIterator first, InputIterator last, ForwardIterator result)
{
ForwardIterator cur = result;
for (; first != last; ++first, ++cur)
_construct(&*cur, *first);
return cur;
}
和之前构造函数时相同,STL也利用traits为这个函数做了偏特化,我这里仍然只列出了最泛化的版本
pop_back的行为相对于push_back就简单了许多,因为在移除元素时,不需要进行缩放内存,单纯的令最后一个元素调用析构函数(STL库中仍然会通过traits去判断是否真的需要去调用),并移动尾后指针就可以了。
vector中还有许多我没有列出的函数(比如插入,但我列出了他底层调用的函数),但是他们的思路和上边的函数都差别不大,这里我就不做赘述了,也给大家留一下试验的空间。
deque相对于前边的array和deque,它的优点是可以向着前后两段进行扩增,用法如下:
// 创建容纳整数的 deque
std::deque<int> d = {7, 5, 16, 8};
// 从 deque 的首尾添加整数
d.push_front(13);
d.push_back(25);
// 迭代并打印 deque 的值
for(int n : d) {
std::cout << n << '\n';
}
这里我们需要去考虑了,如果deque仍然以顺序存储的话,那么怎么在头部扩展呢?难道头部每次增加一个单位就需要将后边的元素全部向后拷贝一次吗?那这样的话开销也太大了,当然,deque也确实不是这样实现的。
deque的内存空间其实并非连续空间,而是分段连续空间。它类似于hash表的类型,一个数组用来保存指针(称为map节点),这个指针指向了另一个数组,而这个数组才是用来存放数据的(称为buf)。在map数组中,最开始并不会直接用第一个元素去创建buf数组,而是从map数组的中间开始创建第一个node数组。这样,若最前端的buf中不能满足在前边加入元素的话,那就在这个buf数组的map节点的前一个map节点在开辟出一个buf数组。如下图所示
这个图很好的展示了deque的分段连续,每一个map节点都会指向一段连续的空间,但是这些空间并不是连续的。
当整个map空间全部占用后,在需要拓展空间并不需要拷贝全部的数据成员,只需要将原map数组拷贝到新内存空间的中段就行了,这就大大节省了时间上的开销。这个扩展和vector一样,一般情况下也是原map数组空间的二倍。
由于deque在内存中并不是完全连续的,所以原生指针的++操作无法满足遍历deque的需求,所以这里就要为deque定义一个迭代器了。
我们先来分析deque的主要行为:可以通过++操作遍历整个deque,同时它支持下标操作,所以定义为随机迭代器(random_access_iterator_tag )
由于deque是分段连续的,这就需要我们的迭代器里面要维护两个指针,一个cur指向元素,另一个node指向元素所在的map节点,为的是当cur指向了buf数组的结尾,可以通过node指针移向下一个buf数组。
下面我们来看定义:
template<typename T>
class _deque_iterator
{//迭代器
//定义型别
typedef _deque_iterator<T> iterator;
typedef random_access_iterator_tag iterator_category;
typedef T value_type;
typedef T* pointer;
typedef T& reference;
typedef ptrdiff_t difference_type;
typedef size_t size_type;
typedef T** map_pointer;
typedef _deque_iterator self;
public:
pointer cur;//指向实际显示的元素
pointer first;//指向buf的开头
pointer last;//指向buf的结尾
map_pointer node;//指向buf所在的map节点
}
这里的迭代器定义了四个指针,他们的指向我也在注释中写清楚了,这里就不做解释了,直接看一些不是重点的函数:
//构造与析构
_deque_iterator()
: first(nullptr), last(nullptr), cur(nullptr), node(nullptr)
{};
_deque_iterator(const map_pointer& x) { set_node(x); cur = x.cur; }
_deque_iterator(const self& x)
: first(x.first), last(x.last), cur(x.cur), node(x.node)
{};
reference operator*() const { return *cur; }
pointer operator->() const { return &(operator*()); }
difference_type operator-(const self& x) const {//两个迭代器之间的差值
return buffer_size * (node - x.node - 1) + (cur - first) + (x.last - x.cur);
}
self operator=(const self& x) {//重载拷贝赋值
first = x.first;
last = x.last;
cur = x.cur;
node = x.node;
return *this;
}
这里的四个重载没什么需要说的,前两个是每个迭代器都需要实现的类指针操作,后两个也比较简单,因为这个结构中有四根指针,所以编辑器默认的拷贝函数这里就不太够用了,所以我们需要自定义一个拷贝构造和拷贝赋值函数。这里我要解释下拷贝构造函数。这个函数先是重新设定当前迭代器所对应的node,然后修改cur的值,set_node函数的定义如下:
void set_node(map_pointer new_node)
{//设置新的map节点
node = new_node;
first = *new_node;
last = first + buffer_size;
}
之后就是这个迭代器的重头戏,也就是使这个分段连续的结构看起来是连续的方法:
self& operator++() {//重载前置++
++cur;
if (cur == last)//若到达了当前buf的结尾
{
set_node(node + 1);//修改当前迭代器所在的node节点
cur = first;//cur跳转至下一个node的第一个元素
}
return *this;
}
self operator++(int) {//重载后置++
self temp = *this;
++*this;
return temp;
}
self& operator--() {//重载前置--
if (cur == first)//如果是当前buf的第一个元素
{
set_node(node - 1);//修改当前迭代器所在的node节点
cur = last;//cur跳转至上一个node的最后一个元素
}
--cur;
return *this;
}
self operator--(int) {//重载后置--
self temp = *this;
--*this;
return temp;
}
self& operator+=(difference_type n) {//重载 +=
difference_type offset = n + (cur - first);// 计算偏移后应该处于当前buf的第几个位置
if (offset >= 0 && offset < buffer_size)//若仍在当前buf中
cur += n;
else//若不在同一个buf中
{
//计算需要偏移几个buf节点(向前或向后)
difference_type node_offset =
offset > 0 ? difference_type(offset / buffer_size) : -difference_type((-offset - 1) / buffer_size) - 1;
set_node(node + node_offset);//调整到正确的node
cur = first + (offset - node_offset * buffer_size);//设置cur
}
return *this;
}
self operator+(difference_type n) const {//重载+
self temp = *this;
temp += n;
return temp;
}
self& operator-=(difference_type n) { return *this += -n; }
self operator-(difference_type n) const {
self temp = *this;
temp -= n;
return temp;
}
//重载下标访问
reference operator[](difference_type n) const { return *(*this + n); }
这里重载了迭代器的移动方式,每当迭代器走到整个buf的终点时,就让他跳转到下一个buf(或上一个buf),这样在使用迭代器的++或- -时,看起来整个结构就像是连续的一样。当进行随机跳转的时候,也需要判定跳转之后是仍然在当前的buf中还是到了其他的buf。
由于deque支持下标操作,所以他的迭代器是随机访问迭代器,这里就需要对[ ]操作符进行重载
list在底层实现中,使用的是我们在C中最常见的链表的数据结构,用法如下:
// 创建含整数的 list
std::list<int> l = { 7, 5, 16, 8 };
// 添加整数到 list 开头
l.push_front(25);
// 添加整数到 list 结尾
l.push_back(13);
// 以搜索插入 16 前的值
auto it = std::find(l.begin(), l.end(), 16);
if (it != l.end()) {
l.insert(it, 42);
}
// 迭代并打印 list 的值
for (int n : l) {
std::cout << n << '\n';
}
由于list在实现时使用的是双向环状链表的结构,所以说它在内存中也不是顺序存储的,这样代表了我们需要为它定义一个迭代器:++操作访问next成员、- -操作访问pre成员。同时,由于C中的链表就不支持下标访问的操作,所以被定义为双向迭代器(bidirectional_iterator_tag )
由于它并不支持下标操作,所以我们不需要重载[ ]运算符,或者说重载[ ]运算符并将之关闭。
list在栈中的主体仍然只是一根指针,指向链表的头。在动态分配的内存中,每个空间都额外保存了两根指针next和pre,通过这两根指针可以访问到整个链表。
要注意的是,在尾节点和头节点之间,额外有一块没有存储数据的空间,这里是为了实现STL中容器的前闭后开区间,令尾后指针end指向这一块空间。
list的逻辑结构如下图所示:
这里注意在实现时,插入和删除一个元素空间后,需要记得维护链表的结构不要改变。
至于堆,在实现时是通过一颗完全二叉树,标准库也为堆提供了很多函数,我们可以通过这些函数直接维护出一个堆出来,他们是:
//将一个连续空间维护成一个堆
//通过operator<比较 创建一个大根堆
template< class RandomIt >
void make_heap( RandomIt first, RandomIt last );
//通过comp比较 创建一个大根堆
template< class RandomIt, class Compare >
void make_heap( RandomIt first, RandomIt last,Compare comp );
这里创建出的堆,使用的是数组保存二叉树的方法,即:一个节点i的左右子树分别是2i和2i+1,其父节点是i/2。此处常使用的底层容器是vector。
//将位于last-1位置上的元素插入到堆中,并维护堆的属性不变
//通过operator<比较 维护一个大根堆
template< class RandomIt >
void push_heap( RandomIt first, RandomIt last );
//通过comp比较 维护一个大根堆
template< class RandomIt, class Compare >
void push_heap( RandomIt first, RandomIt last,Compare comp );
//移除堆顶元素
//通过operator<比较 维护一个大根堆
template< class RandomIt >
void pop_heap( RandomIt first, RandomIt last );
//通过comp比较 维护一个大根堆
template< class RandomIt, class Compare >
void pop_heap( RandomIt first, RandomIt last, Compare comp );
这两个函数并不是真正的从容器中移除堆顶元素,而是将它和最后一个元素进行交换,这样在看起来就像是移除了堆顶元素的效果。但是要注意的是,若再次使用push_heap,会将该元素再次插入,这是就需要我们对堆的范围进行改变了。
//将一个大根堆转换为升序的连续排列,转换之后不在拥有堆的属性
//通过operator<比较
template< class RandomIt >
void sort_heap( RandomIt first, RandomIt last );
//通过comp比较
template< class RandomIt, class Compare >
void sort_heap( RandomIt first, RandomIt last, Compare comp );
另外,由于堆需要保持自己的属性,不提供遍历操作,所以堆也没有迭代器。由此可想到,堆并不能融入STL的泛型算法之中。
在最开始我提到,另外一些常见的如栈(stack)、单向队列(quque)、单向链表(forward_list)、优先队列(priority_queue)等等,都只是基本容器的适配器。
他们的底层都是上边所提到的那些容器,只不过他们只开放了底层容器的部分成员函数,并且将他们改了一个名字,所以我就不再描述他们的行为了,我只把他们的底层容器列出来,他们的行为和底层容器完全相同。
适配器名 | 底层容器 |
---|---|
栈(stack) | 双向队列(deque) |
单向队列(queue) | 双线队列(deque) |
优先队列(priority_quque) | 堆(heap) |
单向链表(forward_list) | 双向链表(list) |
既然容器有那么多,我们在选用时根据需求选用不同的容器,其效率有可能会大大的提升!
下面是选择顺序容器类型的一些准则
- 如果我们需要随机访问一个容器则vector要比list好得多
- 如果我们已知要存储元素的个数则vector 又是一个比list好的选择。
- 如果我们需要的不只是在容器两端插入和删除元素则list显然要比vector好
- 除非我们需要在容器首部插入和删除元素否则vector要比deque好
- 如果只在容易的首部和尾部插入数据元素,则选择deque
- 如果只需要在读取输入时在容器的中间位置插入元素,然后需要随机访问元素,则可考虑输入时将元素读入到一个List容器,接着对此容器重新拍学,使其适合顺序访问,然后将排序后的list容器复制到一个vector容器中
原文链接:https://blog.csdn.net/u014772862/article/details/52137418