一、知识点引入
用数组存数据,插入一个元素要把后面所有元素往后挪一格;删一个元素要往前挪一格。挪动的代价和数据量成正比,规模一大就顶不住。链表换了个思路:元素不要求在内存里挨着,每个元素自己带路,记着下一个和上一个在哪。插入删除只改几根指针,元素本身一动不动。
代价也清楚:想找第 n 个元素,只能从头一个个数过去,没有下标直达。链表是用随机访问换插入删除,这笔账要先算明白再动手。
把这件事落到代码上,会拆成四个具体问题:节点长什么样,空表怎么表示,头尾两端的操作要不要单独写一套,迭代器怎么像指针一样用。这四个问题对应的就是节点结构、哨兵头、insert/erase 的收敛设计、迭代器封装,也是整份实现的骨架。
编译口径 g++ -std=c++98 -pedantic -Wall -Wextra
二、核心概念梳理
先摆一张表,把后面反复出现的概念一次说清,标了重要度的就是重点和难点。
| 概念 | 一句话定义 | 关键点 | 重要度 |
|---|---|---|---|
节点 list_node |
数据域 + 前驱指针 + 后继指针 | 每个节点单独 new,地址互不相邻 |
重点 |
哨兵头 _head |
不存有效数据的固定节点 | 让空表、头插、尾插都不必特判 | 重点·难点 |
| 双向循环 | 尾节点的 _next 指回头,头节点的 _prev 指向尾 |
从任意节点出发都能走遍全表 | 重点 |
| 迭代器 | 对 Node* 的薄包装 |
只重载 *、->、++、--、==、!= |
难点 |
| const 迭代器 | 独立类型,解引用返回 const T& |
不能拿 typedef const iterator 顶替 |
难点 |
insert / erase |
改两根邻居指针 + new / delete 一个节点 |
不搬移任何元素,代价与元素个数无关 | 重点 |
size() |
从头数到尾 | O(n),因为成员里没存计数 | 易错 |
| 迭代器失效 | erase 掉的那个失效,insert 不影响已有迭代器 |
与 vector 的全域失效完全不同 | 难点 |
三个需要先记住的结论:
结论一 ,list 对象本身只占一个指针的空间,实测 sizeof(byte::list<int>) == 8。所有元素都在堆上零散分布,对象里存的是进入这张网的入口。
结论二 ,节点比元素本身大得多。实测 sizeof(byte::list_node<int>) == 24,而 sizeof(int) == 4:为了存一个 4 字节的整数,额外花了 20 字节装两根指针和对齐填充。链表的空间开销是实打实的。
结论三 ,end() 返回的不是尾元素,而是哨兵头。这个设计贯穿全篇,也是这份实现里最省代码的一处取舍。
三、双向链表整体结构
把链表想成一条环形跑道:每个节点是一个站点,站点的牌子正面写着数据,两侧各有一个箭头,一个指向前一站,一个指向后一站。最后一站的"下一站"箭头指向检录处,检录处的"上一站"箭头指向最后一站,于是整条路首尾相接,没有断口。
检录处就是 _head。它自己也占一个站点的位置,但牌子上不写数据,只负责让这条路闭合。有了它,"空表"和"非空表"在指针层面长得一样:空表时检录处的两个箭头都指向自己。
text
_head(哨兵,不存数据)
+---------------------------+
| |
v |
+---------+ +---------+ +---------+
| node1 |<->| node2 |<->| node3 |
| data | | data | | data |
+---------+ +---------+ +---------+
^ |
+---------------------------+
三个指针在结构里的落点很固定:begin() 取 _head->_next,end() 直接返回 _head。于是遍历的写法 for (it = begin(); it != end(); ++it) 在空表上也成立------空表时 begin() 和 end() 都是 _head,循环一次都不进。
双向循环链表的整体结构
四、节点结构拆解
节点是三段式:一段存数据,两段存地址。
cpp
template<class T>
struct list_node
{
T _data;
list_node<T>* _next;
list_node<T>* _prev;
list_node(const T& x = T())
: _data(x)
, _next(NULL)
, _prev(NULL)
{}
};
在 64 位环境下,int 节点排出来是 24 字节:_data 占 4 字节,后面跟 4 字节填充(为了让两个指针按 8 字节对齐),_next 和 _prev 各 8 字节。换成 std::string 元素,节点变成 40 字节,因为 std::string 自己就占 24 字节,再加 4 字节填充和两根指针。
这个数字值得记住:链表省下的是搬移时间,付出的是每个元素多出两根指针的常驻开销。节点里存 int 时,有效载荷只占六分之一。
配图 2|单个节点的三段式布局
节点构造函数给了默认参数 x = T(),这份便利有代价:new Node 不带参数时,要求 T 能默认构造。这个坑放在第十节第 9 条。
五、中间元素插入
插入的动作可以拆成"接线"和"入网"两个动作。新节点先把自己的两根指针接好,再由两个邻居把指针改指向它。四行赋值,顺序有讲究。
设 pos 是要插入位置的迭代器,cur = pos._node,prev = cur->_prev:
cpp
Node* cur = pos._node;
Node* prev = cur->_prev;
Node* newnode = new Node(x); // 数据顺手拷进新节点
prev->_next = newnode; // ① 前驱的 next 改指新节点
newnode->_prev = prev; // ② 新节点的 prev 接前驱
newnode->_next = cur; // ③ 新节点的 next 接后继
cur->_prev = newnode; // ④ 后继的 prev 接新节点
真正不可省的只有一句:prev 必须在动手改链表之前取出来存好。因为第 ④ 行一执行,cur->_prev 就变成新节点了,此后再想从 cur 反推前驱,拿到的是新节点,整条链接会断。
至于 ①②③④ 之间,只要 prev 和 cur 都是局部变量,顺序互换不会立刻出错。但 ④ 放最后仍是好习惯:它一执行,cur 的前驱信息就覆盖了,把"毁掉信息"的动作留到最后,后面加代码时不容易踩空。
配图 3|在 pos 前插入一个节点
插入完成后,pos 指向的那个节点没有移动,地址也没变,它的前驱换成了新节点。这一点决定了 insert 之后旧迭代器依然可用,第六节会把这件事和 erase 对比。
insert 返回新节点的迭代器,这个返回值在连续插入时有用;不用返回值也不影响链表状态。
六、元素删除
删除是反向操作:先把两个邻居接上,再把中间的节点拆掉。
cpp
Node* cur = pos._node;
Node* prev = cur->_prev;
Node* next = cur->_next;
prev->_next = next; // ① 前驱跨过 cur,直接指后继
next->_prev = prev; // ② 后继跨过 cur,直接指前驱
delete cur; // ③ 节点出网,内存归还
顺序的硬约束在这里更明显:next 必须在 delete cur 之前存下来。一旦 cur 被释放,cur->_next 就是读已释放内存,之后再想拿后继只能靠运气。
erase 的返回值是 next,也就是被删节点的后继。这个设计不是随手定的:调用方手上的 pos 在 delete 之后已经悬空,如果不给一个新迭代器,调用方就没法继续往下走。clear 就是靠这个返回值转起来的。
配图 4|删除 pos 处的节点
删除之后,只有指向被删节点的那个迭代器失效,其余迭代器照常可用。这一点和 vector 完全不同:vector 扩容时所有迭代器一起失效,list 只损失被删的那一个。
七、完整代码
头文件保持原实现的骨架:list_node、两个迭代器、list 三块,补齐了原文件里缺的 insert、erase、push_front、pop_front、pop_back、clear、size、拷贝控制,并把析构函数从注释状态恢复成真正会释放节点的版本。nullptr 换成 NULL,#pragma once 换成条件编译守卫。普通迭代器的 operator* 与 operator-> 补上了 const 成员限定,动机与实测在 9.4。
cpp
#ifndef __LIST_98_H__
#define __LIST_98_H__
#include <stddef.h>
#include <assert.h>
namespace byte
{
template<class T>
struct list_node
{
T _data;
list_node<T>* _next;
list_node<T>* _prev;
list_node(const T& x = T())
: _data(x)
, _next(NULL)
, _prev(NULL)
{}
};
template<class T>
struct __list_iterator
{
typedef list_node<T> Node;
Node* _node;
__list_iterator(Node* node)
: _node(node)
{}
T& operator*() const
{
return _node->_data;
}
T* operator->() const
{
return &_node->_data;
}
__list_iterator<T>& operator++()
{
_node = _node->_next;
return *this;
}
__list_iterator<T> operator++(int)
{
__list_iterator<T> tmp(*this);
_node = _node->_next;
return tmp;
}
__list_iterator<T>& operator--()
{
_node = _node->_prev;
return *this;
}
__list_iterator<T> operator--(int)
{
__list_iterator<T> tmp(*this);
_node = _node->_prev;
return tmp;
}
bool operator!=(const __list_iterator<T>& it) const
{
return _node != it._node;
}
bool operator==(const __list_iterator<T>& it) const
{
return _node == it._node;
}
};
template<class T>
struct __list_const_iterator
{
typedef list_node<T> Node;
Node* _node;
__list_const_iterator(Node* node)
: _node(node)
{}
__list_const_iterator(const __list_iterator<T>& it)
: _node(it._node)
{}
const T& operator*() const
{
return _node->_data;
}
const T* operator->() const
{
return &_node->_data;
}
__list_const_iterator<T>& operator++()
{
_node = _node->_next;
return *this;
}
__list_const_iterator<T> operator++(int)
{
__list_const_iterator<T> tmp(*this);
_node = _node->_next;
return tmp;
}
__list_const_iterator<T>& operator--()
{
_node = _node->_prev;
return *this;
}
__list_const_iterator<T> operator--(int)
{
__list_const_iterator<T> tmp(*this);
_node = _node->_prev;
return tmp;
}
bool operator!=(const __list_const_iterator<T>& it) const
{
return _node != it._node;
}
bool operator==(const __list_const_iterator<T>& it) const
{
return _node == it._node;
}
};
template<class T>
class list
{
typedef list_node<T> Node;
public:
typedef __list_iterator<T> iterator;
typedef __list_const_iterator<T> const_iterator;
list()
{
empty_init();
}
list(const list<T>& lt)
{
empty_init();
for (const_iterator it = lt.begin(); it != lt.end(); ++it)
{
push_back(*it);
}
}
list<T>& operator=(list<T> lt)
{
swap(lt);
return *this;
}
~list()
{
clear();
delete _head;
_head = NULL;
}
iterator begin()
{
return iterator(_head->_next);
}
iterator end()
{
return iterator(_head);
}
const_iterator begin() const
{
return const_iterator(_head->_next);
}
const_iterator end() const
{
return const_iterator(_head);
}
size_t size() const
{
size_t n = 0;
for (const_iterator it = begin(); it != end(); ++it)
{
++n;
}
return n;
}
bool empty() const
{
return _head->_next == _head;
}
void push_back(const T& x)
{
insert(end(), x);
}
void push_front(const T& x)
{
insert(begin(), x);
}
void pop_back()
{
erase(--end());
}
void pop_front()
{
erase(begin());
}
iterator insert(iterator pos, const T& x)
{
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 iterator(newnode);
}
iterator erase(iterator pos)
{
assert(pos != end());
Node* cur = pos._node;
Node* prev = cur->_prev;
Node* next = cur->_next;
prev->_next = next;
next->_prev = prev;
delete cur;
return iterator(next);
}
void clear()
{
iterator it = begin();
while (it != end())
{
it = erase(it);
}
}
void swap(list<T>& lt)
{
Node* tmp = _head;
_head = lt._head;
lt._head = tmp;
}
private:
void empty_init()
{
_head = new Node;
_head->_next = _head;
_head->_prev = _head;
}
Node* _head;
};
}
#endif
测试程序覆盖增删改查、正反向遍历、拷贝、赋值、自赋值、清空,以及存 std::string 的情况。
cpp
#include "list.h"
#include <iostream>
#include <string>
int main()
{
std::cout << std::boolalpha;
byte::list<int> lt;
lt.push_back(1);
lt.push_back(2);
lt.push_back(3);
lt.push_back(4);
std::cout << "push_back 后: size=" << lt.size() << " empty=" << lt.empty() << std::endl;
for (byte::list<int>::iterator it = lt.begin(); it != lt.end(); ++it)
{
std::cout << *it << " ";
}
std::cout << std::endl;
byte::list<int>::iterator pos = lt.begin();
++pos;
++pos;
lt.insert(pos, 99);
std::cout << "在下标 2 的位置插入 99: ";
for (byte::list<int>::iterator it = lt.begin(); it != lt.end(); ++it)
{
std::cout << *it << " ";
}
std::cout << " | size=" << lt.size() << std::endl;
pos = lt.begin();
++pos;
lt.erase(pos);
std::cout << "删掉第二个元素: ";
for (byte::list<int>::iterator it = lt.begin(); it != lt.end(); ++it)
{
std::cout << *it << " ";
}
std::cout << " | size=" << lt.size() << std::endl;
lt.push_front(0);
lt.pop_back();
std::cout << "push_front(0) 再 pop_back: ";
for (byte::list<int>::iterator it = lt.begin(); it != lt.end(); ++it)
{
std::cout << *it << " ";
}
std::cout << std::endl;
std::cout << "倒着走一遍: ";
byte::list<int>::iterator rit = lt.end();
--rit;
while (rit != lt.begin())
{
std::cout << *rit << " ";
--rit;
}
std::cout << *rit << std::endl;
byte::list<int> lt2(lt);
lt2.push_back(100);
std::cout << "拷贝后 lt.size=" << lt.size() << " lt2.size=" << lt2.size() << std::endl;
byte::list<int> lt3;
lt3.push_back(7);
lt3 = lt;
std::cout << "赋值后 lt3.size=" << lt3.size() << " 首元素=" << *lt3.begin() << std::endl;
lt.clear();
std::cout << "clear 后 size=" << lt.size() << " empty=" << lt.empty() << std::endl;
byte::list<std::string> ls;
ls.push_back(std::string(30, 'a'));
ls.push_back(std::string("hi"));
std::cout << "string 元素: " << ls.begin()->size() << " " << (*ls.begin()).size() << std::endl;
return 0;
}
编译与运行:
bash
g++ -std=c++98 -pedantic -Wall -Wextra main.cpp -o demo
./demo
输出:
text
push_back 后: size=4 empty=false
1 2 3 4
在下标 2 的位置插入 99: 1 2 99 3 4 | size=5
删掉第二个元素: 1 99 3 4 | size=4
push_front(0) 再 pop_back: 0 1 99 3
倒着走一遍: 3 99 1 0
拷贝后 lt.size=4 lt2.size=5
赋值后 lt3.size=4 首元素=0
clear 后 size=0 empty=true
string 元素: 30 30
八、逐行代码复盘
复盘顺序按"对象从生到死"排:先看节点和迭代器这两个零件,再看链表怎么建、怎么拷、怎么销毁,最后是增删改。
代码结构按职责分四组:节点定义、可变迭代器、const 迭代器、链表本体。四组之间只有单向依赖,节点不认识迭代器,迭代器不认识链表,链表同时用前三个。
8.1 节点为什么用 struct
list_node 用 struct 而不是 class,成员默认公有。这样 iterator 里的 _node->_data、_node->_next 都能直接写,不需要 friend 声明。这是省事的写法,标准库的做法是加 friend 或用公有接口,把封装补回来。
8.2 迭代器里的那个构造函数
cpp
__list_iterator(Node* node)
: _node(node)
{}
没有默认构造,只能拿节点指针初始化。这是有意的:迭代器必须绑在一个确定位置上,凭空造一个空迭代器再去解引用,只会得到野指针。代价是 __list_iterator<T> it; 这种写法编译不过,声明迭代器时必须同时给它初值。
8.3 前置与后置的区别
cpp
__list_iterator<T>& operator++() // 前置:返回引用
{
_node = _node->_next;
return *this;
}
__list_iterator<T> operator++(int) // 后置:返回副本
{
__list_iterator<T> tmp(*this);
_node = _node->_next;
return tmp;
}
区别不在参数那个 int(它只是个占位标记,用来和前置版本区分),而在返回类型。前置返回引用,改的是自己,返回的是自己;后置先留一份旧值,再往前走,返回的是旧位置。所以 ++it 比 it++ 少一次拷贝,循环里优先写前置。
-- 两个版本同理,方向换成 _node->_prev。
8.4 begin 与 end 只差一个指针
cpp
iterator begin() { return iterator(_head->_next); }
iterator end() { return iterator(_head); }
end() 返回哨兵头的迭代器,不是尾元素。遍历结束时 it 停在 _head 上,此时解引用没有意义,只能用来比较。--end() 才回到尾元素,pop_back 就是这么写的。
begin/end 各有一个 const 版本,成对存在不是体力活,重载决议的细节在 9.5 展开。
8.5 构造:一个节点撑起一张空表
cpp
void empty_init()
{
_head = new Node;
_head->_next = _head;
_head->_prev = _head;
}
三行建立一个自环。new Node 用的是带默认参数的构造函数,_data 被初始化成 T()------这就是"元素类型必须可默认构造"的根源。
把三行抽成 empty_init 是为了让拷贝构造复用,C++98 没有委托构造,只能抽函数。
8.6 拷贝构造:逐个 push_back
cpp
list(const list<T>& lt)
{
empty_init();
for (const_iterator it = lt.begin(); it != lt.end(); ++it)
{
push_back(*it);
}
}
先建自己的哨兵头,再把源链表的每个元素复制一份接到自己尾部。这样两个对象各有各的节点,互不影响。
这里用 const_iterator 而不是 iterator,因为 lt 是 const 引用,只能调 const 版的 begin()。
8.7 赋值:传值再交换
cpp
list<T>& operator=(list<T> lt)
{
swap(lt);
return *this;
}
参数按值传,调用时先复制一份副本;交换 _head 之后,副本持有旧数据,函数返回时副本析构,旧节点连带释放。三件事一次做完:释放旧资源、拷贝新数据、自赋值安全。自赋值之所以安全,是因为交换的是两个不同对象(副本和自身),不是同一块内存。
8.8 析构:两件事都要做
cpp
~list()
{
clear(); // 逐个删节点
delete _head; // 最后删哨兵
_head = NULL;
}
clear 只删数据节点,_head 不在它的遍历范围里(遍历条件是 it != end(),而 end() 就是 _head),所以哨兵要单独删。原文件里这段是整块注释掉的,后果见第十节第 2 条。
8.9 insert:四行赋值
前面第五节已经拆过四行赋值的顺序,这里补两点代码层面的细节。
第一,pos 是按值传的迭代器,改它不影响调用方;真正被改的是节点里的指针。
第二,new Node(x) 会把 x 拷贝进节点的 _data。如果 T 是 std::string,这里就是一次真实的字符串拷贝,和 vector 一样无法避免------参数是 const T&,进来就已经是一次引用,构造节点时再拷一次。
8.10 erase:返回值是生命线
cpp
iterator erase(iterator pos)
{
assert(pos != end());
...
delete cur;
return iterator(next);
}
assert(pos != end()) 拦的是最危险的一种调用:pos 是 end() 时,cur 就是哨兵头,删掉它整张表就废了。加上断言后,这种调用在调试版直接崩在出错的那一行,而不是留到后面某次遍历才莫名其妙地挂掉。
删完返回 next,调用方拿到的是后继位置的迭代器,可以继续用。
8.11 四个 push/pop 都是 insert/erase 的包装
cpp
void push_back(const T& x) { insert(end(), x); }
void push_front(const T& x) { insert(begin(), x); }
void pop_back() { erase(--end()); }
void pop_front() { erase(begin()); }
四种操作、两个方向,最后都落到同一个 insert 和 erase 上。这是哨兵头带来的最大收益:尾插不用判断"表是不是空的",头插不用改 _head 本身,代码里没有一处 if (empty()) 的特判。
pop_back 里的 --end() 是在临时对象上做前置自减,得到尾元素的迭代器,再交给 erase 删掉。
8.12 clear、size 与 swap
cpp
void clear()
{
iterator it = begin();
while (it != end())
{
it = erase(it);
}
}
size_t size() const
{
size_t n = 0;
for (const_iterator it = begin(); it != end(); ++it) ++n;
return n;
}
clear 里必须写 it = erase(it),不能写 ++it:erase 之后 it 指向的节点已经被释放,++it 是在读已释放内存。这是 list 里最容易写错的一行循环。
size() 是遍历计数,因为成员里只有一个 _head,没有存元素个数。每次调用都是 O(n),循环条件里反复写 i < lt.size() 会把它变成 O(n²)。
swap 只交换一个指针,代价恒定,也是 operator= 的基础。
8.13 倒着走:--end() 能行,--begin() 不行
cpp
iterator rit = lt.end();
--rit; // 退一格,落在尾元素上
while (rit != lt.begin())
{
std::cout << *rit << " "; // 先处理当前元素
--rit; // 再往后退
}
std::cout << *rit << std::endl; // 首元素进不了循环,单独收一次
--end() 合法,因为 end() 拿到的 _head 前驱确实连着尾节点。但 --begin() 是另一回事:首节点的前驱是 _head,减完落在哨兵上,rit == end() 成立,而这时解引用读到的是哨兵的数据域。实测一个只存了 7 的链表:
text
是否等于 end(): true
解引用得到: 0
0 就是 new Node 时用 T() 初始化出来的默认值。所以倒序遍历要按这个顺序来:先把 rit 退到尾巴,循环条件拿 begin() 兜底,循环体里处理完当前元素再退。首元素恰好卡在条件上,进不了循环,必须在循环后面补一次------这一段漏掉,遍历就会悄悄少一个元素。
C++11 对照(仅拓展) :标准要求
list::size()是常数时间,主流实现为此在对象里多存一个计数,每次插入删除同步加减,并在splice时特殊处理。少一个成员、多一次遍历,这份实现选了前者。
九、核心难点
9.1 哨兵头的价值:把特判清零
不用哨兵头的写法,插入要分三种情况:空表、插在表头、插在中间。三种情况对应三套指针修改,任何一套写漏都会在边界上崩。
用了哨兵头,_head->_prev 永远指向尾节点,_head->_next 永远指向首节点,插入删除只认"前驱"和"后继"两个角色,不需要知道自己在表头还是表尾。8.11 那四个包装函数之所以能写成一行,全部依赖这一点。
代价是多一个节点常驻,以及遍历时不能碰 _head 的数据域。
9.2 为什么不能 typedef 出 const 迭代器
直觉写法是:
cpp
typedef const __list_iterator<T> const_iterator;
编译时一旦用它遍历,报错是:
text
error: cannot increment value of type 'byte::list<int>::const_iterator'
(aka 'const __list_iterator<int>')
原因直接:const __list_iterator<T> 是"本身不可修改的迭代器",++ 会改 _node,于是被拒绝。而需要的是"可以往前走、但不能改所指数据"的迭代器------限制落在解引用上,不在自身上。这两个语义差一层,必须用两个类型分别表达。
所以这份实现写了 __list_iterator 和 __list_const_iterator 两份几乎一样的代码,只有 operator* 的返回类型不同:一个 T&,一个 const T&。
消除这份重复还有一个 C++98 时代的正统做法:SGI STL 把返回类型做成模板参数,__list_iterator<T, Ref, Ptr> 一个模板同时实例化出两种迭代器。代码省了一份,代价是模板参数翻倍、报错更长。这份实现选两份直写,读的时候不用在脑子里代换模板参数。
9.3 两个迭代器之间只能单向转换
两个独立类型带来一个新问题:const_iterator it = lt.begin(); 编译不过,因为 lt 非 const,begin() 返回 iterator,而两个类型之间没有转换路径。原版报错是这样:
text
error: no viable conversion from 'iterator' (aka '__list_iterator<int>')
to 'byte::list<int>::const_iterator'
note: candidate constructor not viable: no known conversion from
'iterator' to 'Node *'
__list_const_iterator 只有一个接 Node* 的构造函数,而 iterator 不是 Node*。补一个转换构造即可:
cpp
__list_const_iterator(const __list_iterator<T>& it)
: _node(it._node)
{}
加上它之后,只读遍历函数就能直接接收普通迭代器,调用方的写法才和标准库一致。
这个转换是单向的 。iterator 可以变成 const_iterator,反过来不行------iterator 这边根本没有接 const_iterator 的构造函数。方向由只读语义决定:把能改的迭代器交给只读上下文,只是收窄权限,安全;把只读迭代器当成能改的用,是在放宽权限,编译器必须拦住。
单向转换顺着比较运算符漏了出来。operator!= 的参数是 const __list_iterator<T>&,同一个比较,两个方向一过一不过:
cpp
if (cit != it) { ... } // 能过:it 隐式转换成 const_iterator
if (it != cit) { ... } // 编译错
B 行的真实报错:
text
t1.cpp:16:12: error: invalid operands to binary expression
('byte::list<int>::iterator' (aka '__list_iterator<int>') and
'byte::list<int>::const_iterator' (aka '__list_const_iterator<int>'))
./list.h:69:8: note: candidate function not viable: no known conversion from
'const_iterator' to 'const __list_iterator<int>' for 1st argument
报错把原因说全了:候选函数只认识 __list_iterator,const_iterator 转不回去。混用两种迭代器比较时,把 const_iterator 放在左边才能过;更省心的做法是同一次遍历只用一种类型,不制造这种不对称。
9.4 const_iterator 和 const iterator 是两回事
三种带 const 的迭代器摆在一张表里,边界立刻清楚:
| 写法 | 读 | 改所指数据 | 挪位置 | 用途 |
|---|---|---|---|---|
iterator it |
可以 | 可以 | 可以 | 读写遍历 |
const_iterator cit |
可以 | 不行 | 可以 | 只读遍历 |
const iterator it |
可以 | 可以 | 不行 | 挪不动的读写迭代器 |
const_iterator 限制的是所指的数据 ,const iterator 限制的是迭代器自身 ,两组限制落在不同的东西上,是正交概念,不是同义词。const iterator 的表现和 int* const 完全一致:指针本身钉死,所指的东西照读写。实测两行:
cpp
const byte::list<int>::iterator it(lt.begin());
*it = 10; // 能过:改的是数据
++it; // 编译错:改的是迭代器自己
B 行的报错:
text
t2.cpp:13:5: error: cannot increment value of type
'const byte::list<int>::iterator' (aka 'const __list_iterator<int>')
这个结论依赖一个前提:operator* 必须是 const 成员。这份实现的初版里它是普通成员函数,于是连 *it = 10 都被拒,报错是 indirection requires pointer operand ('const ...iterator' invalid)------const 对象调不了非 const 成员函数,比"不能挪位置"禁得更宽。修复是把两个取值接口补上 const 限定:
cpp
T& operator*() const
{
return _node->_data;
}
T* operator->() const
{
return &_node->_data;
}
末尾的 const 修饰的是"不修改 _node 这个成员",函数体只读不写,承诺得住。operator++ 和 operator-- 必须改 _node,这个 const 加不上------加不上这件事本身,就是"const iterator 不能挪位置"在类型层面的来源。
const_iterator 那边的 const 链条同理要闭合:operator* 返回 const T&,operator-> 返回 const T*,两条通向数据的路都被掐断,这才叫只读迭代器。只堵一条路,const 就从另一条漏出去。
9.5 begin/end 的 const 重载决议
两个 begin() 并存,靠的是 *this 的 const 性参与重载决议:
cpp
iterator begin();
const_iterator begin() const;
非 const 对象调非 const 版本,const 对象只能调 const 版本,返回类型跟着 *this 的 const 性走------const 版本返回 const_iterator,正好和"const 对象里的东西不能改"对齐。
这对重载缺一边,只读场景当场崩。删掉 const 版本后,用一个接 const list<int>& 参数的打印函数实测:
text
t3.cpp:6:47: error: 'this' argument to member function 'begin' has type
'const byte::list<int>', but function is not marked const
./list_noconst.h:176:12: note: 'begin' declared here
报错不是说返回类型对不上,而是非 const 成员函数根本不允许在 const 对象上调用。所以 const 版本的 begin/end 是只读接口的入口:任何一个接 const list<T>& 的函数,从 size() 到外部的打印逻辑,全靠这一对重载才能遍历。
9.6 模板成员函数不实例化就不报错
原文件的 __list_const_iterator::operator++(int) 里写的是:
cpp
__list_iterator<T> tmp(*this);
返回类型是 __list_const_iterator<T>,局部变量却是 __list_iterator<T>,两处都对不上。但整个工程编译通过了------因为它是模板类的成员函数,没人调用就不会实例化。直到有人写下 it++:
text
error: no matching constructor for initialization of '__list_iterator<int>'
note: in instantiation of member function
'byte::__list_const_iterator<int>::operator++' requested here
错误落在 list.h 的内部,而真正的触发点在调用处。这类坑的排查方法是:报错说某个成员函数实例化失败,就去看谁调了它,而不是盯着出错的那一行。
9.7 size 从 O(1) 退化成 O(n)
标准库的 size() 是常数时间,这份实现是遍历。差异在循环里会被放大:
cpp
for (size_t i = 0; i < lt.size(); ++i) // 每次判断都走一遍全表
改成先存一份 size_t n = lt.size(); 就恢复了。同理,用 empty() 判断空表比 size() == 0 快得多------前者只比较一次指针。
C++11 对照(仅拓展) :
for (auto& e : lt)依赖begin()/end()的成对存在,这份 C++98 实现已经满足,只是要手写迭代器类型。
9.8 一次实测:什么场景该换回 vector
把同样的 200000 个整数分别塞进 std::vector 和这份 byte::list,-O2 编译,本机一次测量的结果:
| 操作 | vector | list |
|---|---|---|
| 尾部插入 200000 个 | 0.000613s | 0.006278s |
| 顺序遍历求和 | 0.000018s | 0.000310s |
插入慢约 10 倍,遍历慢约 17 倍。原因不在算法,而在这份实现的每个节点都要单独 new 一次:一次系统调用、一次内存块查找、一次指针写入,而 vector 扩容后连续写入几乎全是缓存命中。
这个结果说明一件事:链表的 O(1) 插入优势,只有在"已经持有插入位置"时才成立。如果为了插到中间而先遍历找位置,整体还是 O(n),而且常数比 vector 大。真正适合 list 的场景是:手里一直攥着某个位置的迭代器,反复在那附近增删,或者元素本身很大、拷贝一次比重建节点还贵。
十、高频坑点总结
| 编号 | 坑 | 现象 | 根因 | 处理 |
|---|---|---|---|---|
| 1 | 不写拷贝构造 | 两个对象共用一条链,析构时 heap-use-after-free | 默认拷贝构造逐成员复制,_head 指针被抄了一份 |
深拷贝:建自己的哨兵,逐个 push_back |
| 2 | 析构函数留空 | 节点全泄漏,元素的析构函数一次都没执行 | 只 delete _head 或不写析构,数据节点无人回收 |
clear() 后再 delete _head |
| 3 | 用 typedef const iterator 当 const 迭代器 |
cannot increment value of type ... |
const 修饰的是迭代器自身,++ 被禁 |
单独写一个 const_iterator |
| 4 | 缺 iterator 到 const_iterator 的转换 |
no viable conversion from 'iterator' |
两个独立类型之间没有转换路径 | 补一个接 const __list_iterator<T>& 的构造 |
| 5 | const 迭代器后置 ++ 写错类型 |
编译期在 list.h 内部报"无匹配构造" |
模板成员不调用不实例化,错误被藏住 | 局部变量与返回类型都用 const_iterator |
| 6 | erase 后继续用旧迭代器 |
ASan 报 heap-use-after-free,freed by ... erase |
节点已被 delete,迭代器里还存着旧地址 |
用 erase 的返回值,或 it = lt.erase(it) |
| 7 | clear 里写 ++it |
同上,删到第二个节点就炸 | erase 之后 it 已悬空 |
写 it = erase(it) |
| 8 | erase(end()) |
断言 pos != end() 失败 |
end() 是哨兵,删掉它整张表失去入口 |
调用前判断非空,或依赖 assert 拦住 |
| 9 | 元素类型没有默认构造 | no matching constructor for initialization of 'NoDefault',报错指向 list_node |
哨兵节点 new Node 需要 T() |
给 _data 一个不依赖 T 的初始化方式,或换用 allocator |
| 10 | 循环里反复调 size() |
时间复杂度悄悄变成 O(n²) | size() 是遍历计数 |
循环外存一份 |
| 11 | 混用两种迭代器比较且方向写反 | it != cit 编译错,cit != it 却能过 |
operator!= 参数是 iterator,const_iterator 转不回去 |
同一次遍历只用一种迭代器;必须混用时 const_iterator 放左边 |
| 12 | operator* 忘了加 const 成员限定 |
const iterator 连 *it 都解不了 |
const 对象调不了非 const 成员函数 | * 与 -> 声明成 const 成员,++/-- 保持非 const |
其中第 1、2 条的实测数据值得单独记:
不写拷贝构造时,list<int> b(a) 之后两个对象指向同一个 _head。程序结束时的报错是:
text
ERROR: AddressSanitizer: heap-use-after-free
#0 byte::list<int>::begin() list_plain.h:159
#1 byte::list<int>::clear() list_plain.h:243
#2 byte::list<int>::~list() list_plain.h:152
freed by thread T0 here:
#1 byte::list<int>::~list() list_plain.h:153
先析构的那个把 _head 释放了,后析构的那个在自己析构函数里还想读 _head->_next。崩溃发生在第二个对象销毁时,而错误写在构造那一刻------这类 bug 的定位成本远高于修复成本。
析构留空则更隐蔽:程序跑完不报错、不崩溃,只是内存慢慢变多。用一个自带计数的元素类型测:
text
作用域结束前 存活对象=4
作用域结束后 存活对象=4
4 个对象是 3 个元素加 1 个哨兵。作用域结束后一个都没减少,说明节点的 delete 和元素的析构函数全都没执行。对 int 看不出问题,对持有文件句柄、锁、堆内存的类型就是灾难。
十一、全文知识闭环复盘
把这份实现从头到尾串一遍,落成五条。
第一,链表用空间换灵活 。每个元素多背两根指针(int 节点 24 字节,有效载荷只占 4 字节),换来插入删除不搬移。元素越大、插入删除越频繁,这笔买卖越划算;元素小、以随机访问为主,就该用 vector。
第二,哨兵头是整份实现的地基 。它让空表和非空表在指针层面同构,于是 push_front、push_back、pop_front、pop_back 全部收敛成 insert(begin()/end(), x) 和 erase(begin()/--end()) 四行,代码里不出现任何空表特判。
第三,插入和删除的真正难点是指针顺序 。insert 里必须在改链表前存好 prev,erase 里必须在 delete 前存好 next。顺序反了,读到的是新节点或已释放内存,前者静默出错,后者当场崩。
第四,迭代器的 const 要分三层看 。const_iterator 限制所指数据,const iterator 限制迭代器自身,两组限制正交,谁也顶替不了谁;转换只能从 iterator 到 const_iterator 单向进行,比较运算符"一过一不过"的不对称就是这条单向规则的投影;operator*/operator-> 的 const 成员限定、const 版本的 begin/end,都是把这条链条闭合的必要零件------缺任何一环,const 就从缺口漏出去。
第五,迭代器失效规则比 vector 温和 。insert 不改动已有节点,旧迭代器全部有效;erase 只让指向被删节点的那一个失效。代价是 size() 退化成 O(n),以及每次访问都要顺着指针走,缓存不友好。
和 vector 的取舍放在一张表里:
| 维度 | vector | list |
|---|---|---|
| 内存布局 | 一整块连续空间 | 每个节点单独 new,地址零散 |
| 随机访问 | O(1),一次加法算地址 | O(n),顺着指针走 |
| 中间插入删除 | O(n),搬移元素 | O(1),只改指针 |
| 迭代器失效 | 扩容时全部失效 | 只有被删的那个失效 |
| 空间开销 | 几乎为零(容量余量除外) | 每元素多两根指针 |
size() |
O(1) | 这份实现是 O(n) |
| 缓存友好度 | 高,连续访问命中率高 | 低,每次跳一个地址 |



