STL中deque详解

mac2025-10-07  6

主流的STL容器的数据结构都比较常规,类似List就是实现了链表的数据结构,数据以一个node接连串接一个node的形式存储;vector则是一个连续空间存储的变长数组,当空间用完后则申请一倍的空间并老数据拷贝到新分配空间中;map和set则是红黑树,一种特殊的二叉平衡搜索树,保证根到叶节点最大的层数差距在两倍以内,以此保证搜索的速度和维护树结构的性能平衡;unordered_map和unordered_set则使用哈希表,有不同的哈希函数,也有不同的桶实现方法,在此不予细究;唯独deque的实现略“非主流”,这里详细结合STL中的实现解析一下。

基本原理概述

侯捷的《STL源码剖析》中有详细的理论解析,只是示例代码有点老,在此截图一用: 上图可以明显的看出,deque对于数据的中的存储并不是严格连续的,而是分成了很多个缓冲区,用一个map(并非STL中map,而只是一个简单的连续数组)存储所有缓冲区的地址指针,每个缓冲区都可以存储多个数据,当一个缓冲区填满以后,会开辟新的缓冲区存储数据,并将其地址指针存入map,若map也已经存满以后,会创建新的更大的map,并将旧的map数据拷贝进去并释放旧的空间。

代码分析

下面结合代码来详细看看细节(代码来自gcc5.4自带的STL,stl_deque.h):

template<typename _Tp, typename _Alloc = std::allocator<_Tp> > class deque : protected _Deque_base<_Tp, _Alloc>

deque继承自_Deque_base类,空间分配器默认为std标准分配器allocator(该版本底层实现为new和delete的封装,而并非一级二级配置器的模式),_Deque_base类主要完成了deque的内存管理相关的任务,拿一些主要的部分来看:

template<typename _Tp, typename _Alloc> class _Deque_base { protected: typedef typename __gnu_cxx::__alloc_traits<_Alloc>::template rebind<_Tp>::other _Tp_alloc_type; //确定_Tp空间分配器类型 typedef __gnu_cxx::__alloc_traits<_Tp_alloc_type> _Alloc_traits; //alloc_traits会实际调_Tp_alloc_type来分配内存和完成构造和析构操作,这里不详细分析 typedef typename _Alloc_traits::pointer _Ptr; typedef typename _Alloc_traits::const_pointer _Ptr_const; typedef typename _Alloc_traits::template rebind<_Ptr>::other _Map_alloc_type; //确定_Map空间分配器类型 typedef __gnu_cxx::__alloc_traits<_Map_alloc_type> _Map_alloc_traits; //同上 ...

以上代码主要用来确定空间分配器类型,之后的接口中会利用以上确定的空间分配器分配空间以及构造对象,再看看主要成员和接口:

struct _Deque_impl : public _Tp_alloc_type { _Map_pointer _M_map; //上文提到的map地址 size_t _M_map_size; //map大小 iterator _M_start; //首元素所在map节点迭代器 iterator _M_finish; //尾后元素所在map节点迭代器 ... } _Deque_impl _M_impl;

实现了一个内置类型,继承自_Tp空间分配器类,定义一个其对象并作为空间分配器,之后所有空间分配任务和构造析构任务都由这个对象完成,同时包含一些重要成员。

_Ptr _M_allocate_node() { typedef __gnu_cxx::__alloc_traits<_Tp_alloc_type> _Traits; return _Traits::allocate(_M_impl, __deque_buf_size(sizeof(_Tp))); } void _M_deallocate_node(_Ptr __p) _GLIBCXX_NOEXCEPT { typedef __gnu_cxx::__alloc_traits<_Tp_alloc_type> _Traits; _Traits::deallocate(_M_impl, __p, __deque_buf_size(sizeof(_Tp))); }

_Deque_base仅仅只包涵了分配空间和回收的接口,这里只拿了node(上图中的缓冲器)相关的接口,还有map的接口,都大同小异,都是直接调用空间分配起来分配空间。最后提一下_Deque_base的构造中会用到的_M_initialize_map(size_t __num_elements)函数:

template<typename _Tp, typename _Alloc> void _Deque_base<_Tp, _Alloc>:: _M_initialize_map(size_t __num_elements) { const size_t __num_nodes = (__num_elements/ __deque_buf_size(sizeof(_Tp)) + 1); //根据初始化元素的个数确定需要的map大小 this->_M_impl._M_map_size = std::max((size_t) _S_initial_map_size, size_t(__num_nodes + 2)); //根据上面确定map的大小决定(最小为_S_initial_map_size=8) this->_M_impl._M_map = _M_allocate_map(this->_M_impl._M_map_size); //分配map的空间 // For "small" maps (needing less than _M_map_size nodes), allocation // starts in the middle elements and grows outwards. So nstart may be // the beginning of _M_map, but for small maps it may be as far in as // _M_map+3. _Map_pointer __nstart = (this->_M_impl._M_map + (this->_M_impl._M_map_size - __num_nodes) / 2); //因为是双端队列map都是从中间开始分配空间,首元素向左生长,尾元素向右,这点比较重要 _Map_pointer __nfinish = __nstart + __num_nodes; //以下给缓冲器分配空间 __try { _M_create_nodes(__nstart, __nfinish); } __catch(...) { _M_deallocate_map(this->_M_impl._M_map, this->_M_impl._M_map_size); this->_M_impl._M_map = _Map_pointer(); this->_M_impl._M_map_size = 0; __throw_exception_again; } //以下就是空间分配完成后初始化_M_impl中各个关键元素,上文已经提过相应意义 this->_M_impl._M_start._M_set_node(__nstart); this->_M_impl._M_finish._M_set_node(__nfinish - 1); this->_M_impl._M_start._M_cur = _M_impl._M_start._M_first; this->_M_impl._M_finish._M_cur = (this->_M_impl._M_finish._M_first + __num_elements % __deque_buf_size(sizeof(_Tp))); }

到这里_Deque_base的任务基本就完成了,它所有的任务就是提供创建、回收map和node的接口,方便后面的使用。 然后来看看deque的主要实现,主要就是完成元素的插入删除,以插入为例子:

void push_back(const value_type& __x) { if (this->_M_impl._M_finish._M_cur != this->_M_impl._M_finish._M_last - 1) { _Alloc_traits::construct(this->_M_impl, this->_M_impl._M_finish._M_cur, __x); ++this->_M_impl._M_finish._M_cur; } else _M_push_back_aux(__x); }

逻辑还是比较简单的,if判断该缓冲区还有没有空间,有就直接创建对象返回,没有就调用_M_push_back_aux(__x),看看它的实现:

void deque<_Tp, _Alloc>:: _M_push_back_aux(_Args&&... __args) { _M_reserve_map_at_back(); // 1 *(this->_M_impl._M_finish._M_node + 1) = this->_M_allocate_node(); // 2 __try { _Alloc_traits::construct(this->_M_impl, // 3 this->_M_impl._M_finish._M_cur, std::forward<_Args>(__args)...); this->_M_impl._M_finish._M_set_node(this->_M_impl._M_finish._M_node + 1); this->_M_impl._M_finish._M_cur = this->_M_impl._M_finish._M_first; } __catch(...) { _M_deallocate_node(*(this->_M_impl._M_finish._M_node + 1)); __throw_exception_again; } }

_M_push_back_aux(__x)也并不复杂,因为目前尾元素所在缓冲区空间不够了,因此需要申请新的空间,1、_M_reserve_map_at_back()实现的主要功能就是检查map空间是否足够,不够就申请新的空间,并将数据移动过去,并更新_M_impl中存储关键元素的信息;2、然后根据尾元素所在节点申请新的缓冲区空间;3、最后构造元素。 push_front()基本完全一致,只不过其生长方向与back完全相反,可以简单理解为front和back从初始的中间位置分别向两端生长,一旦所在缓冲区空间不够就申请新的空间,并在新的空间继续生长,最后看下pop_back函数,front类似:

void pop_back() _GLIBCXX_NOEXCEPT { if (this->_M_impl._M_finish._M_cur != this->_M_impl._M_finish._M_first) { --this->_M_impl._M_finish._M_cur; _Alloc_traits::destroy(this->_M_impl, this->_M_impl._M_finish._M_cur); } else _M_pop_back_aux(); }

可以看到,基本就是push_front反过来,if检查当前元素是否到达当前缓冲区最前端,不在则直接pop,否则调用_M_pop_back_aux(),_M_pop_back_aux()与_M_push_back_aux()也是几乎完全相反,需要注意的是它会回收缓冲区内存,其他的这里就不再赘述了。

总结

总体来看,deque由于结构的原因,它整体的实现比vector要复杂很多,缓冲区有点类似于一个每个“节点”都是一个大block的List,只不过它的操作主要在头尾两端,头尾节点一旦空间不够了就新建“节点”,pop空了就删除“节点”,因此如果不想要频繁在头部操作时,选择vector要远远优于deque;但如果有大量头部操作时(例如FIFO)还是老老实实用deque吧。

最新回复(0)