STL:List的使用和模拟实现

list:从会用到能自己写一个

节点之间没有地址关系,只有两个指针把彼此串起来。今天聊 list。

list 的接口和 vector 高度一致:构造、size、push_back、insert、erase、迭代器遍历,换个容器名就能跑。真正不同的地方在两处:一是底层结构从连续数组变成了分散节点,二是迭代器不能再拿原生指针糊弄过去。前半段讲接口和性质,后半段拆开节点、哨兵和迭代器封装。

为什么需要 list

vector 的代价集中在"连续"两个字上。中间插入或删除,要把后面的元素整体搬移,复杂度 O(n);容量不够时要开新空间、搬元素、释放旧空间,原有的迭代器、指针、引用全部失效。

list 把每个元素放进独立节点,节点之间用指针相连。已知位置的插入和删除只改几个指针,O(1),不搬移任何元素,也不会让其他节点的地址改变。

代价同样明确:

维度 vector list
底层结构 动态数组,一段连续空间 带头结点的双向循环链表
随机访问 O(1) 不支持,访问第 n 个是 O(n)
中间插入删除 O(n),要搬元素 O(1),只改指针(已知位置)
查找 连续内存,缓存友好 逐节点跳转,缓存不友好
每个元素的额外开销 无 两个指针
插入是否失效迭代器 可能全部失效 不失效

关键限定词是"已知位置"。insert / erase 本身是 O(1),但用 find 找到那个位置是 O(n)。标准文档写"常数时间插入删除",说的是前者,不是"查找加删除"。

💡 背景补充:C 语言的单链表只有 next,删除节点要先找前驱;双向链表补上 prev,再加一个哨兵头结点,空表和非空表的边界就消失了。
📖 参考:《数据结构知识库》第一节(线性表:顺序表与链表的对比)

底层结构:带头双向循环链表

图中红色方框是哨兵结点(_head),蓝色方框是第一个数据结点。四个数据结点 0、2、3、4 首尾相接,4 的 _next 指回哨兵,哨兵的 _prev 指向 4。begin() 返回哨兵的下一个结点,end() 返回哨兵本身。

这个结构把链表最常见的边界情况全部消掉了:

  • 空表时,哨兵的 _next 和 _prev 都指向自己,仍然是一个合法的环。
  • 尾结点永远是 _head->_prev,取尾 O(1),不需要遍历。
  • 头插、尾插、头删、尾删都能统一成"在某结点之前插入"或"删除某结点",不用为空表、单结点、尾结点分别写分支。

插入一个新结点,只需要改四条链接:prev->_next = newnode、newnode->_prev = prev、newnode->_next = cur、cur->_prev = newnode。删除则反向操作两条,再 delete 当前结点。

哨兵结点不存有效数据,它的 _data 是 T(),只是为了占住环的入口。所有"最后一个元素的下一个位置"都指向它,这正是 end() 的语义。

构造、容量与元素访问

常用构造如下:

构造 作用 备注
list() 空链表 只有一个哨兵结点
list(size_t n, T val = T()) n 个 val 标准库有约束;自写实现易被区间构造抢走
list(InputIterator first, InputIterator last) 迭代器区间 左闭右开
list(const list& lt) 拷贝构造 深拷贝
list(initializer_list<T> il) 花括号初始化 C++11
cpp 复制代码
#include <list>
#include <iostream>

int main()
{
    std::list<int> l1;                          // 空
    std::list<int> l2(5, 1);                    // 5 个 1

    int a[] = {1, 2, 3, 4};
    std::list<int> l3(a, a + 4);                // 区间构造
    std::list<int> l4(l3);                      // 拷贝构造
    std::list<int> l5{1, 2, 3};                 // C++11

    for (int e : l5)
        std::cout << e << ' ';
    std::cout << l5.size() << '\n';
}

list 没有 capacity、reserve、shrink_to_fit,也没有 operator[]。容量概念只属于连续空间;链表按需申请一个节点就是一个节点,没有"预留槽位"这回事。

下标访问被去掉的原因很直接:vector 的 operator[] 是"起始地址 + i",O(1);链表要拿到第 i 个,只能从头逐个 ++,O(n)。语法上可以实现,语义上不该伪装成常数时间。

元素访问只保留 front() 和 back(),都返回引用,可以读取也可以修改:

cpp 复制代码
std::list<int> lt = {1, 2, 3};
lt.front() = 100;   // 第一个结点
lt.back() = 200;    // 最后一个结点

迭代器的三个性质

从"能做什么"划分,迭代器有正向、反向、const 三类;从"性质"划分,有单向、双向、随机三类。前者是用法,后者是能力。

性质 支持的操作 典型容器
单向(forward) ++ forward_list、unordered_map、unordered_set
双向(bidirectional) ++、-- list、map、set
随机访问(random access) ++、--、+、-、[] vector、string

性质由底层结构决定,不是想实现什么就实现什么:

  • vector / string 是连续空间,指针加 i 就跳到第 i 个,天然支持 +、-。
  • list 的结点里有两个指针,能顺着 _next 往后、顺着 _prev 往前,但没有"第 i 个"的地址公式,所以只支持 ++、--。
  • forward_list 的结点只有一个 next,只能往后,所以只支持 ++。

这也是 lt.begin() + 3 编译不过的原因。要在第三个位置插入,只能走:

cpp 复制代码
std::list<int> lt = {1, 2, 3, 4};
auto it = lt.begin();
int k = 3;
while (k--)
    ++it;
lt.insert(it, 99);   // 在下标 3 之前插入

std::advance(it, 3) 做的是同一件事,内部按迭代器性质选择实现:随机迭代器直接 it += n,双向迭代器循环 ++ 或 --。

算法会挑迭代器

标准算法的形参名会标明它要求的最低性质,名字本身就是约束:

算法 要求 原因
sort 随机访问 快排、堆排要算中点、要 +、-、[]
reverse 双向 从两端向中间交换,要 --
find 输入迭代器 只做 ++,单向双向随机都能用

所以 std::sort 不能排链表:

cpp 复制代码
#include <algorithm>
#include <list>

std::list<int> lt = {3, 1, 2};
std::sort(lt.begin(), lt.end());   // 编译报错:ListIterator 没有 operator-
lt.sort();                         // 成员函数,归并排序

list 自己实现了一个 sort,因为它用不了算法库那个。标准库的容器里,只有需要特殊实现的容器才会带成员 sort,链表是典型。

reverse 则可以用算法库的版本,因为链表迭代器支持 --:

cpp 复制代码
std::reverse(lt.begin(), lt.end());   // 可以
lt.reverse();                         // 也可以,等价

迭代器的性质在标准库里用继承关系建模:随机访问是双向的特例,双向是单向的特例。算法只声明它需要的最弱性质,更强的迭代器自然能传进去。find 的形参写成输入迭代器,意思是"只要求 ++",所有容器都满足。

只属于 list 的接口:splice、merge、unique、remove

这几个接口是链表结构带来的,vector 没有对应物。

splice 是"剪切 + 粘贴":把源链表的节点直接摘下来,接到目标位置之前,不拷贝、不重新分配。

cpp 复制代码
std::list<int> lt1 = {1, 2, 3, 4};
std::list<int> lt2 = {10, 20, 30};

auto pos = std::find(lt1.begin(), lt1.end(), 2);
lt1.splice(pos, lt2);        // lt2 的所有节点接到 2 之前,lt2 变为空

它还有两个重载:转移单个节点、转移一段区间。也可以对自己用,用来调整顺序:

cpp 复制代码
std::list<int> lt = {1, 2, 3, 4, 5};
auto it = std::find(lt.begin(), lt.end(), 5);
lt.splice(lt.begin(), lt, it);   // 把 5 移到头部

merge 合并两个有序链表,取较小者尾插,节点整体转移:

cpp 复制代码
std::list<int> a = {1, 3, 5};
std::list<int> b = {2, 4, 6};
a.merge(b);          // a: 1 2 3 4 5 6,b 为空

unique 删除相邻的重复值,前提同样是有序:

cpp 复制代码
std::list<int> c = {1, 1, 2, 2, 2, 3};
c.unique();          // 1 2 3

无序时它只比较相邻元素,{2, 1, 2} 去重后仍是 {2, 1, 2}。想真正去重,先 sort 再 unique。

remove 按值删除所有匹配节点:

cpp 复制代码
c.remove(2);         // 删除所有值为 2 的节点

remove 内部逐个比较,O(n);它不要求有序,也不会因为找不到而报错。

排序:把链表拷进 vector

list::sort 是归并排序,时间复杂度 O(n log n),但它每次比较都要跳到下一个节点,缓存命中率很低。数据量大时,把节点拷进 vector、排序、再拷回来,往往更快。

cpp 复制代码
#include <list>
#include <vector>
#include <algorithm>
#include <ctime>
#include <cstdio>

void test_list_sort()
{
    srand((unsigned)time(nullptr));
    const int N = 1000000;

    std::list<int> lt1, lt2;
    for (int i = 0; i < N; ++i)
    {
        int e = rand() + i;          // 减少重复值
        lt1.push_back(e);
        lt2.push_back(e);
    }

    int b1 = clock();
    std::vector<int> v(lt2.begin(), lt2.end());
    std::sort(v.begin(), v.end());
    lt2.assign(v.begin(), v.end());
    int e1 = clock();

    int b2 = clock();
    lt1.sort();
    int e2 = clock();

    printf("copy to vector: %d ms\n", e1 - b1);
    printf("list::sort:     %d ms\n", e2 - b2);
}

Release 构建下,第二条路通常快 2 到 4 倍,而且这个时间把两次拷贝都算在内。拷贝是顺序写入连续内存,代价远小于链表排序时逐个节点的指针跳转。

Debug 构建的结论不能信。递归实现的快排在 Debug 下每层都要建栈帧、写调试信息,开销被放大;链表归并排序的递归深度浅,反而显得不慢。性能对比只在 Release 下有意义。

数据量小的时候,list::sort 完全够用;数据量大且只排一次,先拷进 vector 是更划算的选择。

迭代器失效:插入不失效,删除才失效

list 的插入不改变已有节点的地址,所以插入不会让任何迭代器失效 。这一点和 vector 相反:vector 一旦扩容,所有迭代器、指针、引用全部作废。

删除会让被删节点的迭代器失效。只失效那一个,其他节点不受影响。

cpp 复制代码
void TestListIterator1()
{
    int array[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
    std::list<int> l(array, array + sizeof(array) / sizeof(array[0]));

    auto it = l.begin();
    while (it != l.end())
    {
        l.erase(it);   // it 指向的节点已被 delete
        ++it;          // 未定义行为
    }
}

erase(it) 之后,it 里的 _node 指向一块已释放的内存,再 ++ 就是访问野指针。正确写法是用返回值更新:

cpp 复制代码
auto it = l.begin();
while (it != l.end())
{
    it = l.erase(it);
}

erase 返回被删节点的下一个位置,标准库和这份模拟实现都遵循这个约定。另一种写法是 l.erase(it++):先把旧值传给 erase,再把 it 后移,两个动作的顺序由后缀 ++ 保证。

删除所有偶数这类操作,两种写法都对:

cpp 复制代码
for (auto it = l.begin(); it != l.end(); )
{
    if (*it % 2 == 0)
        it = l.erase(it);
    else
        ++it;
}

手写一个 list:节点与骨架

先定义节点。每个节点存数据加前后两个指针:

cpp 复制代码
namespace bit
{
    template<class T>
    struct list_node
    {
        list_node<T>* _next;
        list_node<T>* _prev;
        T _data;

        // 哨兵节点也走这个构造,所以必须有默认参数
        list_node(const T& x = T())
            : _next(nullptr), _prev(nullptr), _data(x)
        {}
    };
}

T() 对内置类型是零值初始化:int 得 0,double 得 0.0;对自定义类型调用默认构造。哨兵节点不存数据,但必须能构造出来,这个默认参数就是为它准备的。

链表本体只保存两个成员:

cpp 复制代码
template<class T>
class list
{
    using Node = list_node<T>;
public:
    using iterator = list_iterator<T, T&>;
    using const_iterator = list_iterator<T, const T&>;

    // ...

private:
    Node* _head;          // 哨兵
    size_t _size = 0;     // 结点个数
};

_head 永远指向哨兵,不指向第一个数据节点。begin() 是 _head->_next,end() 是 _head。空表时两者相等,循环一次都不进,不需要特判。

_size 不是链表结构必需的,但没有它,size() 要遍历整条链,O(n)。加一个计数器,插入 ++、删除 --,size() 就是 O(1)。

节点指针为什么不能直接当迭代器

vector 的迭代器就是原生指针,因为它的元素连续:++p 的地址偏移正好是下一个元素。

链表不行。一个 list_node<T>*:

cpp 复制代码
using Node = bit::list_node<int>;
Node* p = nullptr;   // 假设指向某个节点

// *p  的类型是 list_node<int>,不是 int
// ++p 的地址加 sizeof(list_node<int>),不是下一个节点

解引用拿到的是整个节点,不是里面的 _data;自增是按地址偏移走,而节点是逐个 new 出来的,地址之间没有规律。

解决办法是把节点指针包进一个类,用运算符重载改掉它的行为:

cpp 复制代码
template<class T, class Ref>//这里的Ref写法直接可以允许少写一个const版本
struct list_iterator
{
    using Self = list_iterator<T, Ref>;
    using Node = list_node<T>;

    Node* _node;

    list_iterator(Node* node)
        : _node(node)
    {}

    Ref operator*()
    {
        return _node->_data;      // 解引用给出数据,不是节点
    }

    Self& operator++()
    {
        _node = _node->_next;     // 自增走链表,不是走地址
        return *this;
    }

    Self operator++(int)
    {
        Self tmp(*this);
        _node = _node->_next;
        return tmp;
    }

    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; }
};

几处细节值得停一下:

  • Self 是 list_iterator<T, Ref> 的别名。类内直接写 list_iterator 也行(注入类名),但形参和返回值都要用它,起个短名字省事。
  • 前置 ++ 返回 Self&:改的是自己,返回引用不产生拷贝,++++it 可以连着写。后置 ++ 必须返回 Self:它要交出自增前 的值,只能先拷一份 tmp 存着。
  • 后置版本形参里的 int 没有任何用处。它只是占位标记,用来和前置版本构成重载,调用时编译器自动补 0。
  • operator* 返回 Ref 而不是写死的 T&,这是后面 const_iterator 能复用同一个类模板的关键。
  • operator!= 的形参是 const Self&,按引用传,不拷贝;函数末尾那个 const 是另一件事,它修饰 this,表示函数不改迭代器自身,因此 const 的迭代器对象也能调用。形参 const 和函数 const 管的是两个不同的对象。

迭代器对象里只有一个 Node*,物理大小和裸指针完全相同:32 位下 4 字节,64 位下 8 字节。差别在类型上。裸指针的 ++ 走内置的地址运算,ListIterator 的 ++ 走重载函数,跳到 _node->_next。同样的字节,不同的规则,这是类型系统在做区分。

有了这个类,begin() 返回 iterator(_head->_next),end() 返回 iterator(_head)。范围 for 只依赖 begin、end、!=、++、*,全部满足,所以自然支持。

operator-> 与两个箭头

迭代器还要模拟指针的 ->。存 int 时用不到,存结构体时绕不开:

cpp 复制代码
struct A
{
    int a1;
    int a2;
};

bit::list<A> lt;
lt.push_back({1, 2});

auto it = lt.begin();
it->a1;

it 是类对象,-> 必须在类里重载:

cpp 复制代码
Ref operator*()  { return _node->_data; }
Ptr operator->() { return &_node->_data; }

operator-> 返回的是数据成员的地址,也就是 T*。写 it->a1 时,编译器实际展开成:

cpp 复制代码
it.operator->()->a1;

如果 operator-> 返回的仍然是一个类对象,编译器会继续对它调用 operator->,直到拿到指针。这条规则允许链式代理。所以严格来说,it->a1 等价于 it.operator->()->a1,中间那个箭头是原生指针自带的。

链表的迭代器必须暴露 pointer 和 reference 两个类型别名,反向迭代器才能从它身上取返回值类型:

cpp 复制代码
template<class T, class Ref, class Ptr>
struct list_iterator
{
    using Self = list_iterator<T, Ref, Ptr>;
    using Node = list_node<T>;

    using reference = Ref;
    using pointer   = Ptr;

    Node* _node;

    Ref operator*()  { return _node->_data; }
    Ptr operator->() { return &_node->_data; }

    // ++、--、==、!= 同上
};

// 两个参数不同的实例化
using iterator       = list_iterator<T, T&, T*>;
using const_iterator = list_iterator<T, const T&, const T*>;

标准库的链表迭代器就是三个模板参数:T、Ref、Ptr。多出来的两个参数只控制 * 和 -> 的返回类型,其他实现完全共用。

const 迭代器:为什么不是 const iterator

指针有两种 const,位置不同,含义不同:

cpp 复制代码
T* const p;        // 指针本身不能改,指向的数据可以改
const T* p;        // 指针本身可以改,指向的数据不能改

const iterator 对应第一种:迭代器对象不能改,所以 ++it 编译不过;但它指向的数据没被保护。const_iterator 对应第二种:可以 ++ 遍历,但不能通过它修改元素。

所以 const_iterator 不能靠给普通迭代器加 const 得到。它需要的是一个"自增正常、解引用返回 const T&"的新类型。

最直觉的做法是另写一个类,节点指针和成员函数照抄一遍,只改 operator* 的返回类型:

cpp 复制代码
template<class T>
struct list_const_iterator
{
    list_node<T>* _node;

    const T& operator*() { return _node->_data; }
    // ++、--、==、!= 全部抄一遍
};

能跑,但迭代器的逻辑(跳 _next、跳 _prev、比较 _node)和 const 毫无关系,只有 * 的返回类型不同。整份代码复制两遍,以后改一处就要同步两处。

把这点差异抽成模板参数,一个类就够:

cpp 复制代码
using iterator       = list_iterator<T, T&>;
using const_iterator = list_iterator<T, const T&>;

同一个类模板,Ref 传 T& 时 operator* 返回可写引用;传 const T& 时返回只读引用。其他成员一字不改。

cpp 复制代码
bit::list<int> lt = {1, 2, 3};
const bit::list<int>& clt = lt;

bit::list<int>::const_iterator cit = clt.begin();   // const 对象调用 const 版本 begin
std::cout << *cit << '\n';   // 可以读
*cit = 10;                   // 编译报错:const T& 不能赋值
++cit;                       // 可以

模板的成员函数体按需实例化:没被调用的函数,编译器只做语法层面的浅扫描,不会检查里面的细节错误。*cit = 10 一旦写出来,operator* 被实例化,错误立刻暴露;不写,错误就一直藏着。

begin/end 的 const 重载

const 对象只能调用 const 成员函数。所以链表的 begin / end 各有两个版本:

cpp 复制代码
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); }

非 const 对象两个版本都能调,重载决议会选最匹配的非 const 版本,拿到可写迭代器;const 对象只能调 const 版本,拿到只读迭代器。

cpp 复制代码
void Print(const bit::list<int>& lt)
{
    bit::list<int>::const_iterator it = lt.begin();
    while (it != lt.end())
    {
        std::cout << *it << ' ';
        ++it;
    }
}

如果只保留非 const 版本,const list<int>& 参数就无法遍历。如果只保留 const 版本,普通对象也能用,但永远拿到只读迭代器,失去了修改能力。两个版本都写,才能按对象本身的 const 属性分流。

复用:insert 与 erase

有了 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()); }

push_back 最初是直接 new 一个节点、改四条链接。改成调用 insert(end(), x) 之后,写指针的地方全篇只剩 insert 和 erase 两处,出了问题也只能从这两处出。

end() 指向哨兵,在它之前插入就是尾插;--end() 得到尾节点,删掉就是尾删;begin() 是第一个数据节点,删掉就是头删。

insert 的完整实现:

cpp 复制代码
void insert(iterator pos, const T& x)
{
    Node* cur  = pos._node;
    Node* prev = cur->_prev;
    Node* newnode = new Node(x);

    // prev <-> newnode <-> cur
    prev->_next    = newnode;
    newnode->_prev = prev;
    newnode->_next = cur;
    cur->_prev     = newnode;

    ++_size;
}

erase 返回下一个位置,顺便解决了遍历删除的问题:

cpp 复制代码
iterator erase(iterator pos)
{
    Node* cur  = pos._node;
    Node* prev = cur->_prev;
    Node* next = cur->_next;

    prev->_next = next;
    next->_prev = prev;
    delete cur;
    --_size;

    return next;   // Node* 隐式转换成 iterator
}

return next; 能编过,是因为 list_iterator 的构造函数只有一个 Node* 参数且没有 explicit,Node* 可以隐式转换成迭代器。标准规定 insert 也返回指向新节点的迭代器;当前实现返回 void,补上 return iterator(newnode); 即可。

两个操作的代价都是 O(1),不需要查找、不需要搬移。

析构、clear、拷贝、赋值

clear 删除所有数据节点,但保留哨兵:

cpp 复制代码
void clear()
{
    iterator it = begin();
    while (it != end())
    {
        it = erase(it);
    }
}

析构在 clear 之后再把哨兵也释放:

cpp 复制代码
~list()
{
    clear();
    delete _head;
    _head = nullptr;
}

拷贝构造必须先把哨兵建出来,再逐个 push_back:

cpp 复制代码
list(const list<T>& lt)
{
    empty_init();                 // 没有这一行,push_back 会解引用空指针
    for (auto& e : lt)
    {
        push_back(e);
    }
}

empty_init 负责建立哨兵并让 _next、_prev 指向自己:

cpp 复制代码
void empty_init()
{
    _head = new Node;
    _head->_next = _head;
    _head->_prev = _head;
}

new Node 不带实参,走的是 list_node 的默认实参 x = T()。这带来一个隐含约束:list<T> 要求 T 可默认构造。

cpp 复制代码
struct NoDef
{
    explicit NoDef(int x) {}
};

bit::list<NoDef> lt;   // 编译报错:no matching function for call to 'NoDef::NoDef()'

报错落在 empty_init 里,离声明只有两行。标准库的链表把哨兵结点的链接部分和数据部分拆开,哨兵只存前后指针,所以没有这个要求。简化版牺牲一点通用性,换来结构上的直白。

传统赋值写法是先判自赋值,再清空、再拷贝:

cpp 复制代码
list<T>& operator=(const list<T>& lt)
{
    if (this != &lt)
    {
        clear();
        for (auto& e : lt)
        {
            push_back(e);
        }
    }
    return *this;
}

更现代的写法是 copy-and-swap:形参按值传,自动得到一份拷贝,再和自身交换,形参析构时带走旧数据。

cpp 复制代码
list<T>& operator=(list<T> lt)   // 传值,已经拷贝一份
{
    swap(lt);
    return *this;
}

void swap(list<T>& lt)
{
    std::swap(_head, lt._head);
    std::swap(_size, lt._size);
}

交换只动两个成员,O(1);旧数据随形参一起析构,不需要手写释放逻辑;自赋值也安全,因为交换的是两份独立的数据。

异常安全是这套写法真正的理由。传统版本先 clear(),旧数据已经删干净;如果后面的 push_back 在中途抛异常(比如 new 失败),对象停在半成品状态:旧数据没了,新数据不全。这只能算基本保证------不泄漏、能析构,但状态不可预期。

copy-and-swap 先让形参把拷贝做完。拷贝阶段抛异常,*this 一个字节都没动,异常安全等级是强保证:要么成功,要么原样。交换只改两个指针和一个计数,本身不会失败。

swap 不能直接调 std::swap(*this, lt),那会走拷贝构造和赋值,反而变成深拷贝。必须交换成员。

📖 参考:《高质量C++/C编程指南》第 9 章(Big-Three、位拷贝、赋值函数四步)

区间构造与整数构造的重载冲突

把两个构造函数并排放:

cpp 复制代码
template<class InputIterator>
list(InputIterator first, InputIterator last)
{
    empty_init();
    while (first != last)
    {
        push_back(*first);
        ++first;
    }
}

list(size_t n, T val = T())
{
    empty_init();
    for (size_t i = 0; i < n; ++i)
        push_back(val);
}

bit::list<int> lt(5, 10); 在这一版里编译不过。两个候选都活着:

候选 实参 5 的转换 匹配质量
list(size_t, T) int → size_t,整型转换 需要转换
list(InputIterator, InputIterator),InputIterator = int 5 本身就是 int 精确匹配

精确匹配优先于转换,模板被选中,实例化成 list(int, int)。函数体里要执行 *first,而 first 是 int:

text 复制代码
error: invalid type argument of unary '*' (have 'int')
  list(InputIterator first, InputIterator last) { ... push_back(*first); ... }

模板参数叫 InputIterator,但编译器不检查任何概念。函数体只用到三样东西:!= 比较、* 解引用、前置 ++。传进来的类型只要凑齐这三个操作,模板就能实例化成功------鸭子类型,看行为不看身份。int 有 != 和 ++,唯独没有 *,错误就出在这里。

参数匹配发生在函数体检查之前。参数对上了,错误要等实例化才爆出来,报错位置还落在模板内部,离调用点很远。

补一个 list(int n, T val = T()) 能救这一句:

cpp 复制代码
// 想拦住负数,却把重载冲突挪到了别处
list(int n, T val = T())
{
    empty_init();
    for (int i = 0; i < n; ++i)
        push_back(val);
}

(5, 10) 恢复了,(arr, arr + 3) 也没受影响。但只要实参不是 int,两个整数版本又打起来。bit::list<int> lt(5u, 10);:

text 复制代码
error: call of overloaded 'list(unsigned int, int)' is ambiguous

unsigned int 转 int 和转 size_t 都是一次整型转换,谁也不比谁更优。long long 实参同样二义。补重载只是把冲突从一个位置挪到另一个位置。

正解是给模板加约束:实参是整数时,让它干脆不参与重载。

cpp 复制代码
#include <type_traits>

template<class InputIterator,
         class = typename std::enable_if<
             !std::is_integral<InputIterator>::value>::type>
list(InputIterator first, InputIterator last)
{
    empty_init();
    while (first != last)
    {
        push_back(*first);
        ++first;
    }
}

std::is_integral<int>::value 为真,enable_if<false> 里没有 type 成员,模板推导失败,候选被静默移除,这就是 SFINAE。整数实参面前只剩 list(size_t, T);指针、数组迭代器面前只剩模板。

C++20 可以写得更直白:

cpp 复制代码
#include <iterator>

template<std::input_iterator InputIterator>
list(InputIterator first, InputIterator last);

标准库的 std::list 用的就是这类约束,所以它既能 std::list<int> l(5, 10),也能 std::list<int> l(a, a + 4)。

约束之后,负数也没了拦截。list<int> lt(-5, 10) 里的 -5 会转换成 size_t,得到一个接近 2^64 的数:

cpp 复制代码
std::size_t n = -5;   // 18446744073709551611

循环会在耗尽内存时抛 bad_alloc。想拦负数,在构造函数体里判断,而不是靠增加一个 int 重载------整型转换是标准允许的,编译器没有理由拦。

💡 让重载决议少一个候选,比让两个候选争胜负更稳。约束模板是前者,补 int 重载是后者。

initializer_list:花括号怎么变成 list

list<int> lt = {1, 2, 3}; 能编过,靠的是 C++11 的 std::initializer_list<T>。

它是个轻量视图:两个指针分别指向一段 const T 数组的开头和结尾,size() 就是 last - first。数组由编译器在栈上生成,initializer_list 本身不拥有数据,也不允许修改元素。拷贝一个 initializer_list 只复制这两个指针,代价等同于拷贝两个指针。

构造函数接收它,遍历一遍插进链表:

cpp 复制代码
list(initializer_list<T> il)
{
    empty_init();          // 先把哨兵建出来,push_back 才有地方挂节点
    for (auto& e : il)     // 逐个取出元素
    {
        push_back(e);      // 每个元素拷贝一份,放进新节点
    }
}

三行代码里各有一个要点:

empty_init() 必须在循环之前。 push_back 走 insert(end(), x),要读 _head->_next。_head 还是野指针时,第一条语句就是访问非法地址。

auto& e 推导出来是 const T&。 initializer_list<T>::begin() 返回 const T*,解引用得到 const T&。写 auto e 会多一次拷贝,写 auto& e 不会。

每个元素必须拷贝进节点。 背后那段 const T[N] 数组只活到当前完整表达式结束,initializer_list 手里只有指针。构造函数返回时数组已经销毁,节点里必须是独立的一份数据。

范围 for 展开成普通的迭代器循环,和 il.begin()、il.end()、!=、++、* 一一对应:

cpp 复制代码
for (auto it = il.begin(); it != il.end(); ++it)
{
    push_back(*it);
}

花括号还能隐式转换成 list:

cpp 复制代码
void f(bit::list<int> lt);

f({1, 2, 3});          // 花括号 → initializer_list → 隐式构造 list

initializer_list 是单参数,构造函数没有加 explicit,因此支持隐式类型转换。vector、string 也有同样的构造函数,这是所有标准容器在 C++11 之后的统一约定。

代价是花括号的语义被这个构造函数接管了。列表初始化优先匹配 initializer_list:

cpp 复制代码
bit::list<int> a(5, 10);   // 5 个 10
bit::list<int> b{5, 10};   // 2 个元素:5 和 10

圆括号和花括号在这里不是风格差异,是两个不同的构造函数。想构造 n 个相同值,必须用圆括号。

反向迭代器:包装一层正向迭代器

反向迭代器的方向是翻转的:++ 对应正向的 --,-- 对应正向的 ++。rbegin() 返回 end() 位置的包装,rend() 返回 begin() 位置的包装。

图中的 rbegin 指向哨兵(即 end),rend 指向第一个数据节点(即 begin)。解引用 rbegin 时不能直接取哨兵的数据,要先把内部的迭代器往前挪一格:

cpp 复制代码
template<class Iterator>
class ReverseListIterator
{
    using Ref = typename Iterator::reference;
    using Ptr = typename Iterator::pointer;

public:
    using Self = ReverseListIterator<Iterator>;

    ReverseListIterator(Iterator it)
        : _it(it)
    {}

    Ref operator*()
    {
        Iterator tmp(_it);
        --tmp;              // rbegin 存的是 end,先退回最后一个元素
        return *tmp;
    }

    Ptr operator->() { return &(operator*()); }

    Self& operator++()
    {
        --_it;              // 反向的 ++ 是正向的 --
        return *this;
    }

    Self operator++(int)
    {
        Self tmp(*this);
        --_it;
        return tmp;
    }

    Self& operator--()
    {
        ++_it;
        return *this;
    }

    Self operator--(int)
    {
        Self tmp(*this);
        ++_it;
        return tmp;
    }

    bool operator!=(const Self& r) const { return _it != r._it; }
    bool operator==(const Self& r) const { return _it == r._it; }

private:
    Iterator _it;
};

Iterator::reference 是依赖类型,前面必须加 typename,否则编译器分不清它是类型还是静态成员。

链表里补上两个接口:

cpp 复制代码
using reverse_iterator = ReverseListIterator<iterator>;

reverse_iterator rbegin() { return reverse_iterator(end()); }
reverse_iterator rend()   { return reverse_iterator(begin()); }

rbegin 不能返回 end() - 1,因为链表迭代器没有 -;减一只能靠 -- 完成,所以包装类的构造函数直接收下 end(),在解引用时再退一步。

list 与 vector 的对比

维度 vector list
底层结构 动态顺序表,一段连续空间 带头结点的双向循环链表
随机访问 支持,O(1) 不支持,O(n)
插入删除 任意位置 O(n),可能扩容 已知位置 O(1)
空间利用率 连续空间,碎片少,缓存友好 节点分散,碎片多,缓存不友好
迭代器 原生指针 对节点指针的封装
迭代器失效 扩容后全部失效 插入不失效,删除只失效被删节点
使用场景 高效存储、随机访问 大量插入删除、不关心随机访问

表格最后一行是选型依据:需要按下标频繁访问、数据量稳定,用 vector;需要频繁在中间插入删除、且已经持有位置,用 list。绝大多数场景 vector 更快,因为缓存和连续内存的优势通常盖过搬移成本。

常见误区

1. 对 list 迭代器做 +、-。 lt.begin() + 3 编译不过。用循环 ++ 或 std::advance。

2. 用 std::sort 排 list。 它要求随机访问迭代器。用成员 lt.sort()。

3. erase 之后继续 ++it。 节点已释放,行为未定义。用 it = lt.erase(it)。

4. 以为 insert 会让迭代器失效。 链表插入不改变已有节点地址,所有迭代器仍然有效。

5. 对无序链表用 unique。 它只删除相邻重复值,无序时几乎不起作用。

6. 对无序链表用 merge。 前提是两个链表都有序,否则结果没有意义。

7. 以为 merge 是拷贝。 节点被整体转移,源链表会变空。

8. 以为 splice 是插入副本。 它是转移,源链表的那一段会消失。

9. 把 const iterator 当 const_iterator。 前者的自增被禁止,后者禁止的是通过迭代器改数据。

10. 以为 clear 会释放哨兵。 clear 只删数据节点;哨兵由析构函数负责。

11. 拷贝构造里忘记 empty_init。 哨兵没建,push_back 解引用空指针。

12. 以为 erase / insert 的 O(1) 包含查找。 查找是 O(n),O(1) 只针对已经拿到位置的插入删除。

13. 自己实现时只写 list(size_t, T) 和区间构造。 list<int> lt(5, 10) 会被区间模板抢走,报 invalid type argument of unary '*'。给模板加 is_integral 约束,而不是再补一个 int 重载。

14. 用花括号构造 n 个相同值。 list<int> lt{5, 10} 是两个元素,不是五个 10。initializer_list 优先于其它构造函数。

15. 以为哨兵不需要 T 可默认构造。 这份实现的哨兵要执行 T(),存没有默认构造函数的类型会编译失败。

本节要点

  • list 的底层是带头结点的双向循环链表;哨兵让空表、头尾操作都不用特判。
  • 没有 operator[]、没有 capacity;随机访问 O(n),已知位置插入删除 O(1)。
  • 迭代器性质由底层结构决定:链表是双向,vector / string 是随机,forward_list 是单向。
  • std::sort 要随机迭代器,std::reverse 要双向,std::find 只要 ++。
  • 链表的迭代器必须封装节点指针,重载 *、->、++、--、!=、==。
  • operator-> 返回数据地址,it->a 实际展开成 it.operator->()->a。
  • const_iterator 是独立类型,靠 Ref 模板参数让 operator* 返回 const T&,不是给迭代器对象加 const。
  • 插入不失效迭代器;删除只失效被删节点的迭代器,erase 返回下一个位置。
  • push_back / push_front / pop_back / pop_front 全部复用 insert / erase。
  • 拷贝构造先 empty_init;赋值可用传统写法或 copy-and-swap,swap 只交换 _head 和 _size。
  • initializer_list 是两个指针的轻量视图,元素在构造时逐个拷贝进节点;单参数构造因此支持花括号隐式转换。
  • 花括号优先匹配 initializer_list:lt(5, 10) 是五个 10,lt{5, 10} 是两个元素。
  • 区间构造模板会抢走整数实参;用 is_integral 把模板约束掉,比补一个 int 重载更可靠。
  • 前置 ++ 返回引用、后置返回旧值的拷贝;成员函数尾部的 const 修饰 this,和形参上的 const 不是一回事。
  • 大数据量排序,拷进 vector 排完再拷回来通常比 list::sort 更快;性能对比只看 Release。

📖 参考:《数据结构知识库》第一节(线性表:顺序表与链表的对比)

📖 参考:《高质量C++/C编程指南》第 7 章(new / delete 配对)、第 9 章(构造、析构、赋值与 Big-Three)

📖 参考:《Effective C++》条款 11(自我赋值)、条款 12(复制每一个成分)


KIRACRIMSON

相关推荐
二十七日秋2 小时前
算法刷题笔记「Day 1|四道 OJ 入门题里的深坑」
c语言·汇编·数据结构·c++·笔记·python·算法
流年如夢2 小时前
继承与多态_继承篇
c++·继承·面向对象·c++进阶
北冥you鱼3 小时前
Go 语言 GORM 使用 Preload:从关联查询到嵌套预加载的完整实践
开发语言·jvm·golang
沫璃染墨3 小时前
从零入门计算机网络系列(十):HTTP协议进阶——从资源路径解析到状态码与请求头详解
linux·运维·服务器·网络·c++·http
dingzd953 小时前
TikTok账号登录态失效的Cookie字段排查路径
开发语言·php·跨境电商·营销策略
曹牧3 小时前
C#:The operation has timed out
开发语言·c#
xiaoye-duck3 小时前
《C++ 工具库实战》高性能日志 spdlog:从基础使用到工程化封装
c++·第三方库
你看我名字3 小时前
数眼智能 Search + Reader 实战:联网问答怎样避免“搜到了,却引用错了”
android·java·开发语言
小嘉丶学长3 小时前
Day13 · 列表与元组
linux·运维·服务器·开发语言·python