摘要:本文从零开始模拟实现 C++ STL 中的
list容器,核心内容包括:哨兵头节点的循环双向链表结构、通过三个模板参数统一 iterator 与 const_iterator 的设计技巧、insert/erase的底层实现与复用,以及 list 与 vector 在迭代器失效问题上的本质区别。全文以"先写一版,发现问题,再优化"的演进思路展开,适合学完类和对象、模板基础后想要进阶的读者。
一、list 和 vector 有什么不同
上一篇文章我们模拟实现了 vector——它的底层是一块连续堆内存,T* 就是天然的迭代器。轮到 list,事情就完全不一样了。
list 是双向链表。每个元素是一个独立的节点,挂在堆上,节点之间用指针串起来。这带来了两个根本性的变化:
- 插入删除不用搬数据——不需要像 vector 那样把后面的元素全部前移或后移,改两根指针就行。
- 迭代器不能用原生指针——
Node*做++做不到跳到下一个节点,除非你重载operator++。但原生指针的类型你能改吗?不能。
所以模拟实现 list 的关键战场不在 push_back、insert 这些操作本身——它们的链表逻辑非常直观。核心难点在于迭代器的设计。这也是为什么 STL 里 list 的源码,迭代器部分占了大头。
本文代码在 VS2022 下编写编译,文中出现的代码片段来自实际的学习过程——包括那些被注释掉的"第一版",它们记录了迭代器设计从笨拙到优雅的演进路径。
二、节点设计:一切从 list_node 开始
链表的最小单元是节点,它要存三样东西:数据、前驱指针、后继指针。
template<class T>
struct list_node
{
T _data;
list_node<T>* _next;
list_node<T>* _prev;
list_node(const T& data = T())
:_data(data)
,_next(nullptr)
,_prev(nullptr)
{}
};
几个细节值得一说:
为什么用 struct 不用 class? 因为后面 list 和 list_iterator 都要直接访问节点的 _data、_next、_prev,用 class 就必须加上 public:,否则默认全部私有,编译的时候一片报错。很多开源库都直接用 struct 定义节点,省得写 public。这是一个小习惯,但踩过坑就记住了。
构造函数默认参数 T()。 list_node<T> 构造时如果不传参数,会用 T() 作为默认值。对于 int,int() 就是 0;对于 string,string() 就是空串。这保证了泛型代码下内置类型和类类型的行为一致。
注意:构造函数把
_next和_prev都初始化为nullptr,但真正使用时链表会立刻把节点的前后指针接好,所以这个nullptr只是"出厂设置",不参与运行时的逻辑。
三、迭代器:整个 list 实现中最值得讲的部分
3.1 为什么必须自己写迭代器类
对 vector 来说,T* 就是迭代器——++p 就是指针偏移,*p 就是解引用。但对 list 来说:
list_node<int>* p = ...;
++p; // p 指针自己加 1,指向了不相干的内存,根本不是"下一个节点"
要让 ++ 的行为变成 p = p->_next,就必须把节点指针包在一个类里,重载 operator++。这就是 list_iterator 存在的根本原因。
3.2 第一版:普通迭代器和 const 迭代器各写一份
最直观的思路是写两个类:list_iterator(可读写)和 list_const_iterator(只读)。代码大约长这样:
// 普通迭代器
template<class T>
struct list_iterator
{
Node* _node;
T& operator*() { return _node->_data; }
Self& operator++() { _node = _node->_next; return *this; }
// ...--, !=, ++(int), --(int)
};
// const 迭代器
template<class T>
struct list_const_iterator
{
Node* _node;
const T& operator*() { return _node->_data; }
Self& operator++() { _node = _node->_next; return *this; }
// ...--, !=, ++(int), --(int)
};
两个类除了 operator* 和 operator-> 的返回类型不同(T& vs const T&、T* vs const T*),其余代码完全一模一样。这是典型的"代码重复"——违反了 DRY 原则。
为了避免写出这样 80% 雷同的代码,我们用一记妙招来解决:用模板参数来区分。这是一个多震撼的时刻!模板不只是用来让容器支持不同类型的元素的,还可以用来描述接口的读写属性。
3.3 第二版:三个模板参数,一份代码兼容两种迭代器
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* node)
:_node(node)
{}
Node* _node;
Ref operator*() const
{
return _node->_data;
}
Ptr operator->() const
{
return &_node->_data;
}
// ...
};
然后在 list 类里用 typedef 实例化出两种迭代器:
template<class T>
class list
{
typedef list_node<T> Node;
public:
typedef list_iterator<T, T&, T*> iterator;
typedef list_iterator<T, const T&, const T*> const_iterator;
};
这招妙在哪? 编译期就把所有区别消化掉了:
iterator的Ref=T&,operator*返回T&,可读可写。const_iterator的Ref=const T&,operator*返回const T&,只读。Ptr同理——T*vsconst T*。
一份迭代器代码,两种行为,全在 typedef 那一行决定。这就是 C++ 编译期多态的威力——没有虚函数、没有运行时开销,却实现了接口的灵活切换。
易错点①:
operator->返回的是地址,不是值。_node->_data是节点里存的数据本身(类型T),但operator->应该返回指针,所以必须写成&_node->_data。第一次写很容易手滑漏掉&,编译器报"无法将 T 转换为 T*"——你忘了取地址。
3.4 前置 ++/-- 和后置的区别
// 前置 ++it
Self& operator++()
{
_node = _node->_next;
return *this; // 返回自增后的自己(引用)
}
// 后置 it++
Self operator++(int)
{
Self tmp(*this); // 保存自增前的副本
_node = _node->_next; // 自身前进
return tmp; // 返回旧副本(值,不是引用)
}
// 前置 --it
Self& operator--()
{
_node = _node->_prev;
return *this;
}
// 后置 it--
Self operator--(int)
{
Self tmp(*this);
_node = _node->_prev;
return tmp;
}
两张表说清楚区分:
前置 ++it | 后置 it++ | |
|---|---|---|
| 返回类型 | Self&(自身引用) | Self(旧副本,按值返回) |
| 核心逻辑 | 前进 → 返回 *this | 保存 tmp → 前进 → 返回 tmp |
| 能否链式 | ++++it 可以 | it++++ 不行(右值) |
前置 --it | 后置 it-- | |
|---|---|---|
| 返回类型 | Self& | Self |
| 核心逻辑 | 后退 → 返回 *this | 保存 tmp → 后退 → 返回 tmp |
易错点②:后置
++返回Self&(加了个&)是高频错误。 如果返回引用,那引用绑定到谁?绑定到tmp?tmp是栈上的局部变量,函数结束就没了——悬空引用。绑定到*this?那返回的是前进之后的状态,跟前置++没有任何区别了。所以后置必须返回值,多一次拷贝,但行为正确。标准库也是这么设计的——这也是为什么建议优先用前置++,少一次拷贝。
3.5 一个容易被忽略的点:迭代器不需要深拷贝
迭代器不需要深拷贝,需要的就是浅拷贝。因为迭代器里面放的是指向资源的指针,而资源不属于迭代器所有。
这个理解很重要。list_iterator 只持有 Node* _node,拷贝迭代器时只拷贝这个指针——两个迭代器指向同一个节点,完全没问题。迭代器不拥有节点的所有权,不负责释放。所以编译器默认生成的拷贝构造和赋值就已经完全正确了。
这一点和 vector 里说的"深浅拷贝"问题正好形成对比:vector 本身拥有资源,必须深拷贝;迭代器不拥有资源,浅拷贝就是正确答案。 什么时候写拷贝构造、什么时候不写,判断标准就是"你到底有没有拥有那块资源"。
四、list 类:哨兵头节点的循环双向链表
4.1 哨兵头节点——一个不存数据的"看门人"
list 的底层结构不是"一条线段",而是一个环。环的入口是一个特殊的节点——哨兵头节点(sentinel node),它不存有效数据,只起标记作用。
void empty_init()
{
_head = new Node(T()); // 头节点,它的 _data 没有实际意义
_head->_next = _head; // 自己指向自己,形成环
_head->_prev = _head;
_size = 0;
}
刚创建的空链表长这样:
┌──────────────┐
│ _head │
│ (哨兵节点) │
│ _next ───────┼───┐
│ _prev ───────┼─┐ │
└──────────────┘ │ │
↑ ↓ ↓
└─────────────┘─┘
自己指自己,一个环
插入了 3 个元素之后:
_head ⇄ node1 ⇄ node2 ⇄ node3 ⇄ _head
↑ ↑
end() begin()
有了这个结构,很多操作变得极其简洁:
- 第一个元素 =
_head->_next - 最后一个元素 =
_head->_prev - 链表为空 =
_head->_next == _head(自己指自己)
哨兵头节点的存在消除了大量空链表的特殊判断——push_back 在空链表上也不需要区分"有没有第一个节点",因为始终有一个 _head 作为锚点。
4.2 begin 和 end
iterator begin()
{
return _head->_next; // 头节点的下一个,即第一个有效元素
}
iterator end()
{
return _head; // 头节点本身,不存数据,只做终止标记
}
const_iterator begin() const
{
return _head->_next;
}
const_iterator end() const
{
return _head;
}
begin() 返回第一个有效元素,end() 返回哨兵头节点自身。这形成了一个左闭右开的区间 [begin, end)——遍历时到了 end() 就停,end() 本身不参与解引用。
const 版本的重载让 print_container(const container& con) 这种函数能正常工作——它只能调用 const 版的 begin() 和 end(),拿到的是 const_iterator,不允许修改容器内容。
4.3 构造函数全家桶
// 默认构造
list()
{
empty_init();
}
// 拷贝构造
list(const list<T>& lt)
{
empty_init(); // 先搭好空壳
for (auto& e : lt)
{
push_back(e); // 逐个拷进来
}
}
// initializer_list 构造——支持 {1,2,3,4,5} 语法
list(initializer_list<T> il)
{
empty_init();
for (auto& e : il)
{
push_back(e);
}
}
拷贝构造必须先 empty_init() 再逐个 push_back——不能直接把 _head 指向源链表的头节点,不然两个 list 共享同一串节点,析构时 double free。这和 vector 的深浅拷贝问题本质相同,只是表现形式不同。
initializer_list 构造让我们能用 list<int> lt = {1,2,3,4,5} 来初始化,和标准库保持一致。
4.4 析构和清理
void clear()
{
auto it = begin();
while (it != end())
{
it = erase(it); // erase 返回下一个有效迭代器
}
}
~list()
{
clear();
delete _head; // 别忘了头节点
_head = nullptr;
}
clear() 遍历删除所有有效节点(不包括头节点),~list() 在 clear() 的基础上再删除头节点本身。顺序不能错——先删数据节点,最后才删哨兵。
4.5 赋值运算符
void swap(list<T>& lt)
{
std::swap(_head, lt._head);
std::swap(_size, lt._size);
}
list<T>& operator=(list<T> lt) // 按值传递,自动拷贝
{
swap(lt); // 交换内部指针
return *this; // lt 析构时带走旧资源
}
这里用了和上一篇 vector 一样的 copy-and-swap 惯用法。参数按值传递,调用时触发拷贝构造生成了 lt,然后直接 swap 交换两个对象的内部数据,函数结束时形参 lt 析构,自动释放旧链表的所有节点。不需要手动 if (this != <) 判断自赋值,也天然异常安全。
易错点③:
swap交换的是指针,不是链表中的所有节点。 很多人第一反应是"交换两个链表"就需要遍历所有节点、逐个拷过去。实际上只需要交换_head和_size,两个变量的值一换,整个链表的所有权就转移了——这就是指针的威力。
五、增删操作:insert 和 erase 是两大基石
你会发现 push_back、push_front、pop_back、pop_front 的代码都非常短——因为它们全是在调 insert 和 erase。这是一种"分层复用"的设计思路。
5.1 insert——在指定位置前插入
void insert(iterator pos, const T& x)
{
Node* cur = pos._node; // pos 指向的节点
Node* prev = cur->_prev; // pos 的前一个节点
Node* newnode = new Node(x);
// 四步接线:prev ⇄ newnode ⇄ cur
newnode->_next = cur;
cur->_prev = newnode;
prev->_next = newnode;
newnode->_prev = prev;
++_size;
}
图解一下这四步接线:
插之前: prev ⇄ cur
插之后: prev ⇄ newnode ⇄ cur
先把将要插入的节点的前后位置用prev和cur记录下来,就不用担心先改 prev->_next会丢掉 cur 的入口。此时四条赋值语句之间没有依赖关系,顺序无所谓,只要四条都写上就对了。
push_back 和 push_front 不过是 insert 的"快捷方式":
void push_back(const T& x)
{
insert(end(), x); // 在 end()(即 _head)前面插入 → 相当于尾插
}
void push_front(const T& x)
{
insert(begin(), x); // 在 begin() 前面插入 → 相当于头插
}
这就是哨兵头节点带来的红利——push_back 在空链表上工作也不需要特殊处理,因为 end() 始终返回 _head,_head->_prev 又绕回来指到自己。
易错点④:list 的 insert 不会导致任何迭代器失效。 因为链表节点是独立分配的,插入新节点只是在堆上新开辟一块内存、改几根指针的指向。原来所有节点的地址完全不变,指向它们的迭代器当然也不受影响。这一点和 vector 完全不同,在第六节会详细对比。
5.2 erase——删除并返回下一个
iterator erase(iterator pos)
{
assert(pos != end()); // 头节点不能删
Node* prev = pos._node->_prev;
Node* next = pos._node->_next;
prev->_next = next; // prev 绕过 pos 直接指向 next
next->_prev = prev;
delete pos._node; // 释放节点内存
--_size;
return next; // 返回被删节点的下一个
}
erase 返回 iterator(next) 非常重要——它指向被删除节点的下一个位置。调用方拿到这个返回值,就能在遍历删除时正确前进:
// 删除所有偶数——正确的写法
list<int>::iterator it = lt.begin();
while (it != lt.end())
{
if (*it % 2 == 0)
{
it = lt.erase(it); // erase 返回下一个,不需要再 ++it
}
else
{
++it;
}
}
pop_back 和 pop_front 同样复用 erase:
void pop_back()
{
erase(--end()); // end() 是 _head,--end() 就是最后一个有效节点
}
void pop_front()
{
erase(begin());
}
六、迭代器失效:list vs vector
这是面试和笔试中的高频考点。一句话总结:list 只有被删除的那个元素的迭代器会失效,其余全部安全;vector 则广泛得多。
| 操作 | vector | list |
|---|---|---|
insert | 若触发扩容,所有迭代器失效;未扩容,插入位置及之后的迭代器失效 | 永不失效 |
erase | 被删元素及之后的所有迭代器失效 | 只有被删元素的迭代器失效 |
push_back | 若触发扩容,全部失效 | 永不失效 |
这个差异的根源在于底层的物理结构:
- vector 是连续内存。扩容时整块内存换了地址,所有指向旧地址的迭代器跟着全部作废。即便是
erase(不换地址),被删元素后面的所有元素都要前移一位,指向这些位置的迭代器自然无效。 - list 是独立节点。每个节点有自己的堆地址,跟其他节点互不干扰。插入新节点不影响旧节点,删除节点只影响被删的那个——前后节点依然在原地,指向它们的迭代器依然有效。
这就是为什么 erase 必须返回 iterator:被删的那个 pos 已经失效了,但你需要知道下一个有效位置在哪,所以返回 next。而 insert 就不需要返回值——调用方传进来的 pos 依然有效(list 中 insert 不失效迭代器)。
七、辅助工具:print_container 的泛型实践
template <class container>
void print_container(const container& con)
{
typename container::const_iterator it = con.begin();
// const 对象只能调 const 版本的 begin/end,返回 const_iterator
for (auto e : con)
{
cout << e << " ";
}
cout << endl;
cout << "----------------" << endl;
}
这个函数不依赖具体类型——传 jys::list<int> 可以,传 std::vector<int> 也可以,只要容器有 const_iterator、begin()、end()。这就是面向接口编程。
注意第一行 typename container::const_iterator 的 typename 不能省——编译器在模板没实例化之前,不知道 container::const_iterator 是类型还是静态成员变量,必须显式声明它是一个类型名。
另外注意参数是 const container&,所以只能调 const 版本的 begin() 和 end()。如果我们的 list 类没有给 begin() 和 end() 写 const 重载,这个函数就编不过——这反过来验证了 const 重载的必要性。
八、易错点全景回顾
| 编号 | 问题 | 后果 | 对策 |
|---|---|---|---|
| ① | list_node 用 class 不加 public: | 所有成员私有,list 和迭代器全报访问错误 | 加 public:,或用 struct |
| ② | operator-> 返回 _data 而非 &_data | 类型不匹配,编译器报错 | 写 &_node->_data |
| ③ | 后置 ++/-- 返回 Self& | 返回局部对象引用,悬空引用 / UB | 改为 Self,按值返回 tmp |
| ④ | erase 后不用返回值 | 迭代器失效后继续使用,跳过元素或崩溃 | 始终 it = lt.erase(it) |
| ⑤ | list 的 insert 误以为迭代器会失效 | 写了类似 vector 中的 pos 重新定位代码 | list 的 insert 不需要——直接传 pos._node 就行 |
| ⑥ | 忘记给 begin()/end() 写 const 重载 | const 引用无法遍历,print_container 编不过 | iterator 和 const_iterator 各一套 begin/end |
九、写在最后
写完 list,再回头看上一篇的 vector,你会发现这两个容器几乎是"互为镜像"的存在:
- vector 的优势是随机访问 O(1),代价是中间插入删除要搬数据。
- list 的优势是任意位置插入删除 O(1),代价是访问第 n 个元素要顺着指针走 n 步。
从实现角度看,vector 的挑战在容量管理(reserve / resize / 扩容策略),list 的挑战在迭代器设计。而这两个挑战背后的 C++ 核心知识——RAII、模板、运算符重载、编译器生成函数的规则、深浅拷贝——恰恰是同一个知识体系的不同侧面。
如果你能把 vector 和 list 都从头写一遍,理解为什么每一行代码那样写,C++ 的类和对象、模板、内存管理这三个重要知识点基本就掌握了。
完整代码可以在学习仓库中找到。文中保留了注释掉的"第一版"迭代器代码——这段演进的历史比一份完美的最终版更有学习价值,它记录了一个关键的思维跃迁:当代码出现大量重复时,模板参数可能是更好的抽象手段,而不只是加一个类。
9451



被折叠的 条评论
为什么被折叠?



