C++ 迭代器五分类与失效场景汇总:一张表避开所有 UB

std::sort(lst.begin(), lst.end()) 编译不过,for (int x : v) { if (...) v.erase(...); } 跑起来会崩 ------ 这两个问题的根子是同一件事:迭代器不是一种东西,而是分等级的,而且会在容器改动时失效。这篇文章把「迭代器五分类」和「失效规则」两块拼成一张可查的表,以后遇到迭代器相关的报错直接对表。

1. 引子:sort 为什么拒绝 list

给一个 std::list<int> 排序,最直观的写法是照抄 vector 的套路:

cpp 复制代码
// 反例,不要这么写:list 的迭代器不满足 sort 的要求,编译就过不去
// std::list<int> lst{3, 1, 2};
// std::sort(lst.begin(), lst.end());

线上编译器 gcc 13 会给出「没有匹配的 sort 重载」加一串模板推导失败的注解,一句话概括是:std::sort 的模板参数被约束成「随机访问迭代器」,而 std::list 的迭代器是「双向迭代器」,差了一级。 list 只能改成:

cpp 复制代码
// 正确写法:list 自带一个成员函数 sort
// std::list<int> lst{3, 1, 2};
// lst.sort();

这不是标准库在挑刺,而是算法和容器之间的一份性能契约 :std::sort 内部是快速排序/内省排序,它需要「跳到第 n 个元素」「求两个迭代器的距离」「按下标二分」,这些操作只有随机访问迭代器才提供 O(1) 版本。list 的节点散落在堆上,it + n 只能靠一步步走,真放进去会退化成 O(n) 的随机访问,sort 的复杂度保证当场作废 ------ 所以标准干脆在类型层面拒绝它,让 list::sort 用更适合链表的自底向上归并排序来做。

官方文档:std::sort(明确写着要求 LegacyRandomAccessIterator)、std::list::sort(成员函数版本,稳定性有保证)

2. 五类迭代器:能力是层层递进的

标准库把迭代器按「能做什么」分成五类(iterator category)。类别之间是包含关系:高类别的迭代器自动满足低类别的一切要求。

text 复制代码
  输入迭代器              前向迭代器          双向迭代器          随机访问迭代器
  input iterator         forward            bidirectional       random access
  ──────────────         ────────           ─────────────       ──────────────
  只能读一次               能多趟遍历           能后退 --            能 +n / -n / it[n]
  只能 ++                 只能 ++              ++ / --              能比大小 <  <= >  >=
                                                                    能 O(1) 求距离

  典型来源                典型来源             典型来源             典型来源
  istream_iterator        forward_list         list / map / set     vector / deque / array
  (读文件、读 stdin)    unordered_map        (双向链表 / 红黑树) 原生指针 / string

  能力包含关系(箭头向右,能力只增不减):

     输入 ──► 前向 ──► 双向 ──► 随机访问
     输出 ──► 前向 ──► 双向 ──► 随机访问      ← 输出迭代器单独成一条线

  记住一句话:**算法声明要求哪一类,就等于声明它能用哪些操作。**
  给 std::sort(要求随机访问)传双向迭代器 = 缺了 +n 和 <,编译期就会被挡住。

把「能不能做」列成矩阵看得更清楚:

操作 输入 输出 前向 双向 随机访问
读取 *it 可以(只能读一次) 不可以 可以 可以 可以
写入 *it = v 不可以 可以 可以(元素可写时) 可以 可以
++it 前进 可以 可以 可以 可以 可以
保存 it 后再走一遍(多趟遍历) 不行 不行 可以 可以 可以
--it 后退 不行 不行 不行 可以 可以
it + n / it - n 不行 不行 不行 不行 可以
it[n] 随机下标 不行 不行 不行 不行 可以
it1 < it2 比位置前后 不行 不行 不行 不行 可以
it2 - it1 求距离 不行 不行 不行 不行 可以(O(1))
代表的操作 istream_iterator back_inserter forward_list list / map vector

「输入迭代器只能读一次」这条最反直觉:std::cin 消费的是字符流,读过去的字节回不来,所以保存一个 istream_iterator 然后想再走一遍是不成立的。

官方文档:迭代器库总览 --- cppreference、std::iterator_traits

C++20 把这一整套重写成了概念(concept) :std::input_iterator、std::random_access_iterator 等,还新增了一档 std::contiguous_iterator(连续迭代器,表示底层元素内存连续,vector / array / string 属于这一档)。分类思想没变,只是从「tag 继承」变成了「概念约束」,报错信息也因此友好得多。本文按 C++17 的 tag 体系讲,这是当前工作环境的口径。

3. 容器 ↔ 迭代器类别对照

容器 / 来源 迭代器类别 由此带来的限制
std::vector / std::array / std::string 随机访问 能用 sort,但插入/扩容极易失效
std::deque 随机访问 能用 sort,插入时几乎全部迭代器失效
std::list 双向 不能用 std::sort ,要用 list::sort;没有 < 比较
std::forward_list 前向 只能单向走;删除要拿到「前一个位置」
std::map / std::set / 多重版本 双向 不能用 std::sort;但本身就按 key 有序
std::unordered_map / std::unordered_set 前向 反向遍历和 --it 都没有;rehash 后全失效
std::istream_iterator / std::istreambuf_iterator 输入 单趟,读完就没了
std::ostream_iterator / std::back_insert_iterator 输出 只能写,不能读也不能比位置
原生指针 T* 随机访问(C++20 起为连续迭代器) 数组不够长就是越界 UB

这张表有个实用推论:「我想反向遍历」这件事在 unordered_* 上做不到 。因为它是前向迭代器,没有 rbegin() / rend()。要反序输出只能先收集到 vector 再 std::reverse。

4. 算法的最低迭代器要求速查

标准库算法在文档里都会标注所需的迭代器类别,这张表决定了「这个算法能不能用在这个容器上」:

算法 最低要求 因此不能用在这些容器上
std::find / std::count / std::for_each 输入 都能用
std::copy / std::transform 输入 → 输出 都能用
std::remove_if / std::unique 前向 都能用
std::rotate / std::inplace_merge 前向 / 双向 forward_list 之外基本都能用
std::reverse / std::next_permutation 双向 不能用 forward_list 和 unordered_*
std::lower_bound / std::equal_range 前向(C++11 起放宽) 都能用,但无序容器上语义无意义
std::sort / std::stable_sort 随机访问 不能用 list / map / set / unordered_*
std::partial_sort / std::nth_element 随机访问 同上
std::make_heap / std::push_heap 随机访问 同上
list::sort / forward_list::sort 容器内建 只在这两个容器上可用

官方文档:算法库总览 --- cppreference(每个算法页面都标了 LegacyIterator 要求)

5. advance / next / distance 与 const_iterator

std::advance / std::next / std::prev / std::distance 是四个「按迭代器类别自动选实现」的工具,也是观察类别的窗口。

cpp 复制代码
// iterator_utils.cpp --- 编译: g++ -std=c++17 -Wall -O2 iterator_utils.cpp -o demo
#include <cstddef>
#include <cstdio>
#include <iterator>
#include <list>
#include <vector>

int main() {
    const std::vector<int> v{10, 20, 30, 40, 50};
    const std::list<int> l{10, 20, 30, 40, 50};

    // distance:随机访问迭代器走「直接相减」O(1),其余类别只能一步步 ++ 到 O(n)
    std::printf("vector 距离 = %td(O(1):直接相减)\n", std::distance(v.begin(), v.end()));
    std::printf("list   距离 = %td(O(n):逐个 ++)\n", std::distance(l.begin(), l.end()));

    // next / prev:返回一个新迭代器,不修改入参
    const auto vit = std::next(v.begin(), 2);
    const auto lit = std::next(l.begin(), 2);
    std::printf("next(vector::begin, 2) = %d\n", *vit);
    std::printf("next(list::begin,   2) = %d\n", *lit);
    std::printf("prev(next(v.begin, 2)) = %d\n", *std::prev(vit));

    // advance:就地移动,没有返回值
    auto ait = l.begin();
    std::advance(ait, 3);
    std::printf("advance(list::begin, 3) = %d\n", *ait);

    // cbegin / cend:强制得到 const_iterator,从类型上禁止误改元素
    const auto cit = v.cbegin();
    std::printf("cbegin = %d\n", *cit);
}
text 复制代码
vector 距离 = 5(O(1):直接相减)
list   距离 = 5(O(n):逐个 ++)
next(vector::begin, 2) = 30
next(list::begin,   2) = 30
prev(next(v.begin, 2)) = 20
advance(list::begin, 3) = 40
cbegin = 10

两个实用结论:

  • 别在循环里对非随机访问容器调 std::distance。 每次调用是 O(n),套在循环里就变成 O(n²)。对 list 求长度用 l.size()(C++11 起是 O(1))。
  • cbegin() / cend() 是「只读」的编译期保证 ,begin() 在 const 对象上才返回 const_iterator。函数参数故意写成 const auto& 或者显式用 cbegin(),能让「本意只读」这件事被编译器守住。

官方文档:std::distance、std::advance、std::next

6. 迭代器失效大表(全篇最该收藏的一节)

「迭代器失效(iterator invalidation)」指的是:容器做了某个操作后,之前拿到的迭代器不能再用了,继续解引用或自增就是未定义行为(undefined behavior,UB)。标准对每个容器的每种操作都有明确规定,汇总如下。

容器 insert / emplace erase(it) push_back / pop_back 重分配 / rehash clear()
vector 未超 capacity → 插入点之后 的失效;超了 → 全部失效 被删元素及其之后的全部失效 push_back 同上(可能扩容);pop_back 只让被删元素失效 reserve / 扩容 → 全部失效 全部失效
deque 全部迭代器失效(但指向元素的引用/指针仍有效) 全部失效(元素本身还在的仍可引用) push_front / push_back → 全部失效 ;pop_* 只让被删元素失效 不适用 全部失效
list 只有 end() 失效 只有被删元素失效 只有 end() 失效 不适用 全部失效
forward_list 只有 end() / before_begin() 失效 只有被删元素失效 push_front 不影响其他迭代器 不适用 全部失效
map / set / 多重版本 全部保持有效 只有被删元素失效 不适用 不适用 全部失效
unordered_map / unordered_set 未 rehash → 保持有效;rehash → 全部失效 只有被删元素失效 不适用 rehash → 迭代器全失效,但指向元素的指针/引用仍有效 全部失效
string 同 vector;SSO 短字符串时引用也可能失效 同 vector 同 vector 同 vector 全部失效
array 不适用(定长) 不适用 不适用 不适用 不适用

三个最容易记混的点,拎出来单独说:

  1. vector 的「部分失效」有条件 。只有 capacity 够、不发生重新分配时,才是「插入点之后的失效」;一旦扩容,整块缓冲区搬家,所有迭代器、指针、引用全废。第 7 节会把这个搬家过程画出来。
  2. deque 是「迭代器比引用脆」的典型 。push_front / push_back 会让所有迭代器失效 ,但已经存在的元素不会搬家,所以指向元素的引用和指针仍然有效。这个区别在有外部缓存引用时很关键。
  3. list / map / set 是「节点式容器」,失效面最小 。插入不搬任何已有节点,所以除 end() 外全部迭代器保持有效;删除也只影响被删的那个。想让「指向元素的指针/引用长期稳定」,就得选节点式容器。

另外,C++11 起 insert 和 erase 都有返回值,这一点是修正失效问题的基础:

  • erase(it) 返回被删元素之后的下一个有效迭代器。
  • insert(...) 返回指向新插入元素的迭代器。

官方文档:容器库 ------ 迭代器失效规则汇总、std::vector 的失效说明、std::unordered_map 的失效说明

7. vector 扩容:迭代器失效的物理原因

前面反复提到「扩容导致全部失效」,跑一遍把这个过程看看清楚:

text 复制代码
  ① v.reserve(2) 后 push_back 两个元素,capacity 用满

       v 的控制块 {begin, end, cap_end}
          │
          ▼
        ┌─────┬─────┐
        │  1  │  2  │       堆上的缓冲区(capacity = 2)
        └─────┴─────┘
          ▲
          │  某个迭代器 it 也指向这里

  ② v.push_back(3) ------ capacity 不够,必须「另开一块更大的 + 把元素搬过去 + 释放旧的」

        新缓冲区(capacity = 4)
        ┌─────┬─────┬─────┬─────┐
        │  1  │  2  │  3  │  ---  │
        └─────┴─────┴─────┴─────┘
          ▲
          │  v.begin() 现在指这里

        ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
        │  1  │  2  │  (旧缓冲区已释放)
        └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
          ▲
          │  it 还指向这块已经失效的内存 → 悬垂迭代器(dangling iterator)
             再用它解引用或 ++,就是 UB
cpp 复制代码
// vector_realloc.cpp --- 编译: g++ -std=c++17 -Wall -O2 vector_realloc.cpp -o demo
#include <cstdio>
#include <vector>

int main() {
    std::vector<int> v;
    v.reserve(2);                       // 预分配 2 个位置
    v.push_back(1);
    v.push_back(2);

    const int* buffer_before = v.data();    // 记录缓冲区首地址,只用来比较「有没有搬家」
    std::printf("填满 capacity 后: size=%zu capacity=%zu\n", v.size(), v.capacity());

    v.push_back(3);                     // 超出 capacity → 重新分配 + 搬迁
    std::printf("再插一个之后    : size=%zu capacity=%zu\n", v.size(), v.capacity());
    std::printf("缓冲区搬家了吗: %s\n",
                v.data() == buffer_before ? "没有(迭代器仍有效)" : "搬了(旧迭代器全部失效)");
}
text 复制代码
填满 capacity 后: size=2 capacity=2
再插一个之后    : size=3 capacity=4
缓冲区搬家了吗: 搬了(旧迭代器全部失效)

所以「提前 reserve(n)」不只是性能优化(少几次分配和拷贝),它还是避免迭代器失效 的手段 ------ 容量一次给够,后面就不会因为扩容而整块搬家。这也是为什么《vector 扩容策略与迭代器失效全解》里反复强调「知道元素数量就 reserve」。

官方文档:std::vector::reserve、C++ Core Guidelines --- SL.con.2 默认选 vector

8. 遍历中删除元素:两种必崩的写法

这是迭代器失效在真实代码里最高频的翻车现场。

cpp 复制代码
// 反例 1,不要这么写:erase 后 it 已经失效,下一轮 ++it 是 UB
// for (auto it = v.begin(); it != v.end(); ++it) {
//     if (*it % 2 == 0) {
//         v.erase(it);      // it 失效;紧接着 for 的自增 ++it 踩在失效迭代器上
//     }
// }

// 反例 2,不要这么写:范围 for 的隐藏迭代器改不了,删元素必然踩空
// for (int x : v) {
//     if (x % 2 == 0) {
//         v.erase(v.begin());   // 隐藏迭代器不知道这回事,下一次自增就是 UB
//     }
// }

范围 for 会被展开成 auto&& __range = v; auto __it = begin(__range); ... ++__it,其中 __it 是编译器的隐藏变量,你拿不到也改不了 。既然没法把它更新成 erase 的返回值,范围 for 里就不能删元素 ------ 这是硬结论,不是风格问题。

正确写法只有一个模式:手动写循环、把 erase 的返回值接回来。

cpp 复制代码
// erase_pattern.cpp --- 编译: g++ -std=c++17 -Wall -O2 erase_pattern.cpp -o demo
#include <cstdio>
#include <iterator>
#include <list>
#include <map>
#include <vector>

int main() {
    // ① vector:erase 返回下一个有效迭代器,必须用它续上
    std::vector<int> v{1, 2, 3, 4, 5, 6};
    for (auto it = v.begin(); it != v.end();) {
        if (*it % 2 == 0) {
            it = v.erase(it);       // 关键:用返回值续上,不要 ++it
        } else {
            ++it;                   // 不删才前进
        }
    }
    std::printf("vector 删偶数后: ");
    for (int x : v) {
        std::printf("%d ", x);
    }
    std::printf("\n");

    // ② map:同一个模式,而且 erase 只让被删元素失效,其他迭代器不受影响
    std::map<int, int> m{{1, 10}, {2, 20}, {3, 30}, {4, 40}};
    for (auto it = m.begin(); it != m.end();) {
        if (it->first % 2 == 0) {
            it = m.erase(it);
        } else {
            ++it;
        }
    }
    std::printf("map 删偶数 key 后大小 = %zu\n", m.size());

    // ③ list:还是同一个模式(list 是双向迭代器,没有 +n,用 std::next 前进)
    std::list<int> lst{1, 2, 3, 4, 5, 6};
    for (auto it = lst.begin(); it != lst.end();) {
        if (*it % 2 == 0) {
            it = lst.erase(it);
        } else {
            it = std::next(it);
        }
    }
    std::printf("list 删偶数后大小 = %zu\n", lst.size());
}
text 复制代码
vector 删偶数后: 1 3 5 
map 删偶数 key 后大小 = 2
list 删偶数后大小 = 3

模式只有三行,记住它的形状就行:

text 复制代码
   for (auto it = c.begin(); it != c.end(); )   ← 注意 for 的第三格是空的
   {
       if (要删的条件)  it = c.erase(it);         ← 接住返回值,迭代器自己前进
       else             ++it;                    ← 不删才手动前进
   }

   口诀:**删了就接返回值,没删就手动 ++;绝不同时做两件事。**

C++20 还给了更省事的写法:std::erase_if(container, pred),一行搞定,内部就是上面这个循环。

cpp 复制代码
// verify: std=c++20
// 需要 C++20:std::erase_if 统一了「按条件删除」的写法
// std::erase_if(v, [](int x) { return x % 2 == 0; });
// std::erase_if(m, [](const auto& kv) { return kv.first % 2 == 0; });

9. 完整示例:靠迭代器类别做编译期分派

最后把「类别」这件事用起来。标准库自己就是这么干的(std::distance 内部按类别分派),我们也能用 std::iterator_traits + if constexpr 写一个「随机访问就 O(1) 求长度,否则老老实实数」的版本:

cpp 复制代码
// category_dispatch.cpp --- 编译: g++ -std=c++17 -Wall -O2 category_dispatch.cpp -o demo
#include <cstddef>
#include <cstdio>
#include <forward_list>
#include <iterator>
#include <list>
#include <map>
#include <type_traits>
#include <vector>

namespace {

// 按迭代器类别在编译期选实现:随机访问直接相减,其余类别逐个 ++
template <typename It>
std::size_t countElems(It first, It last) {
    using Category = typename std::iterator_traits<It>::iterator_category;
    if constexpr (std::is_base_of_v<std::random_access_iterator_tag, Category>) {
        return static_cast<std::size_t>(last - first);   // O(1)
    } else {
        std::size_t n = 0;
        for (; first != last; ++first) {                 // O(n)
            ++n;
        }
        return n;
    }
}

}  // namespace

int main() {
    const std::vector<int> v{1, 2, 3, 4, 5};
    const std::list<int> l{1, 2, 3};
    const std::forward_list<int> f{1, 2, 3, 4};
    const std::map<int, int> m{{1, 10}, {2, 20}};

    std::printf("vector       个数 = %zu(随机访问,O(1) 相减)\n", countElems(v.begin(), v.end()));
    std::printf("list         个数 = %zu(双向,O(n) 逐走)\n", countElems(l.begin(), l.end()));
    std::printf("forward_list 个数 = %zu(前向,O(n) 逐走)\n", countElems(f.begin(), f.end()));
    std::printf("map          个数 = %zu(双向,O(n) 逐走)\n", countElems(m.begin(), m.end()));
}
text 复制代码
vector       个数 = 5(随机访问,O(1) 相减)
list         个数 = 3(双向,O(n) 逐走)
forward_list 个数 = 4(前向,O(n) 逐走)
map          个数 = 2(双向,O(n) 逐走)

这段代码正好收束了全篇的主线:

  • std::iterator_traits<It>::iterator_category 是「问编译器这个迭代器属于哪一类」的标准入口。
  • if constexpr(C++17)让分支在编译期就被丢弃 ------ last - first 这段代码对 list 根本不会被实例化,所以即使双向迭代器没有 operator-,编译也不会报错。这是泛型代码里处理「能力差异」的标准手法。
  • std::is_base_of_v 之所以能用来判断类别,正因为五类迭代器的 tag 之间是继承关系 (random_access_iterator_tag 继承自 bidirectional_iterator_tag,一路到 input_iterator_tag),这也是第 2 节那张包含关系图的类型层面体现。

官方文档:std::iterator_traits、std::random_access_iterator_tag、if constexpr(C++17)

10. 延伸阅读

11. 一句话总结

迭代器的类别决定了「能对容器做什么」,容器的失效规则决定了「拿到手的迭代器还能活多久」------算法挑类别(sort 要随机访问,list 不满足)、代码避失效(vector 扩容全废、节点容器最稳)、遍历中删除一律走「it = c.erase(it) 或 ++it,二选一」这一个模式。三件事记牢,迭代器相关的 UB 基本就绝迹了。

相关推荐
0+1111 小时前
算法 --模拟
c++·算法·leetcode
hetao17338371 小时前
2026-10-01~02 hetao1733837 的刷题记录
c++·算法
郝学胜-神的一滴2 小时前
AI 编程智能体 03:拆解当下主流智能体能力
开发语言·c++·人工智能·python·程序人生·游戏
宵时待雨3 小时前
linux笔记归纳25:多路转接epoll
linux·服务器·网络·c++
Frank_refuel3 小时前
【C++项目】手撕JRpc框架(单机怎么优化到分布式思想)
开发语言·c++
ShineWinsu3 小时前
对于Redis:Hash类型的解析
c++·redis·分布式·缓存·面试·hash·哈希表
wuminyu3 小时前
JEP491中synchronized关键字引发的平台线程钉住解决方法简介
java·linux·c语言·jvm·c++
All for pursuit.4 小时前
【贪心-4】581.最短无序连续子数组
数据结构·c++·算法·leetcode
无名猿4 小时前
list 与 forward_list:链表真的比 vector 快吗
c++·性能优化·stl·内存管理·标准库