【C++】深入剖析list:list及其双向迭代器实现

目录

    • 前言
    • [一. 认识list](#一. 认识list)
    • [二. list的使用技巧](#二. list的使用技巧)
      • [1. 迭代器的分类](#1. 迭代器的分类)
      • [2. list的迭代器类型](#2. list的迭代器类型)
      • [3. list::sort的效率陷阱](#3. list::sort的效率陷阱)
    • [三. list的模拟实现](#三. list的模拟实现)
      • [1. list的主体框架](#1. list的主体框架)
      • [2. list迭代器的完整实现](#2. list迭代器的完整实现)
        • [2.1 const迭代器](#2.1 const迭代器)
        • [2.2 第三个模板参数](#2.2 第三个模板参数)
      • [3. 成员函数的实现](#3. 成员函数的实现)

前言

继上一篇文章讲解的vector之后,这篇文章将带大家学习和实现下一个STL的容器------list。通过前文我们知道,vector的底层是一个动态顺序表,主要是通过数组或者说一块连续的内部空间存储数据。而list则和vector不同,list的底层是一个链表,一个带头双向循环的链表,而因为链表本质是通过一个个物理上不连续的节点组成,所以,list的实现和vector的实现有很大的不同,就拿单个数据存储的方式来说,vector存储一个数据是直接开辟空间直接储存,而list则需要封装一个节点进行数据间接的存储。

这里提前给大家打个预防针,就是list的实现对比vector的实现会难上一点,这里因为list本身的性质所不可避免的(也可以说这就是它插入数据的时间复杂度为O(1)的代价)。但是不需要太过担心,其实只需要理清楚list类的实现框架,就会变得享受这种新颖的实现方式。那么,下面就进入list的学习吧~

本文知识点:

  • 📌迭代器的种类
  • 📌list的成员函数sort的效率陷阱
  • 📌list的迭代器的封装
  • 📌通过模板参数控制list的普通迭代器和const迭代器
  • 📌operator->的实现

一. 认识list

list参考文档地址

上文中,我们已经初步认识了list,知晓了它的本质/底层就是一个链表,这里也可以看看C++标准库中给出的list的定义,如下图所示。


图 1-1

文档的说明是:"list是一个允许在任意位置以常数时间进行插入和删除操作的顺序容器,同时它还支持双向迭代"。从这段说明也可以证明list是一个链表的说法,而关于"双向迭代"在后面会讲解,这里先知道这个特性即可。

list的修改类的成员函数如下图,因为list提供的这些函数接口和vector那里的函数接口的效果和使用几乎是一模一样的,所以本文就不再重复讲解了。

vector文章参考


图 1-2

二. list的使用技巧

1. 迭代器的分类

C++标准库中对list一句简短的说明中,除了描述list的基本特性外,还给出了它另外的一个特性"list支持双向迭代",那这里的双向迭代是什么意思?要讲清楚list的这个性质,得先了解一个新的知识:迭代器的分类

在之前我们对迭代器的了解仅仅只是使用它对容器进行遍历和修改容器的数据,知道迭代器是一个类似指针的东西,但是确不清楚迭代器之间也有自己的分类,即不同容器的迭代器的类型可能是不相同的

或许有的人会和我之前一样,由于每个容器的迭代器的名称都是iterator,所以就理所当然的认为每个容器的迭代器都是一样的,统一的种类,但是事实就是我错了。

迭代器一般被分为3类:单向迭代器,双向迭代器,随机迭代器。分类表格如下:

迭代器类型 支持的操作
单向迭代器 ++
双向迭代器 ++,--
随机迭代器 ++,--,+,-
  • 单向迭代器其实也就是只能往一个方向走的迭代器,所以单向迭代器只支持++,这也就限制了它只能一直向前走,直到走到容器的结尾。
  • 双向迭代器就是可以往前后两个方向走的迭代器,所以它支持++--这两个运算符。
  • 随机迭代器权限最高,可以通过迭代器+-之间的运算访问容器中任意一个位置,它同时也支持双向迭代器的功能。

为什么要对迭代器分类?

那说了迭代器的分类之后,你是否想过,为什么要将迭代器进行分类呢?这是由于不同的容器的特性迫使C++标准库需要给迭代器进行分类。举个例子,如果要访问一个容器的第pos位置的值,对于底层是数组的容器,可以直接使用起始位置的迭代器 + pos就能访问到,其访问的逻辑也就是指针的加减,而对于底层是链表的容器,它还可以使用刚刚的方法访问到第pos位置的值吗?

不行,因为底层为链表的容器和底层为数组的容器访问第pos位置的值的逻辑完全不一样了,而如果容器的底层是树的话,那差别就更加大了。所以,正是因为不同的容器要到达相同的目标所使用的方法和付出的代价不同,所以需要给它们的迭代器进行归类,也就是需要给迭代器分类。

迭代器的分类可以从标准库提供的一些函数接口看出。(如图2-1,2-2所示)这里的函数参数的名字都有迭代器种类的提示,reverse函数需要传递双向迭代器,sort函数需要传递随机迭代器。

虽然这两个函数都是模板函数,理论上可以传递任意种类的迭代器,但是如果传递的迭代器的功能和参数中需要的迭代器的功能匹配不上,编译就会不通过。例如:reverse函数需要传递双向迭代器才能正常执行函数功能,如果此时传递一个红黑树的迭代器(单向迭代器),编译就会不通过。


图 2-1


图 2-2 ⚠ 注意:当函数参数指明需要传递双向迭代器时,传递随机迭代器,函数也可以正常运行,这是因为随机迭代器的功能包含了双向迭代器的功能。随机迭代器和双向迭代器都包含了单向迭代器的功能。

2. list的迭代器类型

这里回归到上面的问题,"list支持双向迭代"这句话是什么意思呢?在知道了迭代器的分类之后,其实大概也可以猜出这句话要表达的意思了,即list迭代器是双向迭代器。C++标准库中也对这个结论再次进行了说明,如图 2-3所示。


图 2-3

3. list::sort的效率陷阱

由图 2-2我们得知,C++算法库(algorithm)中的sort函数需要传递随机迭代器才可以让函数正常调用,而list的迭代器是双向迭代器,这也就代表list不能使用算法库中的sort函数进行排序。而为了让list也能使用库函数排序,所以C++标准库为list封装了一个成员函数sort(之后表述为list::sort)。

这看似标准库为 list 封装了 list::sort 可以让 list 的排序很方便,但是 list::sort 在实际的场景中其实并不常用,也并不实用,而造成这个的原因是因为 list::sort 的效率非常的低!

我们可以使用下面这段代码对list::sort的性能进行测试,代码中分别为vector容器和list容器插入了100万个随机数,vector使用std::sort排序,list使用list::sort排序,通过clock函数记录两个排序在Debug版本下所用的时间,如图 2-4所示。

cpp 复制代码
srand(time(0));
int N = 1000000;
list<int> lt;
vector<int>v;
for (int i = 0; i < N; i++)
{
	int x = rand() + i;
	lt.push_back(x);
	v.push_back(x);
}

int begin1 = clock();
sort(v.begin(), v.end());
int end1 = clock();

int begin2 = clock();
lt.sort();
int end2 = clock();

cout << "vector: " << end1 - begin1 << endl;
cout << "list: " << end2 - begin2 << endl;

注:为了方便读者阅读,代码中省略了头文件和main函数


图 2-4

这里看上去时间没有相差很多,甚至使用std::sort排序的vector所用的时间还更久,这里先不着急下结论,我们再测试一下在Release版本下std::sort和list::sort的性能对比,如图 2-5所示。

这里两个函数的性能之差就体现出来了,std::sort比list::sort的效率快了一倍多。那在Debug版本下list::sort的效率也还可以啊,为什么就直接定论list::sort效率很低?

这是因为,我们给用户的发行版本是Release版本,所以我们在乎的只是在Release版本下程序的效率,而list::sort在Release版本下效率很低,那对于实际的工程项目来说它就是效率低,没有实用性。而如果过于的在乎Debug版本下list::sort的效率那就是本末倒置了。


图 2-5

🎯总结list::sort在Release版本下的效率很低,在实际的场景中尽量不要使用

三. list的模拟实现

1. list的主体框架

在实现list这个STL容器之前,得理清楚它得大框架。首先需要明确list的底层是一个带头双向循环链表 。那既然是链表,就一定离不开节点的结构,所以,其次就是确定好list节点的结构最后定义好成员变量和迭代器,而对于list来说,迭代器的实现可不像vector那样简单,因为list的结构是一个个的节点,关于list的迭代器的实现在后文重点讲解,现在要先搭好大框架。

  • list节点结构

    list的节点结构的内部和数据结构中实现的带头双向循环链表一样,都是一个数据两个指针,但是这里需要注意的是,因为后续在list类中会频繁对节点进行操作,所以将节点结构写成struct。开放节点的成员变量以方便list访问。

    cpp 复制代码
    template<class T>
    struct list_node
    {
        T data;
        list_node<T>* prev;
        list_node<T>* next;
        
        list_node(const T& val = T())
            :data(val)
            ,next(nullptr)
            ,prev(nullptr)
        {
        }
    };
  • list成员变量

    因为list是带头双向循环链表,所以只需要有链表的头节点就可以拿到链表任意位置的数据,所以list的成员函数只需要一个指向头节点的指针即可。

    cpp 复制代码
    template<class T>
    class list
    {
    public:
        typedef list_node<T> Node;
    private:
        Node* _head;
    };
  • list普通迭代器的实现

    到list的迭代器的实现就有问题了,因为迭代器的操作需要模拟指针的操作,而list不像vector一样可以直接使用指针作为迭代器,list是由一个个结构体(链表节点)组成的,只能提供指向一个链表节点的指针。所以,如果它想像其它迭代器一样,*迭代器就可以拿到数据的值,++就可以指向下一个数据,就需要++将指向链表节点的指针封装成一个类作为迭代器的类型++。

    如下代码所示,这段代码将链表节点类型的指针Node*封装成一个类作为list的迭代器的类型,其中需要说明的地方有3个。

    1. 因为迭代器必须要Node*类型的变量初始化才能知道它指向哪个位置,所以类中只有一个参数为Node*的构造函数,使用其它方式初始化迭代器都会报错。
    2. 类中为了支持和其它容器迭代器一样的操作,对*++!=做了运算符重载,使得迭代器的基本遍历功能可以使用。但是,因为list是双向迭代器,所以它应该还要有后置++前置--后置--等运算符重载,这里为了方便读者阅读暂时只实现核心的运算符重载。
    3. 因为后续类中有很多地方会使用__list_iterator<T>,而如果后面出了变故需要改变类模板的参数,就有很多的地方需要修改,所以为了避免这种问题,直接将__list_iterator起别名为Self。
    cpp 复制代码
    template<class T>
    struct __list_iterator
    {
        typedef list_node<T> Node;
        typedef __list_iterator<T> Self;
        // 成员变量
        Node* _node;
        // 成员函数
        Self(Node* node)
            :_node(node)
        {}
        T& operator*()
        {
            return _node->data;
        }
        Self& operator++()
        {
            _node = _node->next;
            return *this;
        }
        bool operator!=(const Self& it)
        {
            return _node != it._node;
        }
    };
  • list的主体框架

    将上面实现的list的框架的核心结合起来,就实现了list的大框架,代码如下:

    cpp 复制代码
    namespace yzx
    {
        template<class T>
        struct list_node   // 节点结构
        {
            T data;
            list_node<T>* prev;
            list_node<T>* next;
            // 构造函数
            list_node(const T& val = T())
                :data(val)
                ,next(nullptr)
                ,prev(nullptr)
            {
            }
        };
        
        template<class T>
        struct __list_iterator   // 迭代器
        {
            typedef list_node<T> Node;
            typedef __list_iterator<T> Self;
            // 成员变量
            Node* _node;
            // 成员函数
            Self(Node* node)
                :_node(node)
            {}
            T& operator*() { return _node->data; }
            bool operator!=(const Self& it) { return _node != it._node; }
            Self& operator++()
            {
                _node = _node->next;
                return *this;
            }
    
        };
        
        template<class T>
        class list
        {
        public:
            typedef list_node<T> Node;
            typedef __list_iterator<T> iterator;
            void empty_init()   // 用于list的空初始化
            {
                 _head = new Node;
                _head->prev = _head;
                _head->next = _head;
            }
            list() { empty_init(); }
        private:
            Node* _head;
        };  
    }

2. list迭代器的完整实现

在完善了list类的大框架之后,下一步就是开始精工细作,实现list的一些核心的功能,这里因为list的迭代器比较特殊,且之后的很多成员函数内部都需要复用迭代器来实现和简化代码,所以我们先将list的迭代器的所有功能实现完再开始其它成员函数的实现。

2.1 const迭代器

首先要知道,const迭代器的含义不是使用const修饰迭代器本身,而是不能通过*迭代器的方式修改容器的数据,但是迭代器本身是可以变化的,所以可以首先排除下面代码的这种实现方法。

cpp 复制代码
typedef __list_iterator<T> iterator;
typedef const __list_iterator<T> const_iterator;

直接将上面的方法排除之后,不难想到,既然是*迭代器不能修改,那么const迭代器和普通迭代器的关键区分点就应该在operator*上,普通迭代器的operator*是返回T&,那是否const迭代器就是返回const T&就可以了呢?

我们姑且认为这种方法可行,但是,如果只改变返回值,这两个函数却不能构成函数重载,而要如果要构成函数重载,就需要将返回值为const T&的那个运算符重载函数改为const成员函数,如下代码所示。

cpp 复制代码
T& operator*() 
{
    return _node->data; 
}
const T& operator*() const
{
    return _node->data; 
}

这段的代码咋一看没啥问题,可是如果仔细想想,就会发现下面的这个函数只有当迭代器本身被const修饰才会被调用,也就是只有const __list_iterator<T>才能调用到这第二个函数,那么,这个运算符重载不就是对上面的错误的方法的补充吗?所以这种方案是不可行的。

而正确的实现const迭代器的方法也很简单,直接为迭代器的类模板增加一个参数即可,如下代码所示:

cpp 复制代码
template<class T, class Ref>
struct __list_iterator   // 迭代器
{
    typedef list_node<T> Node;
    typedef __list_iterator<T, Ref> Self;
    // 成员变量
    Node* _node;
    // 成员函数
    Self(Node* node)
        :_node(node)
    {}
    Ref operator*() 
    {
        return _node->data; 
    }
};
cpp 复制代码
typedef __list_iterator<T, T&> iterator;   // 普通迭代器
typedef __list_iterator<T, const T&> const_iterator;   // const迭代器

类模板中直接增加了一个参数Ref,而operator*的函数返回值直接就返回Ref的类型,这也就代表,我们是可以在模板类的实例化时直接控制operator*的返回值的,而有了这样的方法,就可以通过使用不同的参数实例化这个模板类来区分普通迭代器和const迭代器

2.2 第三个模板参数

普通迭代器和const迭代器实现了之后,迭代器就还差一个较为特别的东西没有实现,即operator->。因为迭代器的操作需要模仿指针的操作,所以实现这个运算符重载是很有必要的。

operator->

operator->的函数的实现很简单,就是直接返回数据的地址也可以说返回指向数据data的指针。这里operator->其实可以和operator*做对比,operator->是返回数据的地址,而operator *是返回数据的引用,使用这两种方式都可以访问到迭代器指向位置的数据,只是它们的使用方法不同而已。

普通迭代器的operator->是直接返回指向数据的指针,也就是T*,而const迭代器的operator->则不同,因为const迭代器不能修改容器内的数据,所以需要返回const T*,而这个问题又可以和operator*进行类比了。

因为operator*函数曾经面临过这种的情况(普通迭代器和const迭代器对于一个相同的函数其返回值不同),所以operator->的解决方案也使用operator*的解决方案:为类模板再增加一个参数,也就是第三个模板参数

cpp 复制代码
template<class T, class Ref, class Ptr>
struct __list_iterator   // 迭代器
{
    typedef list_node<T> Node;
    typedef __list_iterator<T, Ref, Ptr> Self;
    // 成员变量
    Node* _node;
    // 成员函数
    Self(Node* node)
        :_node(node)
    {}
    Ref operator*() 
    {
        return _node->data; 
    }
    Ptr operator->()
    {
        return &(_node->data);
    }
};

所以,最终版本的迭代器的声明如下代码所示:

cpp 复制代码
typedef __list_iterator<T, T&, T*> iterator;   // 普通迭代器
typedef __list_iterator<T, const T&, const T*> const_iterator;   // const迭代器

🎯总结:其实到这里会发现,list迭代器其实并没有很大的难度,也就是需要单独的封装一个类而已。而关于普通迭代器和const迭代器,只需记住,当遇到类中同一个成员函数,如果因为普通迭代器和const迭代器的区分使得函数返回值不同,就给类模板增加一个参数,使得类实例化时就可以为普通迭代器和const迭代器区分。


迭代器的成员函数的补充

cpp 复制代码
Self operator++(int)
{
	Self tmp(_node);
	_node = _node->next;
	return tmp;
}

Self& operator--()
{
	_node = _node->prev;
	return *this;
}

Self operator--(int)
{
	Self tmp(_node);
	_node = _node->prev;
	return tmp;
}

bool operator==(const Self& it) const
{
	return _node == it._node;
}

3. 成员函数的实现

list的迭代器实现完之后,list的核心点就算讲解完了,其它的成员函数的实现相较而言都更简单,没有那么多坑点,下面实现几个list较为常用的成员函数。

修改(部分)
  • insert

    这里insert的实现和数据结构带头双向链表的insert的实现基本上就是一样的,时间复杂度也是O(1)的。不一样的是,这里为了统一迭代器失效这个规定,这里insert和erase都有迭代器类型返回值。insert返回的是指向插入位置的迭代器,erase返回的是指向删除位置的下一个元素的位置的迭代器

    cpp 复制代码
    iterator insert(iterator pos, const T& val)
    {
        Node* node = pos._node;
        Node* newnode = new Node(val);
        // 链接newnode节点
        newnode->prev = node->prev;
        newnode->next = node;
        node->prev->next = newnode;
        node->prev = newnode;
        // 使用newnode构造一个iterator的临时对象做返回值
        return newnode;
    }
  • erase

    cpp 复制代码
    iterator erase(iterator pos)
    {
        Node* node = pos._node;
        node* prev = node->prev;
        node* next = node->next;
        // 解除node节点的链接
        prev->next = next;
        next->prev = prev;
        delete node;
        node = nullptr;
        return next;
    }
  • push_back

    cpp 复制代码
    void push_back(const T& val)
    {
        insert(end(), val);
    }
构造函数
cpp 复制代码
list(const initializer_list<T>& il)
{
    empty_init();
    for(const auto& e : il)
    {
        push_back(e);
    }
}
析构函数
cpp 复制代码
~list()
{
    auto it = begin();
    while(it != end())
    {
        erase(it);
        ++it;
    }
    delete _head;
    _head = nullptr;
}

最后,相信你已经可以看出来了,实现了inserterase这两个关键函数后,其它的成员函数或多或少都可以直接复用这两个函数,而这也是我只实现了一部分成员函数的原因,因为其它的成员函数的实现基本上也都是这样,直接复用其它成员函数即可。对于想要对list的实现一探究竟的读者,我将list的实现的完整的代码放在下面,需要的可以自取。

list实现的完整代码

相关推荐
脚踏实地皮皮晨1 小时前
003002004_WPF Panel 基类 官方类定义
开发语言·windows·算法·c#·wpf·visual studio
520拼好饭被践踏1 小时前
JAVA+Agent学习day25
java·开发语言·学习
大黄说说1 小时前
Java 并发大坑:volatile、synchronized、Lock 三者如何选择?
java·开发语言
魏码不凡1 小时前
CURL报错:未找到SSL证书文件问题
开发语言·php·ssl
tudousisi2222 小时前
P4667 [BalticOI 2011] Switch the Lamp On复盘
c++
geovindu2 小时前
go:loghelper
开发语言·后端·golang
听雨入夜2 小时前
zero.zhang
开发语言·python
砚凝霜2 小时前
软考网络工程师|第 4 章 WLAN MAC 机制、Ad Hoc、无线安全、WPAN 蓝牙 ZigBee 完整备考笔记
网络·笔记·安全
江屿风2 小时前
【C++笔记】【二叉搜索树】流食般投喂
开发语言·数据结构·c++·笔记