C++标准库中的链表使用了allocator和iterator实现。因为默认的allocator就是调用new和delete,所以我这里直接使用了new和delete。且仅实现了一些常用的操作,这里只是仿照标准库实现,和标准库有差别。(const的iterator没写,list拷贝构造,拷贝赋值,移动构造,移动赋值都没写,以后有时间再来吧)
在C++中list是一个双向的、环状的链表,清楚了这个概念我们就不难理解标准库中的操作。
一、list_iterator
"list_iterator.h"
#pragma once
#include <iostream>
#include "list_node.h"
template<typename T>
struct list_iterator
{
typedef T value_type;
typedef T* pointer;
typedef T& reference;
typedef std::bidirectional_iterator_tag iterator_category;
typedef std::ptrdiff_t difference_type;
//两个迭代器之间的距离,这里实际上用的是longlongint
//每个迭代器都需要定义这5个类型,方便算法获取
//为了代码的可读性,我没有使用这里定义的5个类型
list_iterator(list_node<T>* node) noexcept
: node(node) {}
//因为链表需要通过迭代器来操作节点,进行插入删除等操作,
//所以这里的参数不能是const
list_iterator() noexcept
: node(nullptr) {}
list_iterator(const list_iterator<T>& it) noexcept
: node(it.node) {}
//使用const确保拷贝过程中不修改迭代器
inline T& operator*() const noexcept { return (*node).data; }
inline T* operator->() const noexcept { return &(operator*()); }
inline list_iterator<T>& operator++() noexcept {
node = node->next;
return *this;
}
inline list_iterator<T> operator++(int) noexcept {
list_iterator<T> tmp = *this;
++*this;
return tmp;
}
inline list_iterator<T>& operator--() noexcept {
node = node->prev;
return *this;
}
inline list_iterator<T> operator--(int) noexcept {
list_iterator<T> tmp = *this;
--*this;
return tmp;
}
inline bool operator==(const list_iterator<T>& it) const noexcept {
return it.node == node;
}
inline bool operator!=(const list_iterator<T>& it) const noexcept {
return it.node != node;
}
list_node<T>* node;
};
这里的迭代器是一个pointer-like-class,就是将一个类作为指针使用。(将一个类作为指针使用,必须重载所有指针具有的操作)
为什么不使用原生指针呢,是因为链表在内存中的分布空间不是连续的,使用原生指针++无法获取到指针下一个节点的位置,这时候就可以使用迭代器这样一个智能的指针来管理链表,重载++等操作符以满足我们操作链表的需求。(而像array,vector这样内存空间连续的容器就不需要,使用原生指针即可)
而且使用迭代器可以帮助我们很轻松地访问链表中的元素。在C++标准库中,强调将数据与操作数据的方法(算法)分离,这时候就需要通过迭代器来访问容器中的数据。以往我们在链表中特定位置插入一个数据时,需要从链表的头指针开始向后遍历直到对应位置才能进行插入操作,而使用迭代器可以配合std::advance直接进行插入操作。
list_iterator<T> insert(const list_iterator<T>& pos, const T& value) {
list_node<T>* new_node = new list_node<T>;
new_node->data = value;
list_node<T>* pos_node = pos.node;
new_node->prev = pos_node->prev;
new_node->next = pos_node;
pos_node->prev->next = new_node;
pos_node->prev = new_node;
++size;
return list_iterator<T>(new_node);
}
int main(){
list<int> a{1,2,3};
list_iterator<int> it = a.begin();
a.show();
//1 2 3
advance(it, 3);
a.insert(it,10);
a.show();
//11 1 2 3 10 4
}
1.关于noexcept
C++标准规定,迭代器的基本操作不能抛出异常。这里的所有函数都使用了noexcept,这样可以节省开销(如果涉及异常处理,就需要生成栈展开代码,会造成额外性能开销),若涉及动态内存操作则有可能会抛出异常,此时就不能使用noexcept。
2.构造函数
__list_iterator(__list_node<T>* node)
: node(node) {}
不使用explicit,允许从节点指针隐式构造迭代器。
__list_node<int>* node = ...;
__list_iterator<int> it = node; // 若使用 explicit 则禁止隐式转换
这里的参数不使用const, 因为链表需要通过迭代器来操作节点,进行删除插入等操作,所以不能是const。
__list_iterator(const __list_iterator<T>& it)
: node(it.node) {}
不使用explicit,允许迭代器隐式复制。
__list_iterator<int> it1 = ...;
__list_iterator<int> it2 = it1; // 若使用 explicit 则禁止隐式转换
使用const,确保拷贝过程中不修改迭代器。
3.析构函数
iterator不需要析构函数,因为迭代器不具有节点的所有权,也不进行内存的分配,所以不需要特殊清理,应该交由list实现。在这里实现析构函数反而是一个危险的操作。
4.operator*与operator->的重载
inline T& operator*() const noexcept { return (*node).data; }
inline T* operator->() const noexcept { return &(operator*()); }
如果要获取iterator指向的节点直接使用it.node(结构体中的成员默认为public),如果要获取iterator指向节点所存储的数据,则使用*it
使用迭代器访问容器中元素的成员时有两种方法:(*it).member或it->member
C++规定->运算符会递归调用,直到返回一个原始指针,然后通过该指针访问成员。
这里的operator->调用了operator*,在外部调用it->value
等价于(it.operator->())->value
等价于&(*it)->value(对(*node).data取地址,获得的是node中data(T)类型的指针)
如果data(T)是Foo类型,那么就相当于*Foo->value
为什么operator->返回的是一个指针而不是引用?
因为只有指针或重载了operator->的类能使用->,如果返回的是引用类型的话会报错。
5.operator++与operator--的重载
//++iterator
inline __list_iterator<T>& operator++() noexcept{
node = node->next;
return *this;
}
//iterator++
inline __list_iterator<T> operator++(int) noexcept{
__list_iterator<T> tmp = *this;
//这里调用拷贝构造函数
++*this;
//调用了operator++重载,也就是node = node -> next
//后面的operator--同理,node = node -> prev
return tmp;
}
这里的return *this解引用得到迭代器本身,而不会调用operator*重载。因为this是一个指针,*this是对指针解引用,而不是对对象使用operator*,只有当*it时才会调用我们重载的operator*。
为什么前置++返回的是引用,后置++返回的是值?
C++不允许对后置++进行链式调用
++++i; 相当于 ++(++i); 合法
i++++; 相当于 (i++)++; 非法
因为后置++返回的是一个临时的副本,即右值,我们无法修改一个右值。
6.operator==与operator!=的重载
inline bool operator==(const __list_iterator<T>& it) const noexcept {
return it.node == this.node;
}
inline bool operator!=(const __list_iterator<T>& it) const noexcept {
return it.node != this.node;
}
注意这里使用it.node来获取迭代器的节点进行比较,对于普通指针,p1 == p2 比较的是它们是否指向同一内存地址,链表迭代器的 == 运算符同样比较底层指针,正确模拟了指针的行为。不使用it->node,因为iterator是一个结构体,可以通过it.node直接获取(struct成员默认public)。如果这里使用it->node就变成了irerator.node->node。
二、list_node
“list_node.h”
#pragma once
template<typename T>
struct list_node {
list_node* prev;
list_node* next;
//在模板类的定义内部,类名本身(如 list_node)会被视为 list_node<T> 的同义词
T data;
template<typename... Args>
_List_node(Args&&... args)
: data(std::forward<Args>(args)...), prev(nullptr), next(nullptr) {}
};
typename... Args:定义了一个模板参数包,表示零个或多个模板类型参数。允许构造函数接收任意数量、任意类型的参数,用于初始化节点的数据成员 data。
Args&&万能折叠,可以根据参数类型自动绑定到左值或右值。
使用forward完美转发将参数 args 以原始值类别(左值或右值)转发给 data 的构造函数。若参数是右值(如临时对象),转发为右值,可触发移动构造。若参数是左值,转发为左值,触发拷贝构造。
三、list
"list.h"
#pragma once
#include "list_iterator.h"
template<typename T>
class list
{
public:
explicit list() {
init();
}
list(std::initializer_list<T> list) {
init();
for (auto i : list) {
push_back(i); //????????????????
}
}
~list() {
clear();
delete ptr;
}
list_iterator<T> insert(const list_iterator<T>& pos, const T& value) { /该位置后面擦插入,符合后面push_back操作
list_node<T>* new_node = new list_node<T>;
new_node->data = value;
list_node<T>* pos_node = pos.node;
new_node->prev = pos_node->prev;
new_node->next = pos_node;
pos_node->prev->next = new_node;
pos_node->prev = new_node;
++size;
return list_iterator<T>(new_node);
}
list_iterator<T> begin() {
return list_iterator<T>(ptr->next);
}
list_iterator<T> end() {
return list_iterator<T>(ptr);
}
void clear() {
while (!empty()) {
pop_front(); 为什么不删哨兵ptr??????????????????????
}
}
bool empty() {
return ptr->next == ptr;
}
list_iterator<T> erase(const list_iterator<T>& pos) { 为什么要加const????????
list_node<T>* pos_node = pos.node;
list_iterator<T> next_node = list_iterator<T>(pos_node->next);
pos_node->prev->next = pos_node->next;
pos_node->next->prev = pos_node->prev;
delete pos_node;
--size;
return next_node;
}
void pop_front() {
erase(begin());
}
void pop_back() {
erase(--end());
}
void push_front(const T& value) {
insert(begin(), value);
}
void push_back(const T& value) {
insert(end(), value); //这里的链表是环状链表,insert插入的位置是给定位置的后一位,这样子刚好就是在尾部插入
}
void show() {
for (list_iterator<T> it = begin(); it != end();++it) {
std::cout << *it << " ";
}
std::cout << '\n';
}
void show_size() {
std::cout << size << '\n';
}
private:
void init() {
ptr = new list_node<T>; //标准库在这里使用allocator实现
ptr->next = ptr->prev = ptr;
size = 0;
}
list_node<T>* ptr; //环形链表的哨兵
unsigned long long size;
};
1.构造函数
explicit list() {
init();
}
void init() {
ptr = new list_node<T>; //标准库在这里使用allocator实现
ptr->next = ptr->prev = ptr;
size = 0;
}
这里的ptr->next = ptr->prev = ptr实际上就是ptr->next = ptr,ptr->prev = ptr。将ptr的前驱指针和后继指针全部指向自己,形成了一个环状的空链表。统一了链表的插入,删除,遍历等操作,不需要再对空链表进行额外的处理。注意,该ptr哨兵并不属于容器中的一部分(size=0),只是作为连接头尾指针的一个桥梁,在后续对链表进行操作的时候大有用处。
explicit list(std::initializer_list<T> list) {
init();
for (auto i : list) {
push_back(i);
}
}
为什么使用explicit?
禁止链表进行隐式转换。但对于上面的默认构造函数是多余的,因为默认构造函数不会触发隐式转换。
list<int> a = {1, 2, 3}; // 拷贝初始化,需要隐式转换
list<int> a{1, 2, 3}; // 直接初始化,无论是否有 explicit 都合法
使用后禁止第一种初始化方式,但是在标准库中是允许的,一般在标准库容器的初始化列构造函数中不使用explicit。
push_back(i);
使用push_back而不是push_front,确保元素存入容器的顺序与初始化列表一致。
2.析构函数
~list() {
clear();
delete ptr;
}
void clear() {
while (!empty()) {
pop_front();
}
}
void pop_front() {
erase(begin());
}
一直弹出(删除)第一个元素直至链表为空(这里使用push_back也是一样的效果),最后删除哨兵节点。
3.insert()
list_iterator<T> insert(const list_iterator<T>& pos, const T& value) {
list_node<T>* new_node = new list_node<T>;
new_node->data = value;
list_node<T>* pos_node = pos.node;
new_node->prev = pos_node->prev; //设置新节点的前驱
new_node->next = pos_node; //设置后驱
pos_node->prev->next = new_node; //更新前驱节点的后继
pos_node->prev = new_node; //更新位置节点的前驱
++size;
return list_iterator<T>(new_node);
}
这里对链表节点操作的顺序不可以变更,顺序大概是先对新节点进行前驱和后继的更新,然后从远端开始更新。
如果害怕写错可以使用临时变量来储存:
list_node<T>* prev_node = pos_node->prev; // 保存前驱节点
new_node->prev = prev_node;
new_node->next = pos_node;
prev_node->next = new_node; // 使用临时变量,避免依赖修改后的 pos_node->prev
pos_node->prev = new_node;
这里返回值是list_iterator<T>,因为返回的list_iterator<T>(new_node)是一个临时变量,无法使用引用传值。
为什么在这里的参数要加const?
因为后面在push_front()调用了insert()
void push_front(const T& value) {
insert(begin(), value);
}
传入的是一个临时变量begin()(右值),无法对其进行引用,加上const代表对其不进行修改(实际也没有进行修改),这样insert的参数就可以任意传入左值或右值,后面的erase同理。
这里的insert插入的位置是指定位置的后一位,这样写的作用会在后面push_back体现。
4.erase()
list_iterator<T> erase(const list_iterator<T>& pos) {
list_node<T>* pos_node = pos.node;
list_iterator<T> next_node = list_iterator<T>(pos_node->next);
pos_node->prev->next = pos_node->next;
pos_node->next->prev = pos_node->prev;
delete pos_node;
--size;
return next_node;
}
erase对节点的操作顺序可以交换,不会产生影响。
delete pos_node和delete pos.node是一样的,因为它们都指向了同一个节点。
5.begin()与end()
list_iterator<T> begin() {
return list_iterator<T>(ptr->next);
}
list_iterator<T> end() {
return list_iterator<T>(ptr);
}
之前有提到过,ptr是连接头尾指针的桥梁,使用begin()就能获取头指针,使用--end()就能获取尾指针。
为什么end()要返回一个不属于容器中的元素ptr?
因为标准库中的容器都是左闭右开区间,使用for(iterator it = begin();it != end();++it)就可以正确遍历一个容器。
6.push_back()
void push_back(const T& value) {
insert(end(), value);
}
这里的链表是环状链表,insert插入的位置是给定位置的后一位,这样子刚好就是在尾部插入。
四、测试与测试结果
#include "list.h"
using namespace std;
int main() {
list<int> a{1,2,3};
list_iterator<int> it = a.begin();
a.show();
//1 2 3
a.push_back(4);
a.show();
//1 2 3 4
a.push_front(11);
a.show();
//11 1 2 3 4
advance(it, 3);
a.insert(it,10);
a.show();
//11 1 2 3 10 4
a.pop_front();
a.show();
//1 2 3 10 4
a.pop_back();
a.show();
//1 2 3 10
a.show_size();
//4
return 0;
}
{1,2,3}会自动生成为一个初始化列表,传入list(std::initializer_list<T> list)进行初始化。在调用到insert()中的new list_node<T>时会调用list_node的构造函数,prev和next初始化为nullptr,而类型T的data根据左值或右值进行对应的构造。