C++ STL容器(二) —— list 底层剖析

计划写几篇关于C++ STL容器底层剖析的文章,主要基于的是MSVC的实现,本篇先从比较简单的 list 入手,个人感觉文章更偏于代码的具体实现,而不是原理的讲解,所以前置需要你了解链表的相关算法,如果有问题欢迎评论区指出。


文章目录


数据结构

之前知道的C++ STL容器里的 list 是双向链表,看完 MSVC 里的实现,更准确地说其 list 是一个双向循环的链表,并且有一个哨兵节点。


UML 类图

MSVC 内部的实现还是比较复杂的,所以在深入代码实现之前,先梳理下 list 相关的 UML 类图,这里 list 封装了操作这个双向链表的方法,且可以看作是双向链表+分配器的组合,而实际的双向链表结构其实本体是 _List_val 其保存了链表的哨兵节点和链表的实际长度。而每个链表上的节点信息由 _List_node 类定义。

对于 list 中节点的插入也封装成了一个操作类 _List_node_emplace_op2


代码解析

由于相关的操作和代码很多,所以这里只挑一些我觉得比较常用的操作进行分析,管中窥豹一下了。

默认构造函数

先从默认构造函数入手,下面是源码:

cpp 复制代码
    list() : _Mypair(_Zero_then_variadic_args_t{}) {
        _Alloc_sentinel_and_proxy();
    }

下面是 _MyPair 里的构造函数,首先匹配上的是 _Zero_then_variadic_args_t 为首参数的构造函数,其分别调用了 _Ty1 的无参构造函数,和 _Myval2 的有参构造函数,这里的 _Ty1 就是我们的分配器,我们不关注,_Myval2 就是保存了我们的双向链表 _List_val

cpp 复制代码
    template <class... _Other2>
    constexpr explicit _Compressed_pair(_Zero_then_variadic_args_t, _Other2&&... _Val2) noexcept(
        conjunction_v<is_nothrow_default_constructible<_Ty1>, is_nothrow_constructible<_Ty2, _Other2...>>)
        : _Ty1(), _Myval2(_STD forward<_Other2>(_Val2)...) {}

而这里 _List_val 其实是没传参数的构造函数,具体代码如下,就是初始化链表长度为0,_Myhead 是哨兵节点指针,初始为NULL:

cpp 复制代码
_List_val() noexcept : _Myhead(), _Mysize(0) {} // initialize data

回到 list(),后续调用 _Alloc_sentinel_and_proxy(),这里翻译就是分配哨兵和代理,本质上就是给哨兵节点分配内存,然后将其的 _Next_Prev 都指向自身,_Construct_in_place 具体实现是 ::new (static_cast<void*>(_STD addressof(_Obj))) _Ty(_STD forward<_Types>(_Args)...); 就是个 placement new 在已分配好的内存上构建对象,最后将 _Myhead 指向 _Newhead 我们就保存了哨兵节点的指针。这里还有一部分代码是关于容器代理的(和迭代器有关),博主还没有完全弄懂,等后续全部理解了,再出相关文章讲解,本篇只关注于 list 本身。

cpp 复制代码
    void _Alloc_sentinel_and_proxy() {
        auto&& _Alproxy = _GET_PROXY_ALLOCATOR(_Alnode, _Getal());
        _Container_proxy_ptr<_Alty> _Proxy(_Alproxy, _Mypair._Myval2);
        auto& _Al     = _Getal();
        auto _Newhead = _Al.allocate(1);
        _Construct_in_place(_Newhead->_Next, _Newhead);
        _Construct_in_place(_Newhead->_Prev, _Newhead);
        _Mypair._Myval2._Myhead = _Newhead;
        _Proxy._Release();
    }

list<int> 为例看看 _Mypair 具体保存了什么。

插入节点

无论是 push_frontpush_backinsert 最后调用的都是 _Emplace 方法:

cpp 复制代码
    void push_front(const _Ty& _Val) {
        _Emplace(_Mypair._Myval2._Myhead->_Next, _Val);
    }
    void push_back(const _Ty& _Val) {
        _Emplace(_Mypair._Myval2._Myhead, _Val);
    }
    iterator insert(const_iterator _Where, const _Ty& _Val) { // insert _Val at _Where
#if _ITERATOR_DEBUG_LEVEL == 2
        _STL_VERIFY(_Where._Getcont() == _STD addressof(_Mypair._Myval2), "list insert iterator outside range");
#endif // _ITERATOR_DEBUG_LEVEL == 2
        return _Make_iter(_Emplace(_Where._Ptr, _Val));
    }

那么我们深入看下 _Emplace 方法。

cpp 复制代码
    template <class... _Valty>
    _Nodeptr _Emplace(const _Nodeptr _Where, _Valty&&... _Val) { // insert element at _Where
        size_type& _Mysize = _Mypair._Myval2._Mysize;
        if (_Mysize == max_size()) {
            _Xlength_error("list too long");
        }

        _List_node_emplace_op2<_Alnode> _Op{_Getal(), _STD forward<_Valty>(_Val)...};
        ++_Mysize;
        return _Op._Transfer_before(_Where);
    }
  1. 首先是获取到链表的长度,看看是否超过了最大长度的限制,一般是 2 64 − 1 2^{64} - 1 264−1。
  2. 构造了一个操作类的对象。
  3. ++_Mysize 增加链表的长度。
  4. 调用 _Transfer_before 插入节点。

那么看下这个操作类的构造函数:

cpp 复制代码
    template <class... _Valtys>
    explicit _List_node_emplace_op2(_Alnode& _Al_, _Valtys&&... _Vals) : _Alloc_construct_ptr<_Alnode>(_Al_) {
        this->_Allocate();
        _Alnode_traits::construct(this->_Al, _STD addressof(this->_Ptr->_Myval), _STD forward<_Valtys>(_Vals)...);
    }
  1. this->_Allocate() 就是用分配器分配一块_List_node大小的内存。
  2. 然后 construct 在这块内存上构造,就是把 _List_node._Myval 给设置好,但它的 _Prev_Next 都还未设置。

最后就是通过 _Op._Transfer_before(_Where) 设置好 _Prev_Next,本质就是一个简单的链表插入节点,在 _Insert_next 前插入节点,对于一开始无节点的链表(只有一个哨兵节点),就是插入到哨兵节点的前面。

cpp 复制代码
    pointer _Transfer_before(const pointer _Insert_before) noexcept {
        const pointer _Insert_after = _Insert_before->_Prev;
        _Construct_in_place(this->_Ptr->_Next, _Insert_before);
        _Construct_in_place(this->_Ptr->_Prev, _Insert_after);
        const auto _Result    = this->_Ptr;
        this->_Ptr            = pointer{};
        _Insert_before->_Prev = _Result;
        _Insert_after->_Next  = _Result;
        return _Result;
    }

删除节点

对于 pop_frontpop_back 内部调用的是 _Unchecked_erase,这里哨兵节点的前驱节点是链表的最后一个节点,哨兵节点的后继节点是链表的第一个节点:

cpp 复制代码
void pop_front() noexcept /* strengthened */ {
#if _CONTAINER_DEBUG_LEVEL > 0
        _STL_VERIFY(_Mypair._Myval2._Mysize != 0, "pop_front called on empty list");
#endif // _CONTAINER_DEBUG_LEVEL > 0

        _Unchecked_erase(_Mypair._Myval2._Myhead->_Next);
    }

void pop_back() noexcept /* strengthened */ {
#if _CONTAINER_DEBUG_LEVEL > 0
        _STL_VERIFY(_Mypair._Myval2._Mysize != 0, "pop_back called on empty list");
#endif // _CONTAINER_DEBUG_LEVEL > 0

        _Unchecked_erase(_Mypair._Myval2._Myhead->_Prev);
    }

继续看下 _Unchecked_erase ,这里的 _Pnode 就是要删除的节点。

cpp 复制代码
    _Nodeptr _Unchecked_erase(const _Nodeptr _Pnode) noexcept { // erase element at _Pnode
        const auto _Result = _Pnode->_Next;
        _Mypair._Myval2._Orphan_ptr2(_Pnode);
        --_Mypair._Myval2._Mysize;
        _Pnode->_Prev->_Next = _Result;
        _Result->_Prev       = _Pnode->_Prev;
        _Node::_Freenode(_Getal(), _Pnode);
        return _Result;
    }
  1. 首先获取到要删除节点的后继节点给 _Result
  2. _Orphan_ptr2 主要是在 Debug 下和迭代器有关的操作,后续出文章细说。
  3. 链表大小减 1。
  4. 经典的删除节点操作,让删除节点的前驱节点的后继指向 _Result,然后把 _Result 节点的前驱节点指向要删除节点的前驱节点
  5. _Node::_Freenode(_Getal(), _Pnode) 分配器回收内存

其中 _Node::_Freenode 具体代码如下:

cpp 复制代码
    template <class _Alnode>
    static void _Freenode(_Alnode& _Al, _Nodeptr _Ptr) noexcept { // destroy all members in _Ptr and deallocate with _Al
        allocator_traits<_Alnode>::destroy(_Al, _STD addressof(_Ptr->_Myval));
        _Freenode0(_Al, _Ptr);
    }

destroy 就是调用 _List_node_Myval 的析构函数:

cpp 复制代码
    template <class _Uty>
    static _CONSTEXPR20 void destroy(_Alloc&, _Uty* const _Ptr) {
#if _HAS_CXX20
        _STD destroy_at(_Ptr);
#else // ^^^ _HAS_CXX20 / !_HAS_CXX20 vvv
        _Ptr->~_Uty();
#endif // ^^^ !_HAS_CXX20 ^^^
    }

_Freenode0 一个是调用要删除节点 _Prev_Next 的析构函数(这里可能是封装成指针的类),然后分配器回收内存:

cpp 复制代码
    template <class _Alnode>
    static void _Freenode0(_Alnode& _Al, _Nodeptr _Ptr) noexcept {
        // destroy pointer members in _Ptr and deallocate with _Al
        static_assert(is_same_v<typename _Alnode::value_type, _List_node>, "Bad _Freenode0 call");
        _Destroy_in_place(_Ptr->_Next);
        _Destroy_in_place(_Ptr->_Prev);
        allocator_traits<_Alnode>::deallocate(_Al, _Ptr, 1);
    }

对于另一个删除节点的方法 erase 本质也是调用了 _Freenode

cpp 复制代码
    iterator erase(const const_iterator _Where) noexcept /* strengthened */ {
#if _ITERATOR_DEBUG_LEVEL == 2
        _STL_VERIFY(_Where._Getcont() == _STD addressof(_Mypair._Myval2), "list erase iterator outside range");
#endif // _ITERATOR_DEBUG_LEVEL == 2
        const auto _Result = _Where._Ptr->_Next;
        _Node::_Freenode(_Getal(), _Mypair._Myval2._Unlinknode(_Where._Ptr));
        return _Make_iter(_Result);
    }

_Freenode 前面已经分析过了,下面看下这里的 _Unlinknode,其实就是之前提到的删除节点的经典方式,不知道为啥前面的那部分代码不直接用 _Unlinknode 这个封装好的方法:

cpp 复制代码
    _Nodeptr _Unlinknode(_Nodeptr _Pnode) noexcept { // unlink node at _Where from the list
        _Orphan_ptr2(_Pnode);
        _Pnode->_Prev->_Next = _Pnode->_Next;
        _Pnode->_Next->_Prev = _Pnode->_Prev;
        --_Mysize;
        return _Pnode;
    }

清空容器

下面再聊下也是常使用的一个操作 clear 清空容器:

cpp 复制代码
    void clear() noexcept { // erase all
        auto& _My_data = _Mypair._Myval2;
        _My_data._Orphan_non_end();
        _Node::_Free_non_head(_Getal(), _My_data._Myhead);
        _My_data._Myhead->_Next = _My_data._Myhead;
        _My_data._Myhead->_Prev = _My_data._Myhead;
        _My_data._Mysize        = 0;
    }
  1. _Node::_Free_non_head 回收除了哨兵节点之外其他节点的内存。
  2. 将哨兵节点复原到初始状态,即它的 _Next_Prev 都指向自身。
  3. 链表长度归 0。

这里主要再看下 _Free_non_head 的内部实现:

cpp 复制代码
    template <class _Alnode>
    static void _Free_non_head(
        _Alnode& _Al, _Nodeptr _Head) noexcept { // free a list starting at _First and terminated at nullptr
        _Head->_Prev->_Next = nullptr;

        auto _Pnode = _Head->_Next;
        for (_Nodeptr _Pnext; _Pnode; _Pnode = _Pnext) {
            _Pnext = _Pnode->_Next;
            _Freenode(_Al, _Pnode);
        }
    }
  1. 把哨兵节点的前驱节点的后继设为 nullptr。
  2. 找到哨兵节点的后继节点,也就是链表的第一个节点。
  3. 然后开始释放链表中的每一个节点。

析构函数

最后看下 list 的析构函数:

cpp 复制代码
    ~list() noexcept {
        _Tidy();
#if _ITERATOR_DEBUG_LEVEL != 0 // TRANSITION, ABI
        auto&& _Alproxy = _GET_PROXY_ALLOCATOR(_Alnode, _Getal());
        _Delete_plain_internal(_Alproxy, _Mypair._Myval2._Myproxy);
#endif // _ITERATOR_DEBUG_LEVEL != 0
    }

_Tidy() 其实内部就和之前 clear 差不多,只不过这里还需要把哨兵节点也释放掉:

cpp 复制代码
    void _Tidy() noexcept {
        auto& _Al      = _Getal();
        auto& _My_data = _Mypair._Myval2;
        _My_data._Orphan_all();
        _Node::_Free_non_head(_Al, _My_data._Myhead);
        _Node::_Freenode0(_Al, _My_data._Myhead);
    }

小结

本篇文章大致地讲解了下 list 的内部结构和一些基础操作的具体实现,但是代码框架还挺庞大的,尤其还会涉及到辅助 Debug 信息的代码。然后博主对迭代器失效的问题(MSVC的内部实现)还没有完全搞明白,所以后续想着还是把 MSVC 的迭代器的部分给搞清楚,更篇迭代器的文章,之后再继续更一些 vectorunordered_mappriority_queue 等MSVC的底层实现解析。

相关推荐
唐诺5 小时前
几种广泛使用的 C++ 编译器
c++·编译器
冷眼看人间恩怨6 小时前
【Qt笔记】QDockWidget控件详解
c++·笔记·qt·qdockwidget
红龙创客6 小时前
某狐畅游24校招-C++开发岗笔试(单选题)
开发语言·c++
Lenyiin6 小时前
第146场双周赛:统计符合条件长度为3的子数组数目、统计异或值为给定值的路径数目、判断网格图能否被切割成块、唯一中间众数子序列 Ⅰ
c++·算法·leetcode·周赛·lenyiin
yuanbenshidiaos7 小时前
c++---------数据类型
java·jvm·c++
十年一梦实验室8 小时前
【C++】sophus : sim_details.hpp 实现了矩阵函数 W、其导数,以及其逆 (十七)
开发语言·c++·线性代数·矩阵
taoyong0018 小时前
代码随想录算法训练营第十一天-239.滑动窗口最大值
c++·算法
这是我588 小时前
C++打小怪游戏
c++·其他·游戏·visual studio·小怪·大型·怪物
fpcc8 小时前
跟我学c++中级篇——C++中的缓存利用
c++·缓存
呆萌很8 小时前
C++ 集合 list 使用
c++