list:哨兵节点,const_iterator 和 const iterator的区别与迭代器失效

一、知识点引入

用数组存数据,插入一个元素要把后面所有元素往后挪一格;删一个元素要往前挪一格。挪动的代价和数据量成正比,规模一大就顶不住。链表换了个思路:元素不要求在内存里挨着,每个元素自己带路,记着下一个和上一个在哪。插入删除只改几根指针,元素本身一动不动。

代价也清楚:想找第 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)
缓存友好度 高,连续访问命中率高 低,每次跳一个地址
相关推荐
沫璃染墨1 小时前
从零入门计算机网络系列(六):TCP套接字编程实战——从listen监听到客户端与服务器通信
linux·服务器·c++·tcp/ip·计算机网络·tcp
努力实干2 小时前
C++ 面向对象与工程架构
开发语言·c++·架构
代钦塔拉2 小时前
【C++/Qt 进阶】设计模式系列(6):桥接模式——把“两个独立变化的维度“拆开
c++·qt·设计模式
青少儿编程课堂2 小时前
蓝桥杯·全国青少年信息素养大赛·电子学会Python/C/C++考级:任意进制互转与大数多进制加法解析
c++·python·算法·bfs·信息学竞赛
Shadow(⊙o⊙)2 小时前
仿muduo实现高并发服务器——EPOLL设置与移除Poller.hpp管理Channel
运维·服务器·c++
闻缺陷则喜何志丹2 小时前
【C++特性及类库】多线程
开发语言·c++·多线程·语言特性·系统库
多弗朗皮卡丘2 小时前
C++ 哈希(下)
c++·算法·哈希算法
敲代码的苦132 小时前
头歌数据结构-排序的实现及其应用
数据结构
dtq04243 小时前
C++3 - 分支语句if和string变量
开发语言·c++·学习