list(链表)是一种非常重要的数据结构,在实际应用中到处可见,所以在笔试面试中都是考察的大热门。与vector类似,list也是线性结构,但不同的是list中的内存分布并不是连续的。
同时std::list作为C++中的一种容器(container),相信用过的朋友不在少数,但是真正读过源码了解底层实现的可能并不多。原因大致有二,首先list作为一种工具,会用就行,你也大抵没见过使用电视的人需要会造电视吧;其次,标准库中相应的模板的知识,增加了对list这种数据结构理解的难度。知乎上曾有这样的一个问题:c/c++语言应该在linux系统下面学习吗?我觉得不是的,我们要抓住事情的关键部分,这里的关键就是c/c++语言本身。在windows系统,你可以在”宇宙第一IDE”vs下安安心心的学C/C++语言,但是在linux下面呢,你得先学会linux的基本命令、makefile和系统相关知识等等。诚然,在linux下学习会对编译、链接过程更加了解,但学习曲线的陡然上升是不符合我们的预期的。
基于上面的想法,我们只需要研究包含了简单类型如int类型的std::list<int>
即可,当然为了不与标准库冲突,我们的代码全在namespace yang
中编写,也就是yang::list
大致相当于std::list<int>
的功能。(用自己名字,让我厚颜无耻一次吧,哈哈)
1、list的构成
list可以看作是由一系列的node来构成,其结构示意图如下:
而每一个node的组成如下:
这里prev指针指向上一个node节点,next指针指向下一个node节点
所以很自然我们的list_node应该如下:
struct list_node
{
list_node* prev{ nullptr };
list_node* next{ nullptr };
int value;
};
2、如何操作list中的node
最简单的方式莫过于定义一个list_node* 指针来操作,但是直接用指针的方式首先是不太安全,其次是写起代码来不够优雅美观。所幸,在STL中有一个很好的范本,迭代器iterator。将list_node pointer封装在iterator类中,不但较为安全,并且简洁美观。
list_iterator的实现如下:
struct list_iterator
{
list_node* node_;
list_iterator(list_node* node) :node_(node){}// implicit constructor cast
list_iterator(const list_iterator& rhs) :node_(rhs.node_){}
bool operator==(const list_iterator &rhs) const {
return node_ == rhs.node_;
}
bool operator!=(const list_iterator &rhs) const {
return node_ != rhs.node_;
}
int& operator*() const{ return node_->value; }
list_iterator& operator++(){
node_ = node_->next;
return *this;
}
list_iterator operator++(int){
list_iterator tmp = *this;
++*this;
return tmp;
}
list_iterator& operator--(){
node_ = node_->prev;
return *this;
}
list_iterator operator--(int){
list_iterator tmp = *this;
--*this;
return tmp;
}
};
首先list_iterator
内部定义了一个list_node* node_
的成员变量,用于来操作list
中的每一个node
。构造和拷贝构造函数都很简单,都只是初始化了node_
变量,并没有做其他事。重载操作符是实现迭代器的关键,我们来讲解下面五个函数。
2.1、迭代器的比较
首先我们需要比较两个迭代器是否相等或不等,以确定何时可以终止迭代。很简单,判断它们的成员变量node_
相等即可。
bool operator==(const list_iterator &rhs) const {
return node_ == rhs.node_;
}
bool operator!=(const list_iterator &rhs) const {
return node_ != rhs.node_;
}
2.2、迭代器的解引用
最重要的是要得到node节点中的值,这个值很容易获得(node_->value
),关键这个函数怎么写,写成普通的成员函数可以么?
int getValue(){return node_->value}
错到是没错,但是未免麻烦。想想我们引入迭代器是用来代替裸指针的,那么在行为上更类似指针的行为就更好理解且美观了,所以我们可以重载* operator
int& operator*() const{ return node_->value; }
为什么返回值是引用呢?留给下文阐述。
2.3、 迭代器的移动
对于某一个节点list_node* node_
来说,它的上一个节点可以用node_->prev
表示,它的下一个节点可以用node_->next
表示。所以很自然的,有:
list_iterator& operator++(){
node_ = node_->next;
return *this;
}
list_iterator& operator--(){
node_ = node_->prev;
return *this;
}
对于一个指向某个节点的迭代器list_iterator curr(node_)
来说,--curr和++curr
就分别表示了该node_
节点的上一个节点和下一个节点,简洁还明了。
好了,现在说说为什么有的重载操作符需要返回引用了,记得这个问题还在知乎上有个讨论,但我粗略地看过一遍,似乎没有特别有说服力的答案。实际上,很简单,因为左值表达式(lvalue expression)和右值表达式(rvalue expression)的关系。
那么有哪些表达式是左值表达式呢?(以下内容节选自cpp reference,原文为英文,感兴趣的可以自行去查阅)
1、函数或者操作符重载表达式的返回引用类型
2、内置类型的前++和前–表达式
3、*p,the bulit-in indirection expression 内置的解引用表达式
我们知道,C++的设计初衷是让用户定义的类型(class)能像内置类型(int)一样工作,具有同等的地位。
那么对于int a = 5; int *p = &a;
来说,--a,++a,*p
就全部是左值表达式了,所以对于用户自定义类型来说,如果要重载这几个操作符的话,就要保证该操作符重载表达式是左值表达式了。而操作符重载实际上就是一种较为特殊的函数而已,而从上述的第一条可以看到,只要函数返回引用类型,该函数调用表达式就是个左值表达式了。
事实上,所有的操作符重载都是符合这个规律的。
好了,本章到此就结束了,下一章我们就可以讲list本身了。
总结: list作为一种很重要的数据结局,是我们不得不掌握的知识点。本文重点介绍了list中的节点list_node
和迭代器list_iterator
和其代码实现,对于迭代器如何操作list中的node有了初步的了解。
参考资料:
STL 源码剖析 侯捷著
数据结构(c++语言版)(第三版)邓俊辉编著
gcc 2.95 version source code