迭代器模式:遍历聚合对象,C++ STL迭代器原理,自定义迭代器
迭代器模式,说白了就是提供一种统一的方式去遍历容器里的元素,而不暴露容器的内部结构。你想想看,我们写C++的时候,用vector、list、map,遍历方式各不相同。但有了迭代器,一切就变得整齐划一了。
我个人习惯把迭代器想象成「一个会走路的指针」。它知道当前指向哪个元素,也知道怎么走到下一个。这种抽象,让算法和容器彻底解耦——这也是STL设计的核心思想。
为什么需要迭代器模式?
假设你写了一个自定义的集合类,比如一个二叉树。如果没有迭代器,用户想遍历你的树,就得知道你的节点结构、遍历顺序。这太糟糕了。迭代器模式就是来解决这个问题的。
我在项目中遇到过这样一个场景:一个老同事写了一个自定义的图结构,遍历的时候要求调用方传入一个回调函数。后来需求变了,需要支持多种遍历顺序(BFS、DFS),回调函数里塞满了状态判断,代码变得一塌糊涂。我当时建议改成迭代器模式,每个迭代器只负责一种遍历策略,调用方用起来清爽多了。
C++ STL迭代器原理
STL的迭代器设计得非常精巧。它不是一个具体的类,而是一组概念(concept)。不同的容器提供不同能力的迭代器,但都遵循相同的接口约定。
STL迭代器分为五类,能力从弱到强:
| 迭代器类别 | 支持的操作 | 典型容器 |
|---|---|---|
| 输入迭代器 | 只读,单向,一次遍历 | istream_iterator |
| 输出迭代器 | 只写,单向,一次遍历 | ostream_iterator |
| 前向迭代器 | 读写,单向,可多次遍历 | forward_list |
| 双向迭代器 | 读写,可前可后 | list, set, map |
| 随机访问迭代器 | 读写,支持跳跃、下标、算术运算 | vector, deque, array |
为什么要有这么多分类?因为不同的容器底层结构不同,能提供的操作也不同。比如链表没法直接跳转到第N个元素,所以list的迭代器不支持 it + 5 这种操作。STL通过迭代器分类,在编译期就限制了不合理的操作。
自定义迭代器:从零开始写一个
嗯,这里要注意。自定义迭代器在C++里其实有一套固定的「套路」。你只需要按照STL的约定,实现几个必要的操作符就行了。
我以一个简单的动态数组为例,写一个只读的前向迭代器:
template<typename T>
class MyArray {
public:
// 迭代器类型定义
class Iterator {
public:
using iterator_category = std::forward_iterator_tag;
using value_type = T;
using difference_type = std::ptrdiff_t;
using pointer = const T*;
using reference = const T&;
Iterator(pointer ptr) : m_ptr(ptr) {}
reference operator*() const { return *m_ptr; }
pointer operator->() const { return m_ptr; }
// 前置++
Iterator& operator++() {
++m_ptr;
return *this;
}
// 后置++
Iterator operator++(int) {
Iterator tmp = *this;
++(*this);
return tmp;
}
bool operator==(const Iterator& other) const {
return m_ptr == other.m_ptr;
}
bool operator!=(const Iterator& other) const {
return !(*this == other);
}
private:
pointer m_ptr;
};
Iterator begin() const { return Iterator(m_data); }
Iterator end() const { return Iterator(m_data + m_size); }
// ... 构造、析构、增删等省略
private:
T* m_data;
size_t m_size;
};
你看,核心就是那几个操作符:*、++、==、!=。再加上类型别名,告诉STL这个迭代器的能力等级。
iterator_category,结果std::advance这种算法直接编译报错。STL内部会根据这个标签选择最优的实现。没有它,编译器就不知道你的迭代器能做什么。
迭代器适配器:站在巨人的肩膀上
STL还提供了一些迭代器适配器,它们不直接遍历容器,而是对遍历行为进行「加工」。
- 反向迭代器:
rbegin()/rend(),从尾到头遍历。底层其实就是把++映射成--。 - 插入迭代器:
back_inserter、front_inserter、inserter。赋值操作变成插入操作,非常适合配合std::copy使用。 - 流迭代器:
istream_iterator、ostream_iterator。把输入输出流当成容器来遍历。
举个例子,从标准输入读取整数,存入vector:
std::vector<int> vec;
std::copy(
std::istream_iterator<int>(std::cin),
std::istream_iterator<int>(), // 默认构造表示流结束
std::back_inserter(vec)
);
一行代码搞定。这就是迭代器模式的威力——算法和容器完全解耦,中间通过迭代器这座桥连接。
迭代器失效:一个必须警惕的问题
迭代器虽好,但有个大坑:迭代器失效。当你修改容器结构时(插入、删除、扩容),已有的迭代器可能变得无效。
我曾经在线上排查过一个bug,症状是程序偶尔崩溃,而且只在数据量大的时候出现。查了两天才发现,是有人在遍历vector时插入了新元素,导致迭代器指向了被重新分配的内存。嗯,从那以后,我写代码时对迭代器生命周期格外小心。
不同容器的失效规则不同:
- vector:插入或删除可能导致所有迭代器失效(因为可能重新分配内存)。
- list:插入不会使已有迭代器失效,删除只会使被删除元素的迭代器失效。
- map/set:插入不会使已有迭代器失效,删除只会使被删除元素的迭代器失效。
erase 的返回值来更新迭代器,或者先把要操作的元素记录下来,遍历完再统一处理。
迭代器模式的知识体系
下面这张图总结了迭代器模式的核心脉络:
迭代器模式在C++里不是「要不要用」的问题,而是「怎么用好」的问题。STL已经把它融入了语言的血脉。你写的每一个 std::sort、每一个 std::find,背后都是迭代器在默默工作。
最后说一句:如果你要写自己的容器,一定要提供迭代器。这不仅是为了STL算法的兼容,更是对使用者的一种尊重。好的接口设计,就是让别人用起来觉得「本该如此」。