我第一次用 std::sort 去排一个 std::list,编译报错报得那叫一个惨。模板错误刷了几十屏,最后 GCC 甩给我一句“no match for operator-”,我才反应过来:std::sort 要求随机访问迭代器,而 list 的迭代器只能双向移动。这件事对我触动挺大,因为在此之前,我写遍历循环从来都是 for (size_t i = 0; i < v.size(); i++),完全没意识到“遍历”这件事本身还有这么多讲究。后来系统性啃完迭代器模式,才明白它在 C++ 里根本不是一种可有可无的语法糖,而是整个 STL 泛型算法体系的基石,也是把容器、算法、适配器三类组件串起来的核心接口层。这篇文章我会从“迭代器模式到底解决了什么问题”讲起,把五种迭代器的能力阶梯、自定义迭代器的完整写法、迭代器失效的坑,以及 C++20 ranges 对迭代器体系的现代化改造一次性讲透。不管你是刚学 C++ 不久、被 STL 算法搞得一头雾水,还是写了一阵子业务代码、想搞明白怎么在自己的数据结构里也套用这套模式,这篇都应该能给你一些参考。
1. 从“算法不认 list”说起:迭代器到底解决了什么问题
1.1 std::sort 为什么拒绝 list
先回到开头的报错。std::sort 的签名要求传入的迭代器满足 RandomAccessIterator,它的实现里会做 it + n、it - it、it < it 这类操作,而 list 的迭代器只支持 ++it、--it、it != it,物理上根本没有“跳转”能力。链表的节点散落在内存各处,CPU 即使想跳也没法按偏移量定位——你要访问链表第 n 个节点,必须从前向后一步步走。
那 list 想排序怎么办?它自己实现了 std::list::sort(),底层是归并排序,只需要双向迭代器就够了。这就引出一个很关键的问题:排序算法明明有统一的抽象,为什么不能服务所有容器?
因为容器的存储结构决定了对元素进行"访问与移动"的代价。数组是连续内存,随机访问 O(1);链表是非连续内存,随机访问 O(n)。迭代器模式的精妙之处,就是把这些“存储结构差异”封装在迭代器内部,让算法只看迭代器暴露出的能力,而不关心背后到底连着数组还是链表。
1.2 迭代器模式的本质:把“怎么走”和“去哪”分离
如果让我用一句话概括迭代器模式,我会说:它把遍历逻辑从容器里抽出来,放到了一个独立的小对象里,这个小对象只对外暴露两个最基本的问题——“当前位置在哪”以及“下一个位置怎么走”。
类比生活里的场景,迭代器就是餐厅的服务员。你想吃饭(调用算法),不需要知道后厨食材放在哪个冰箱、调料摆在第几排,只需要跟服务员说“我要一份宫保鸡丁”。服务员负责从入口走到对应桌位、端菜、返回,这些遍历动作和顾客完全隔离。容器就是后厨,算法就是顾客,迭代器就是中间那个服务员。
这个“分离”带来了两个巨大的收益:
第一,算法可以和容器解耦。std::find 不关心你传进来的是 vector、list、deque 还是原生的 C 数组,只要迭代器满足“能前进、能解引用、能比较相等”这三个条件,算法就能正常工作。STL 里 100 多个算法,配合各种容器,理论上能组合出上千种用法,而不用像面向对象设计里那样为每个容器实现一套查找逻辑。
第二,遍历操作可以被组合和定制。迭代器本身是对象,所以你可以对迭代器做修饰、包装、过滤,比如逆序遍历、插入模式遍历、只读遍历、从输入流里“假装顺序遍历”……这些操作都是算法无关的,只是改变了迭代器“走法”。这就是后面要讲的迭代器适配器(iterator adapters)能玩出花样的基础。
很多人以为迭代器模式只是 STL 内部的一个实现细节,其实它是一种跨语言的通用设计模式。C# 的
IEnumerator、Java 的Iterator、Python 的__iter__/__next__,本质都是同一套思想——把集合的遍历行为抽象出来,让调用方可以统一操作。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 五类迭代器:能力阶梯决定代码边界
2.1 从 input 到 random_access,能力递增
C++ 标准把迭代器按能力划分成了五个等级。理解这套等级制度非常重要,因为每个 STL 算法都会声明它最少需要哪个等级的迭代器,等级不满足,编译直接失败——这是 C++ 在编译期就帮你拦住错误设计的手段。
我整理了一张表,列一下五种迭代器的能力和常见代表:
| 迭代器类别 | 能做什么 | 典型代表 | 可用于的算法示例 |
|---|---|---|---|
| Input(输入) | 只读、单遍扫描,++、*、== |
istream_iterator |
std::find、std::count_if |
| Output(输出) | 只写、单遍写入 | ostream_iterator、back_inserter |
std::copy 的目标 |
| Forward(前向) | 可读写、可多遍扫描 | forward_list 迭代器、unordered 容器迭代器 |
std::replace、std::unique |
| Bidirectional(双向) | 在前向基础上支持 -- |
list、set、map 迭代器 |
std::reverse、std::inplace_merge |
| Random Access(随机) | 在双向基础上支持 +n、[]、< |
vector、deque、原生指针 |
std::sort、std::binary_search |
注意这些能力是严格的包含关系:随机访问迭代器一定也是双向迭代器,双向迭代器也一定满足前向迭代器的要求。标准库通过 iterator_category 这个类型别名来标识迭代器的等级,算法在内部会根据这个标签选择不同的实现路径。
2.2 iterator_traits 与标签分发:STL 算法内部如何“看人下菜碟”
你可能好奇过,std::advance(it, n) 这个函数怎么做到对 vector 迭代器直接 it += n,对 list 迭代器却只能循环 n 次 ++it?答案就在标签分发(tag dispatch)机制里。
看一个简化版的实现思路:
cpp复制namespace detail {
template <typename Iter>
void advance_impl(Iter& it, int n, std::random_access_iterator_tag) {
it += n; // 随机访问迭代器有能力直接跳
}
template <typename Iter>
void advance_impl(Iter& it, int n, std::bidirectional_iterator_tag) {
if (n > 0) while (n--) ++it;
else while (n++) --it;
}
template <typename Iter>
void advance_impl(Iter& it, int n, std::input_iterator_tag) {
while (n--) ++it;
}
}
template <typename Iter>
void my_advance(Iter& it, int n) {
detail::advance_impl(it, n, typename std::iterator_traits<Iter>::iterator_category());
}
iterator_traits 可以从迭代器类型里提取出 iterator_category、value_type、difference_type 等信息,而且它对原生指针有偏特化版本,所以裸指针也能当成随机访问迭代器用。这个 traits 机制,本质上是 C++ 在编译期做“能力探测”的经典手段。你在自己的代码里也可以用同样的模式:定义标签类、重载不同版本的函数、用 iterator_traits<T>::iterator_category 触发分发,就能写出一套既能处理 vector 又能处理 list 的通用工具函数,而且性能上一点都不会浪费。
经验分享:写自定义迭代器时,最容易被踩的坑就是把
iterator_category误写成小写字符串或者其他类型。标准算法在编译期用iterator_traits提取这个别名时,类型不匹配会导致模板匹配失败,而且报错信息通常特别长。我建议写完自定义迭代器后,立刻用static_assert验证一下能力等级是否符合预期,比如static_assert(std::is_base_of_v<std::forward_iterator_tag, iterator_traits<MyIter>::iterator_category>)。
3. 手写一个链式迭代器:从 0 到能用 std::find
理论知识聊完,得落到代码上。我们来实现一个真正的迭代器,目标只有一个:让自己写的链表容器能配合 STL 算法使用。这个例子我会写得尽量简短,但五脏俱全。
3.1 自定义 Node 链与迭代器骨架
假设我有这样一个极简的单链表容器:
cpp复制#include <iterator>
#include <cstddef>
struct Node {
int value;
Node* next;
};
class IntList {
public:
using value_type = int;
class iterator {
public:
// 迭代器内部必须声明的五个类型别名,算法都靠它们工作
using iterator_category = std::forward_iterator_tag;
using value_type = int;
using difference_type = std::ptrdiff_t;
using pointer = int*;
using reference = int&;
explicit iterator(Node* n = nullptr) : m_node(n) {}
reference operator*() const { return m_node->value; }
pointer operator->() const { return &m_node->value; }
iterator& operator++() {
m_node = m_node->next;
return *this;
}
iterator operator++(int) {
iterator tmp = *this;
++(*this);
return tmp;
}
bool operator==(const iterator& other) const { return m_node == other.m_node; }
bool operator!=(const iterator& other) const { return !(*this == other); }
private:
Node* m_node;
};
iterator begin() { return iterator(m_head); }
iterator end() { return iterator(nullptr); }
private:
Node* m_head = nullptr;
};
这里最核心的部分是迭代器自增的前置与后置重载。前置 operator++() 返回引用,因为修改的是自身,没有必要拷副本;后置 operator++(int) 需要保存旧值、自增、再返回旧值,所以返回的是值。很多初学者不注意这个区别,把后置返回引用,一旦返回的是函数体内的局部临时对象,就是经典的悬垂引用崩溃现场。
3.2 让 std::find 认识你的迭代器
迭代器写完之后,std::find、std::count_if、std::for_each 这类只需要输入迭代器能力的算法理论上就能直接用了。为什么?
因为 std::find 内部的实现只做这几件事:
cpp复制template <typename InputIt, typename T>
InputIt find(InputIt first, InputIt last, const T& value) {
for (; first != last; ++first) {
if (*first == value) {
return first;
}
}
return last;
}
它只用了 !=、*、++,没有下标访问,没有随机跳转。所以只要我们的迭代器实现了这些运算符,STL 算法就能拿它正常干活。
试一下:
cpp复制#include <algorithm>
#include <cassert>
#include <vector>
int main() {
Node n1{1, nullptr};
Node n2{2, nullptr};
Node n3{3, nullptr};
n1.next = &n2;
n2.next = &n3;
IntList list;
list.set_head(&n1); // 假设你实现了这个辅助函数
auto it = std::find(list.begin(), list.end(), 2);
assert(it != list.end());
assert(*it == 2);
// 还可以配合范围 for 循环,编译期会自动展开成 begin()/end() 迭代器调用
std::vector<int> collected;
for (int x : list) {
collected.push_back(x);
}
assert(collected.size() == 3);
}
范围 for 循环能工作,是因为它会自动调用 begin() 和 end(),这个行为本质上也是迭代器模式的便利性体现。
但你如果试图对 IntList 调用 std::sort,编译依然会失败——因为 std::sort 要求 random_access_iterator_tag,而我们的迭代器声明的是 forward_iterator_tag。编译器在检查 iterator_category 时直接把这条路封死了。这不是缺陷,而是 C++ 故意设计的保护机制:在你写出恰好能跑但效率上完全失控的代码之前,编译期就拦住你。
3.3 const 迭代器与隐藏的转换坑
在上面的代码里我只写了 iterator,但实际工程中几乎必然会遇到 const 容器遍历的需求。标准容器都同时提供 iterator 和 const_iterator,而且两者之间会有隐式转换规则:普通迭代器可以隐式转换为 const 迭代器,反之不行。这很好理解——const 版本只是把解引用结果变成了 const 引用,从“可读写”变成“只读”不损失能力,所以允许转换;反过来从“只读”变“可读写”有安全隐患,就禁止转换。
自定义迭代器也应该遵循这个惯例。你可以这样扩展:
cpp复制class const_iterator {
public:
using iterator_category = std::forward_iterator_tag;
using value_type = int;
using difference_type = std::ptrdiff_t;
using pointer = const int*;
using reference = const int&;
explicit const_iterator(const Node* n = nullptr) : m_node(n) {}
reference operator*() const { return m_node->value; }
pointer operator->() const { return &m_node->value; }
const_iterator& operator++() {
m_node = m_node->next;
return *this;
}
bool operator==(const const_iterator& other) const { return m_node == other.m_node; }
bool operator!=(const const_iterator& other) const { return !(*this == other); }
private:
const Node* m_node;
};
然后让 iterator 里加一个转换为 const_iterator 的成员函数或构造函数即可。这里最常见的坑是在 const 成员函数里调用了返回 iterator 的 begin()。比如:
cpp复制void printAll(const IntList& list) {
for (auto it = list.begin(); it != list.end(); ++it) {
// 如果只有 iterator begin() 没有 const_iterator begin(),这里就会编译报错
}
}
解决办法是重载 const 版本的 begin()/end() 返回 const_iterator,同时也提供 cbegin()/cend()。所以你在自己设计容器接口时,四个函数最好一次配齐,而不是等编译报错了再补。
4. 迭代器失效是设计者的备忘录
说到迭代器模式,不能绕开迭代器失效(iterator invalidation)这个问题。这是所有从“下标遍历”切换到“迭代器遍历”的人都会遇到的一道坎。核心矛盾在于:迭代器内部保存的是容器内部的某个位置标记,而容器在插入或删除元素后,这个位置标记可能不再代表原意。
4.1 各容器迭代器失效情况对照
我把常用容器的失效规则整理成了一张表,方便查阅:
| 容器 | 插入操作 | 删除操作 | 备注 |
|---|---|---|---|
| vector | 如果导致 reallocation,所有迭代器失效;否则仅插入点之后的迭代器失效 | 删除点及之后的所有迭代器失效 | 容量变化是最大雷区 |
| deque | 两端插入时,所有迭代器失效(但引用不一定失效);中间插入全部失效 | 删除点附近的迭代器失效 | 规则较为复杂,建议一律视为全部失效 |
| list | 插入不影响现有迭代器 | 仅被删除元素自身的迭代器失效 | 最友善的容器 |
| forward_list | 插入不影响现有迭代器 | 仅被删除元素自身的迭代器失效 | 同上 |
| map/set/unordered_* | 插入不影响现有迭代器(unordered 发生 rehash 时除外) | 仅被删除元素自身的迭代器失效 | unordered 容器插入导致 rehash 时全部失效 |
这个表最值得注意的一点是:chain 类容器(list、forward_list)在删除操作时比数组类容器更容易编写正确代码,因为删除当前节点后的迭代器稳定性好;而 vector 在删除中间元素后,后面所有迭代器都失效。很多老手的习惯是用 std::remove_if + erase 处理 vector,而不是手写循环逐个 erase,根本原因就是手写循环时的迭代器管理太容易出错了。
4.2 循环内擦除的正确姿势
如果你需要在循环中删除满足条件的元素,这是两种完全不同的写法:
手写循环删除 vector 中的偶数:
cpp复制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); // erase 返回下一个有效迭代器
} else {
++it;
}
}
注意这里不能直接 v.erase(it) 后继续使用 it++,因为 erase 之后 it 已经失效了,任何解引用、递增操作都是未定义行为。上面的写法把 erase 的返回值重新赋给 it,是安全且标准的做法。
用 erase + remove_if 惯用法:
cpp复制v.erase(std::remove_if(v.begin(), v.end(),
[](int x) { return x % 2 == 0; }),
v.end());
std::remove_if 只会做复制迁移,不会真正改变容器的 size,它把应该保留下来的元素放到前面,返回一个指向“逻辑尾部”的迭代器,然后 erase 把后面的残留元素清掉。这充分利用了迭代器的“逻辑遍历”能力,是处理数组类容器批量删除的首选。
再补充一个 list 的注意点:std::list::erase 与 std::vector::erase 一样返回下一个迭代器,但 list 本身还有一个 remove_if 成员函数,底层效率更高。所以实际上如果你在写链表业务,直接用成员函数更明智:
cpp复制lst.remove_if([](int x) { return x % 2 == 0; });
这些例子背后是一个共同原则:迭代器失效的规则不是你需要背诵的考试题,而是容器设计者对“位置语义”的一种契约说明。 写代码前先在脑内跑一遍内存布局,想清楚这个迭代器在物理上指的是什么,很多事故就能避免。
5. 迭代器适配器与 C++20 ranges 的组合玩法
迭代器模式的另一个强大之处在于它天然支持组合。STL 里专门有一类“适配器”,把普通迭代器的行为包装成另一种更特殊的行为。这类适配器在现实工程里出镜率非常高。
5.1 三种插入器:往不同容器里“塞”数据
std::back_inserter、std::front_inserter、std::inserter 分别对应 push_back、push_front、insert 的迭代器包装。它们的共同点是实现了 operator* 返回自身,operator++ 空操作——也就是说,它们在赋值时把数据塞进容器,在“前进”时什么都不做。
一个常见用法是配合 std::copy 把数据从一个容器复制到另一个不同结构的容器:
cpp复制std::vector<int> src = {1, 2, 3, 4, 5};
std::list<int> dst;
std::copy(src.begin(), src.end(), std::back_inserter(dst));
这个例子虽然看起来平平无奇,但它背后是迭代器模式里非常经典的“解耦”思想:std::copy 不关心目标是什么容器,它只管对目标迭代器进行解引用赋值和自增;back_inserter 负责把赋值动作翻译成 push_back 调用。算法逻辑和插入语义完全分离,真正做到了各司其职。
如果你不借助插入器,就得先给 list 扩容再 copy,代码会重复写一堆 dst.push_back,而且没法通用化到其他容器上。
5.2 istream_iterator 让文件读取变得很“算法”
istream_iterator 是输入迭代器里最有代表性的一个。它把“从输入流读取一个 int”包装成了迭代器的解引用操作。之前提到过的“两行读文件”的写法:
cpp复制std::ifstream file("data.txt");
std::vector<int> data{
std::istream_iterator<int>(file),
std::istream_iterator<int>()
};
这里第二个参数是默认构造的 istream_iterator,表示流结束哨兵。整个语句本质上是在说:“把这个文件里的所有整数,都放进 vector 里”。读文件这件事突然变得像从一个容器里拷贝元素一样自然。
但要小心一个语法陷阱:如果写成 std::vector<int> data(std::istream_iterator<int>(file), std::istream_iterator<int>());,编译器可能把它解读成一个函数声明——返回类型是 vector<int>,参数是两个迭代器对象。这就是著名的“最令人烦恼的解析”(most vexing parse)。所以尽量用大括号初始化或者增加额外一对括号来规避。
5.3 ranges 视图:延迟求值的现代迭代器
C++20 引入了 <ranges>,从更抽象的层面对迭代器模式做了升级。你可以认为 std::views::filter、std::views::transform 是一类“惰性迭代器适配器”,它们包装底层迭代器,在每次自增/解引用时执行过滤或变换逻辑,但不会立刻生成新的容器。
cpp复制#include <ranges>
#include <vector>
#include <iostream>
std::vector<int> nums = {1, 2, 3, 4, 5, 6, 7, 8};
auto result = nums
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * n; });
for (int x : result) {
std::cout << x << " "; // 输出 4 16 36 64
}
这个 result 并不是一个存储了平方结果的 vector,而是一个视图(view)。遍历到底有多少次 filter/transform 被执行?答案是:真正的 filter 和 transform 动作都发生在 operator++ 和 operator* 被调用的时候,也就是 for 循环里。这种延迟求值模式避免了生成中间容器,内存和 CPU 都更省,代码却更接近人的思维。
如果你自己在设计一个大型系统的遍历逻辑,ranges 这种“管道式”组合能大幅减少临时对象和中间状态,尤其适合处理数据流的场景。它没有推翻迭代器模式,而是把迭代器模式的可组合性发挥到了极致。
6. 什么时候该自己写迭代器,什么时候别写
很多朋友看完前面的内容后会问:我平时用的都是现成的 vector、map,好像完全没有自己写迭代器的必要。这观察基本正确,但有两个场景是必须自己写迭代器的。
第一种是你拥有一个非 STL 风格的自定义数据结构,比如二叉树、N 叉树、内存池、跳表、或者一个基于文件偏移量的索引结构。如果这些数据结构想和算法组件打通,就得提供符合迭代器接口的包装。就像我第三节里的 IntList 一样,只要实现了迭代器,std::find、std::count_if、范围 for 循环全都立刻可用了。
第二种是把命令式代码封装成生成器风格。你有一个计算流程,比如“从传感器读取温度数据 → 过滤异常值 → 转换成摄氏度 → 统计均值”。正常情况下你可能写一堆循环加临时变量,但如果把数据源封装成一个输入迭代器,下游每一步都可以用 STL 算法或 ranges 来表达,整体代码会简洁很多,调试时也更容易定位问题在哪一层。
但是也有两个“别写”的建议:
第一,如果你只是想在现有 vector 外面包一层自定义类,不要重复造迭代器。直接 using iterator = std::vector<T>::iterator; using const_iterator = std::vector<T>::const_iterator; 然后把 begin/end 转发出去就够了。很多业务代码里出现“自定义迭代器”其实只是包装了标准容器,这属于过度设计。
第二,不要为了让一个自定义类“看起来像容器”而去强行模拟所有迭代器能力。比如一个用于显示日志行的类型,它本质上是一串字符串的视图,就没有必要提供随机访问迭代器。提供 forward_iterator 级别的 begin/end 就够了,使用者能遍历、能查找、能计数,完全满足日志分析场景。
从工程实践角度看,我自己写迭代器时还有一个习惯:把迭代器类直接嵌套在容器类内部(就像第三节的写法),这样类型查找和可读性都更好。另外,不论自定义容器内部是否用了标准容器,我都会在类的内部同步定义 value_type、size_type、difference_type 这些习惯性类型别名,因为这是 C++ 社区约定的一部分,泛型代码里到处都依赖它们。
最后分享一个经验之谈:写迭代器最该关注的是“语义正确性”而不是“编译通过”。我曾在一个自定义树结构上写了个简化版的迭代器,前后都能自增自减,编译也过了,结果在 std::distance 上跑出了一个负值,浪费了很久才意识到迭代器自增/自减没有严格遵循前闭后开区间语义。所以给迭代器做单元测试时,除了测 begin/end 的行为,一定要测边界条件:空容器、单元素容器、在 end 处前移、随机跳转到 begin 之前——这些角落最容暴露设计问题。
迭代器模式如果只从设计模式分类上看,属于“行为型模式”,但它在 C++ 里其实被升华成了语言和标准库层面的基础设施。理解了它,你在看 STL 源码、写泛型工具、或者设计自己的容器类时,都会觉得游刃有余;反过来不理解它,顶多能写点业务代码,一旦碰到模板报错就只能对着几十屏错误信息发愁。希望这篇能帮你把那层窗户纸捅破。
