文章目录
本次所需实现的三个类及其成员函数接口总览
namespace cl
{
//模拟实现list当中的结点类
template<class T>
struct _list_node
{
//成员函数
_list_node(const T& val = T()); //构造函数
//成员变量
T _val; //数据域
_list_node<T>* _next; //后继指针
_list_node<T>* _prev; //前驱指针
};
//模拟实现list迭代器
template<class T, class Ref, class Ptr>
struct _list_iterator
{
typedef _list_node<T> node;
typedef _list_iterator<T, Ref, Ptr> self;
_list_iterator(node* pnode); //构造函数
//各种运算符重载函数
self operator++();
self operator--();
self operator++(int);
self operator--(int);
bool operator==(const self& s) const;
bool operator!=(const self& s) const;
Ref operator*();
Ptr operator->();
//成员变量
node* _pnode; //一个指向结点的指针
};
//模拟实现list
template<class T>
class list
{
public:
typedef _list_node<T> node;
typedef _list_iterator<T, T&, T*> iterator;
typedef _list_iterator<T, const T&, const T*> const_iterator;
//默认成员函数
list();
list(const list<T>& lt);
list<T>& operator=(const list<T>& lt);
~list();
//迭代器相关函数
iterator begin();
iterator end();
const_iterator begin() const;
const_iterator end() const;
//访问容器相关函数
T& front();
T& back();
const T& front() const;
const T& back() const;
//插入、删除函数
void insert(iterator pos, const T& x);
iterator erase(iterator pos);
void push_back(const T& x);
void pop_back();
void push_front(const T& x);
void pop_front();
//其他函数
size_t size() const;
void resize(size_t n, const T& val = T());
void clear();
bool empty() const;
void swap(list<T>& lt);
private:
node* _head; //指向链表头结点的指针
};
}
结点类的模拟实现
我们经常所说的链表,一般印象中会觉得是一个单链表,其实STL实现的底层是一个带头结点的双向循环链表,因此我们明确就知道了,一个结点应该存储指向前面一个地址的成员,和后一个地址的成员,还有数据域(即前驱指针,后继指针,数据域)三个成员变量。
构造函数
构造一个结点很简单,只需要给定数据域构造一个结点出来就行了。
template<class T>
struct ListNode
{
ListNode(const T& x = T())
:_prev(nullptr)
,_next(nullptr)
,_data(x)
{}
ListNode<T>* _prev;
ListNode<T>* _next;
T _data;
};
注意: 若构造结点时未传入数据,则默认以list容器所存储类型的默认构造函数所构造出来的值为传入数据。
迭代器类的模拟实现
迭代器类存在的意义
之前模拟实现vector,string时都没有说要实现一个迭代器,为什么实现list的时候就需要实现一个list的迭代器呢?
因为string和vector对象都将数据存储在了一块连续的空间,通过指针++,–等操作符,直接跳过一个元素的大小,解引用就可以拿到对应位置的值了,指针是原生类型。
但是对应list来说,数据是存在每个结点,各个结点的内存地址是随机的,并不是连续的,当使用原生指针++,–等操作时,只是地址上跳过来一个结点大小,并没有到达对应位置结点,所以list使用原生指针就失效了。
而迭代器的意义就是,让使用者可以不必关心容器的底层实现,可以用简单统一的方式对容器内的数据进行访问
既然list的结点指针满足不了迭代器的定义,所以我们需要对这个结点指针进行封装,对结点指针的各种运算符操作进行重载,使得我们使用可以像vector,string当中的迭代器那样,简单统一的方式使用迭代器,比如使用list当中的迭代器++其实就是底层执行了p=p->next,只是我们表面上看不见而已。
总结: list迭代器类,实际上就是对结点指针进行了封装,对其各种运算符进行了重载,使得结点指针的各种行为看起来和普通指针一样。(例如,对结点指针自增就能指向下一个结点)
迭代器类的模板参数说明
这里我们所实现的迭代器类的模板参数列表当中为什么有三个模板参数?
template<class T, class Ref, class Ptr>
在list的模拟实现当中,我们typedef了两个迭代器类型,普通迭代器和const迭代器。
typedef _list_iterator<T, T&, T> iterator;
typedef _list_iterator<T, const T&, const T> const_iterator;**
这里我们就可以看出,迭代器类的模板参数列表当中的Ref和Ptr分别代表的是引用类型和指针类型。
当我们使用普通迭代器时,编译器就会实例化出一个普通迭代器对象;当我们使用const迭代器时,编译器就会实例化出一个const迭代器对象。
若该迭代器类不设计三个模板参数,那么就不能很好的区分普通迭代器和const迭代器。
构造函数
迭代器类实际上就是对结点指针进行了封装,其成员变量就只有一个,那就是结点指针,其构造函数直接根据所给结点指针构造一个迭代器对象即可。
__list_iterator(Node* x)
:_node(x)
{}
++运算符的重载
首先是前置++,前置++原本的作用是将数据自增,然后返回自增后的数据。我们的目的是让结点指针的行为看起来更像普通指针,那么对于结点指针的前置++,我们就应该先让结点指针指向后一个结点,然后再返回“自增”后的结点指针即可
self& operator++()
{
_node = _node->_next;
return *this;
}
对于后置++,我们则应该先记录当前结点指针的指向,然后让结点指针指向后一个结点,最后返回“自增”前的结点指针即可。
self operator++(int)
{
self tmp(*this);
_node = _node->_next;
return tmp;
}
说明: self是当前迭代器对象的类型:
typedef __list_iterator<T, Ref, Ptr> self;
–运算符的重载
对于前置- -,我们应该先让结点指针指向前一个结点,然后再返回“自减”后的结点指针即可
self& operator--()
{
_node = _node->_prev;
return *this;
}
而对于后置- -,我们则应该先记录当前结点指针的指向,然后让结点指针指向前一个结点,最后返回“自减”前的结点指针即可。
self operator--(int)
{
self tmp(*this);
_node = _node->_prev;
return tmp;
}
==运算符的重载
当使用==运算符比较两个迭代器时,我们实际上想知道的是这两个迭代器是否是同一个位置的迭代器,也就是说,我们判断这两个迭代器当中的结点指针的指向是否相同即可。
bool operator==(const self& s) const
{
return _node == s._node;
}
!=运算符的重载
!=运算符刚好和==运算符的作用相反,我们判断这两个迭代器当中的结点指针的指向是否不同即可。
bool operator!=(const self& s) const
{
return _node != s._node;
}
*运算符的重载
当我们使用解引用操作符时,是想得到该位置的数据内容。因此,我们直接返回当前结点指针所指结点的数据即可,但是这里需要使用引用返回,因为解引用后可能需要对数据进行修改。
Ref operator*()
{
return _node->_data;
}
->运算符的重载
某些情景下,我们使用迭代器的时候可能会用到->运算符。
想想如下场景:
当list容器当中的每个结点存储的不是内置类型,而是自定义类型,例如日期类,那么当我们拿到一个位置的迭代器时,我们可能会使用->运算符访问Date的成员:
list<Date> lt;
Date d1(2021, 8, 10);
Date d2(1980, 4, 3);
Date d3(1931, 6, 29);
lt.push_back(d1);
lt.push_back(d2);
lt.push_back(d3);
list<Date>::iterator pos = lt.begin();
cout << pos->_year << endl; //输出第一个日期的年份
注意: 使用pos->_year这种访问方式时,需要将日期类的成员变量设置为公有。
对于->运算符的重载,我们直接返回结点当中所存储数据的地址即可。
Ptr operator->()
{
return &(_node->_data);
}
讲到这里,可能你会觉得不对,按照这种重载方式的话,这里使用迭代器访问日期类当中的成员变量时不是应该用两个->吗?
pd = pos-> pd->_year == pos->->_year
这里本来是应该有两个->的,第一个箭头是pos ->去调用重载的operator->返回Date* 的指针,第二个箭头是Date* 的指针去访问对象当中的成员变量_year。
但是一个地方出现两个箭头,程序的可读性太差了,所以编译器做了特殊识别处理,为了增加程序的可读性,省略了一个箭头。
list的模拟实现
默认成员函数
构造函数
list是一个带头双向循环链表,无参的默认构造函数,空链表,实际上是一个头结点,前驱和后继指针都指向头结点本身
list()
{
empty_init();
}
void empty_init()
{
_head = new Node;
_head->_next = _head;
_head->_prev = _head;
}
拷贝构造函数
先创建一个空链表,再将lt链表的每个元素尾插过来就行了。
list(const list<T>& lt)
{
empty_init();
for (const auto& e : lt)
{
push_back(e);
}
}
赋值运算符重载函数
两种写法:
写法一:传统写法
先清空原容器,再将lt当中的元素一个一个尾插过来即可。
//传统写法
list<T>& operator=(const list<T>& lt)
{
if (this != <)
{
clear();
for (const auto& e : lt)
{
push_back(e);
}
}
return *this; //支持连续赋值
}
现代写法:
现代写法的代码量较少,首先利用编译器机制,故意不使用引用接收参数,通过传值传参编译器自动调用list的拷贝构造函数构造出来一个list对象,然后调用swap函数将原容器与该list对象进行交换即可。函数调用结束自动调用析构函数释放拷贝出来的对象。
list<T>& operator=(list<T> lt)
{
swap(lt);
return *this;
}
析构函数
对对象进行析构时,首先调用clear函数清理容器当中的数据,然后将头结点释放,最后将头指针置空即可。
void clear()
{
iterator it = begin();
while (it != end())
{
it = erase(it);
}
}
~list()
{
clear();
delete _head;
_head = nullptr;
}
迭代器相关函数
begin和end
首先我们应该明确的是:begin函数返回的是第一个有效数据的迭代器,end函数返回的是最后一个有效数据的下一个位置的迭代器。
对于list这个带头双向循环链表来说,其第一个有效数据的迭代器就是使用头结点后一个结点的地址构造出来的迭代器,而其最后一个有效数据的下一个位置的迭代器就是使用头结点的地址构造出来的迭代器。(最后一个结点的下一个结点就是头结点)
iterator begin()
{
return iterator(_head->_next);
}
iterator end()
{
return iterator(_head);
}
当然,还需要重载一对用于const对象的begin函数和end函数。
const_iterator begin() const
{
return const_iterator(_head->_next);
}
const_iterator end() const
{
return const_iterator(_head); //头结点为end,最后一个有效结点的下一个为头结点
}
访问容器相关函数
front和back
front返回链表一个结点值, back返回链表最后一个结点值
T& front()
{
return *(begin());
}
T& back()
{
return *(--end());
}
当然还要重载const对象
const T& front() const
{
return *(begin());
}
const T& back() const
{
return *(--end());
}
插入、删除函数
insert
insert函数可以在所给迭代器之前插入一个新结点, 返回新结点位置的迭代器。
iterator insert(iterator pos, const T& x)
{
assert(pos._node);
Node* cur = pos._node;
Node* prev = cur->_prev;
Node* newnode = new Node(x);
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
return newnode;
}
erase
erase函数可以删除所给迭代器位置的结点。
并且返回下一个结点位置的迭代器。
iterator erase(iterator pos)
{
assert(pos._node);
assert(pos != end());
Node* cur = pos._node;
Node* prev = cur->_prev;
Node* next = cur->_next;
prev->_next = next;
next->_prev = prev;
delete cur;
return next;
}
push_back和pop_back
复用insert和erase
void push_back(const T& x)
{
insert(end(), x);
}
void pop_back()
{
erase(--end());
}
push_front和pop_front
void push_front(const T& x)
{
insert(begin(), x);
}
void pop_front()
{
erase(begin());
}
其他函数
size
size函数用于获取当前容器当中的有效数据个数,因为list是链表,所以只能通过遍历的方式逐个统计有效数据的个数。
size_t size() const
{
size_t sz = 0;
iterator it = begin();
while (it != end())
{
sz++;
it++;
}
return sz;
}
resize
resize函数的规则:
- 若当前容器的size小于所给n,则尾插结点,直到size等于n为止。
- 若当前容器的size大于所给n,则只保留前n个有效数据。
实现resize函数时,不要直接调用size函数获取当前容器的有效数据个数,因为当你调用size函数后就已经遍历了一次容器了,而如果结果是size大于n,那么还需要遍历容器,找到第n个有效结点并释放之后的结点。
这里实现resize的方法是,设置一个变量len,用于记录当前所遍历的数据个数,然后开始遍历容器,在遍历过程中:
- 当len大于或是等于n时遍历结束,此时说明该结点后的结点都应该被释放,将之后的结点释放即可。
- 当容器遍历完毕时遍历结束,此时说明容器当中的有效数据个数小于n,则需要尾插结点,直到容器当中的有效数据个数为n时停止尾插即可。
void resize(size_t n, const T& val = T())
{
size_t len = 0;
iterator it = begin();
while (it != end() && len < n)
{
len++;
it++;
}
if (len >= n)
{
while (it != end())
{
it = erase(it);
}
}
else
{
while (len < n)
{
push_back(val);
len++;
}
}
}
clear
clear函数用于清空容器,我们通过遍历的方式,逐个删除结点,只保留头结点即可。
void clear()
{
iterator it = begin();
while (it != end())
{
it = erase(it);
}
}
empty
empty函数用于判断容器是否为空,我们直接判断该容器的begin函数和end函数所返回的迭代器,是否是同一个位置的迭代器即可。(此时说明容器当中只有一个头结点)
bool empty() const
{
return begin() == end();
}
swap
swap函数用于交换两个容器,list容器当中存储的实际上就只有链表的头指针,我们将这两个容器当中的头指针交换即可。
void swap(list<T>& lt)
{
std::swap(_head, lt._head);
}