1. 迭代器模式到底在解决什么问题
1.1 从一个没法复用的遍历代码说起
如果你写过一阵子 C++,肯定见过这种代码:
std::vector<int> v{1, 2, 3, 4}; for (size_t i = 0; i < v.size(); ++i) { std::cout << v[i] << std::endl; }这段代码本身没毛病,但你想把它换到std::list上立刻就行不通。链表不提供随机下标访问,你要是强行用v[i],写出来的效率也会惨不忍睹。你想把“遍历一堆元素”这动作抽象出来复用,下标方案做不到。
迭代器模式解决的就是这个问题:把“怎么遍历”和“用什么容器存”彻底分开。遍历方只跟迭代器打交道,迭代器负责把容器内部的真实结构藏起来,对外暴露统一的++、*、==这些操作。你可以在不知道容器底层是数组、链表、红黑树还是哈希表的情况下,写出对它们一视同仁的算法代码。
你可以把迭代器理解成图书馆的索书牌。书库内部是分架区还是密集架,读者根本不需要关心;读者拿到的索书牌会精确指向某一本书,往前走一格就是下一本,接着往前走还能继续找到更多书。图书管理员只要保证索书牌能正确映射到每本书,读者的遍历流程就永远成立。这正是设计模式里典型的“行为型模式”:它解耦的不是数据结构本身,而是遍历这一行为。
1.2 为什么 C++ 把迭代器变成了“算法协议”
C++ 的迭代器和其他语言里的Iterator接口相比,另一个显著不同是:它不是靠虚函数约定,而是靠“结构化语义 + 编译期契约”。这意味着标准库算法(std::sort、std::find、std::accumulate)不关心你迭代器的具体类型是什么,只看你满不满足它需要的操作。满足多少能力,就用多少能力。
比如同样是找元素:
std::vector<int> vec{10, 20, 30, 40}; std::list<int> lst{10, 20, 30, 40}; auto it1 = std::find(vec.begin(), vec.end(), 30); auto it2 = std::find(lst.begin(), lst.end(), 30);这两行代码的调用形式完全一样。但内部实现路径完全不同:vector的迭代器是随机访问,底层内存连续,如果没有特殊优化,算法也可以一个个走过去;list的迭代器只能沿节点指针向前跳。对于使用者来说,写法不变,性能特征却一目了然——这正是 C++ 讲究“不为你不需要的东西买单”的体现。
学这个模式的时候,我最怕有人把它当成“八股概念”背下来。你真正要领会的是:迭代器是连接算法与容器之间的协议层。设计模式书籍里描绘的 UML 类图,落到 C++ 里就是这套类型契约和运算符语义。后面我会写一个完整可编译的自定义迭代器,你跟着走一遍,比背十张 UML 图都管用。
2. 理解边界:迭代器的分类与指针血缘
2.1 五个迭代器类别的层次关系
C++ 迭代器一共有五个分类,很多人看到input_iterator、output_iterator、forward_iterator、bidirectional_iterator、random_access_iterator就头大。其实你不用背全,只需要抓住一条主线:
输入/输出迭代器 → 前向迭代器 → 双向迭代器 → 随机访问迭代器这里输入和输出是两个独立分支,日常使用中你可以先粗略理解为“功能更弱的前向迭代器”。具体看:
- 输入迭代器:只能单向移动、
*读取、不能回退,典型代表是istream_iterator,它包裹一个输入流,每次++相当于从流里读下一个值。 - 输出迭代器:只能单向写入,
*配合赋值使用,典型代表是ostream_iterator。 - 前向迭代器:既能读也能写,还支持保存迭代器副本继续推进,典型代表是
forward_list的迭代器。 - 双向迭代器:在前向基础上支持
--,能往前走也能往后退,list、set、map的迭代器属于这一类。 - 随机访问迭代器:在双向基础上支持
it + n、it - n、it[n],甚至能做大小比较和求距离,vector、deque的迭代器以及原生指针都属于这一类。
为什么算法要区分这个层次?因为不同能力可以让算法走不同实现路径。std::distance(it1, it2)遇到随机访问迭代器时直接算it2 - it1,复杂度是 O(1);遇到双向迭代器时只能一个个++数过去,复杂度 O(n)。算法本身不笨,它在编译期通过迭代器的 category 标签做分发。
2.2 指针就是最朴素的迭代器
C++ 里原生指针天然满足随机访问迭代器的全部操作:*p解引用、p + n跳转、p1 - p2求距离,甚至p[n]走下标。所以数组可以不用写专门的迭代器类,直接用T*作为begin()和end()的返回类型。
这带来一个非常实用的启示:写自定义迭代器,本质上就是在模拟指针的语义。每写一个运算符重载之前,先问自己一句:如果这是一个原生指针,它应该有什么行为?照着这个思路去写,基本不会跑偏。
具体到代码实现上,绝大多数自定义迭代器内部就放一个指向容器元素的原始指针(或者指向链表节点的指针),然后把自己包装成“一个受控的指针”。比如我后面案例里的FixedArrayIterator,内部只有一个T* ptr_,所有操作都是基于这个指针转译出来的。
提示:你完全可以在某些简单容器里直接把
iterator定义成T*,这是合法且高效的。但它的缺点是丢失了迭代器 traits 里的额外信息,标准库在使用某些高级算法时可能把它当原生指针,行为不够精确。正式代码里还是建议写一个完整的迭代器类,至少做一次类型包装。
3. 手写一个可用的迭代器:从固定容器到完整实现
3.1 从一个固定容量容器开始
纸上谈兵没意思,我直接以一个“固定容量小数组容器”为例,把从容器到迭代器的每一步写出来。这种容器适合游戏里那些不想每次都做堆分配的场景,元素数量有上限,栈上放一块连续内存就够用。
先用最原始的方式实现容器:
template<typename T, std::size_t N> class FixedArray { public: using value_type = T; using iterator = T*; T* begin() { return buf_; } T* end() { return buf_ + N; } private: T buf_[N]; };这段代码能跑,range-for也能正常遍历。但它还不够“设计模式”:万一容器变成链表、跳表、B+ 树叶子链,T*立刻失效。为了真正理解迭代器模式,我下面把迭代器改成独立类。
3.2 迭代器类的核心实现与五个成员类型
自定义迭代器最容易被忽略的是五个嵌套类型。如果只把operator++和operator*写出来,很多算法编译不过,因为算法内部需要通过迭代器拿到元素类型、指针类型、差值类型等元信息。
template<typename T> class FixedArrayIterator { public: using iterator_category = std::random_access_iterator_tag; using value_type = T; using difference_type = std::ptrdiff_t; using pointer = T*; using reference = T&; FixedArrayIterator() : ptr_(nullptr) {} explicit FixedArrayIterator(T* p) : ptr_(p) {} reference operator*() const { return *ptr_; } pointer operator->() const { return ptr_; } FixedArrayIterator& operator++() { ++ptr_; return *this; } FixedArrayIterator operator++(int) { FixedArrayIterator tmp = *this; ++ptr_; return tmp; } FixedArrayIterator& operator--() { --ptr_; return *this; } FixedArrayIterator operator--(int) { FixedArrayIterator tmp = *this; --ptr_; return tmp; } private: T* ptr_; };为什么必须有这五个类型?因为 STL 内部大量依赖std::iterator_traits<It>::value_type这种机制。比如std::accumulate里需要定义累加的临时变量,如果不知道元素类型,编译直接失败。iterator_category则像一张“能力证明”,告诉算法我是随机访问的,你可以大胆用it + n和it - it。
这里我故意把指针类型定义成T*、引用类型定义成T&,意图也值得解释:当T是const U时,这个类自动变成const迭代器。后面第 4 节我会详细讲这一点。
3.3 补上随机访问操作并通过静态校验
既然标了random_access_iterator_tag,就必须把随机访问能力补齐,否则等于吹牛不兑现。至少要写下面这些:
FixedArrayIterator& operator+=(difference_type n) { ptr_ += n; return *this; } FixedArrayIterator& operator-=(difference_type n) { ptr_ -= n; return *this; } friend FixedArrayIterator operator+(FixedArrayIterator it, difference_type n) { it += n; return it; } friend FixedArrayIterator operator+(difference_type n, FixedArrayIterator it) { it += n; return it; } friend FixedArrayIterator operator-(FixedArrayIterator it, difference_type n) { it -= n; return it; } friend difference_type operator-(const FixedArrayIterator& a, const FixedArrayIterator& b) { return a.ptr_ - b.ptr_; } friend bool operator==(const FixedArrayIterator& a, const FixedArrayIterator& b) { return a.ptr_ == b.ptr_; } friend bool operator!=(const FixedArrayIterator& a, const FixedArrayIterator& b) { return a.ptr_ != b.ptr_; } friend bool operator< (const FixedArrayIterator& a, const FixedArrayIterator& b) { return a.ptr_ < b.ptr_; } friend bool operator> (const FixedArrayIterator& a, const FixedArrayIterator& b) { return a.ptr_ > b.ptr_; } friend bool operator<=(const FixedArrayIterator& a, const FixedArrayIterator& b) { return a.ptr_ <= b.ptr_; } friend bool operator>=(const FixedArrayIterator& a, const FixedArrayIterator& b) { return a.ptr_ >= b.ptr_; }之后把容器端改成返回自定义迭代器:
template<typename T, std::size_t N> class FixedArray { public: using value_type = T; using iterator = FixedArrayIterator<T>; iterator begin() { return iterator(buf_); } iterator end() { return iterator(buf_ + N); } private: T buf_[N]; };写完后最好用编译期断言验证一遍,而不是只看“能编译”就觉得没问题。C++20 之前可以用std::iterator_traits,C++20 以后直接用概念:
#include <concepts> static_assert(std::random_access_iterator<FixedArrayIterator<int>>);编译通过只能说明语法正确,但类型语义是不是真的满足随机访问,静态断言说了才算。我在写生产代码时,几乎每给迭代器加一个功能就加一个static_assert,因为迭代器一旦嵌入算法内部,运行期出错的排查成本远比编译期高。
4. 细节决定成败:const 迭代器与反向遍历
4.1 const 迭代器的正确打开方式
新手最容易翻车的地方就是 const 迭代器。很多人以为只要给容器加上const_iterator begin() const就万事大吉,结果在函数里直接被编译错误打脸。
你首先要清楚一件事:const 迭代器 = 指向 const 数据的迭代器,而不是“迭代器对象本身不可修改”。这两种语义完全不一样。
最简单的实现手法是让迭代器类模板化。用T可以既是普通类型,又是const U:
template<typename T> class FixedArrayIterator { // 上面第 3 节里的所有代码照抄 using value_type = T; };然后在容器里同时定义两种别名:
using iterator = FixedArrayIterator<T>; using const_iterator = FixedArrayIterator<const T>; iterator begin() { return iterator(buf_); } const_iterator begin() const { return const_iterator(buf_); } const_iterator cbegin() const { return const_iterator(buf_); }当T = const U时,operator*的返回类型会退化成const U&,调用方只能读取不能修改。这就是“同一个类模板,实例化两次,自然得到两种语义”的技巧,很多开源容器库都采用这个方案。
注意:别用
const FixedArrayIterator<T>作为const_iterator。那表达的是“迭代器对象本身不可变”,不是“指向的对象不可变”。这个错误一旦犯下,用户在 const 容器上还能通过迭代器修改元素,编译期根本发现不了隐患。
4.2 reverse_iterator 的“偏移一格”陷阱
标准库里的std::reverse_iterator是一个适配器。它包住一个正向迭代器,把++映射成内部正向迭代器的--,把--映射成内部的++。你可以直接用,没必要自己重造轮子:
using reverse_iterator = std::reverse_iterator<iterator>; reverse_iterator rbegin() { return reverse_iterator(end()); } reverse_iterator rend() { return reverse_iterator(begin()); }这里最隐蔽的坑是:rbegin()构造时传入的是end(),而不是最后一个元素的位置。因为reverse_iterator内部存放的永远是“逻辑当前位置的下一个位置”,取*rbegin()的时候,它要先做内部--,再解引用。
如果你以后自己实现反向迭代器,一定要记住这个“偏移一格”的语义。我自己就踩过这个坑:早期实现反向迭代器时直接在begin()上构造,结果首元素访问不到,还隔三差五触发未定义行为。后来把构造参数改成end(),所有问题一次性消失。
5. 让自定义迭代器在 STL 算法里跑起来
5.1 实战验证:用 accumulate / reverse / sort 做压力测试
迭代器写得好不好,拉进标准库算法里遛一遛就知道了。下面是三个最常用的验证场景:
#include <numeric> #include <algorithm> FixedArray<int, 4> arr{10, 20, 30, 40}; int sum = std::accumulate(arr.begin(), arr.end(), 0); // 100 std::reverse(arr.begin(), arr.end()); // arr 变为 {40, 30, 20, 10} std::sort(arr.begin(), arr.end()); // arr 恢复为 {10, 20, 30, 40}std::accumulate能通过说明输入迭代器语义没问题;std::reverse能通过说明双向迭代器的--实现正确;std::sort能通过说明随机访问和元素交换都正常。
我最喜欢拿std::sort当试金石,因为std::sort对迭代器要求极高:内部会频繁调用std::iter_swap、比较运算符、operator-计算中点左右位置,任何一处语义不对,排序结果就会错得不着边际,甚至直接崩溃。跑一次std::sort,等于给迭代器做了全套心肺体检。
5.2 用编译期断言守住迭代器契约
除了运行期测试,编译期静态校验也很重要。我习惯在文件底部写一批static_assert,让编译器充当永远不厌烦的测试框架:
static_assert(std::is_same_v< std::iterator_traits<FixedArrayIterator<int>>::iterator_category, std::random_access_iterator_tag>); static_assert(std::is_same_v< std::iterator_traits<FixedArrayIterator<int>>::value_type, int>);如果你用的是 C++20,可以直接写标准概念,更简洁也更符合现代写法。这些断言看着不起眼,但在后续维护中价值巨大:一旦有人把迭代器悄悄改成单链表版本,却忘了改iterator_category,算法可能选择了错误路径,运行期只表现为莫名其妙变慢或者越界。有断言兜底,错误在编译期就暴露了。
6. 迭代器使用中的经典翻车现场
6.1 迭代器失效,永远先怪容器
迭代器失效不是迭代器本身的问题,而是容器结构变化导致的。最常见的例子是std::vector扩容:
std::vector<int> v{1, 2, 3}; auto it = v.begin(); v.push_back(4); // 触发扩容,重新分配内存 // it 已经指向旧内存 —— 悬空! *it = 99; // 未定义行为这个跟自定义迭代器直接相关:如果你的容器内部用裸地址表示迭代器,一旦容器重新分配存储,所有旧迭代器集体失效。只是标准库容器已经明确规定了各自的失效规则,而你的自定义容器必须把规则写在文档或者注释里,否则用户会拿标准库的行为惯性来套你的容器,然后踩坑。
我自己的经验是:迭代器内部永远不要缓存“位置之外”的任何东西。比如不要缓存容器指针,更不要缓存某个索引值然后每次解引用时再去索引,那会让迭代器语义彻底乱套。最简单的底层表示永远是“一个直接指向元素的原始指针或节点地址”。
6.2 调试迭代器的几个土办法
迭代器的问题经常在算法内部暴露,而不是在你写的几行代码里。比如std::sort崩溃,堆栈上只能看到一堆模板内部函数,你自己写的迭代器代码早就被内联消失了。这时候我一般这么做:
- 临时在迭代器类的
operator*、operator++里加断言,检查指针是否落在容器边界内。 - 开启标准库调试模式:GCC/libstdc++ 用
_GLIBCXX_DEBUG宏,MSVC 用/D_ITERATOR_DEBUG_LEVEL=2。开启后标准库容器会帮你检查迭代器是否越界使用。 - 记录迭代器创建时的容器地址,解引用前再核对一遍,类似弱引用的作用。
第二个技巧特别值钱。默认编译模式下,STL 迭代器基本不做边界检查,很多越界问题只有在 debug 模式下才暴露。我遇到过一种死循环,就是自定义容器返回的end()比实际缓冲区多了一个元素,导致std::find一直读到了容器外部的内存。开了调试模式后,编译器立刻报告越界,问题瞬间定位。
6.3 不同容器迭代器的比较问题
两个来自不同容器的迭代器做==比较,在自定义迭代器里如果只是简单比较底层指针,很难被及时拦截。例如v1.begin() == v2.begin(),底层指针恰好指向同一块内存的极端情况也存在,但更常见的是它们指向不同地址,比较结果一直是false,你浑然不觉哪里出错。
标准库的态度是:比较不同容器的迭代器属于未定义行为。自定义迭代器也应该延续这个契约。如果你确实需要兼容这种误用,可以考虑在 debug 模式下缓存容器标识,比较前先查归属;但在 release 版本里为了性能,通常不做检查。我建议至少在注释里写明“跨容器比较未定义”,让使用者心里有数。
7. 现代 C++ 里,还需要亲手写迭代器吗
7.1 range-for 和标准库 ranges 带来的便利
C++11 的range-for让大部分人不再需要显式写出begin()和end()循环。你只要保证容器有这两个成员函数,遍历逻辑就成了三个字符的事。C++20 更进一步,推出了std::ranges和视图适配器:
std::vector<int> v{1, 2, 3, 4, 5}; auto even = v | std::views::filter([](int x) { return x % 2 == 0; }) | std::views::transform([](int x) { return x * x; });你在这段代码里一个迭代器都看不到,但ranges内部工作的核心依然是由一个个 view 迭代器串联完成的。问题是这些 view 迭代器的类型复杂得吓人,比如transform_view::iterator同时包装了内部迭代器和函数对象,解引用时调用变换函数,比较时又要同时比较两个内部迭代器。如果你不理解迭代器模式的基本协议,碰到这种复杂迭代器里的 bug,连下手的思路都没有。
7.2 哪些场景下还得自己写迭代器
现代 C++ 弱化了手写迭代器的需求,但远没有消灭它。我总结过至少四类场景绕不开:
- 自定义容器类型,尤其是内存紧凑的组件容器(比如游戏 ECS 里的扁平数组)。
- 对非容器资源做遍历封装,比如按行遍历一个二进制日志文件、把数据库查询结果集封装成迭代器。
- 天然非连续的容器:跳表、倒排索引、B+ 树的叶子链,这些结构没法用
std::vector替代。 - 不希望暴露裸指针的专用算法接口,但仍想享受 STL 算法的便利。
在这些场景里,迭代器模式不是“为了用模式而用”,而是在真正为数据结构建立面向算法的访问协议。
7.3 我个人的一些选择标准
这些年写容器和迭代器,我总结出几条实操标准,直接说给你听。
第一,先想清楚自己需要哪个层次的迭代器。如果你的容器只支持单向遍历,就别硬凑随机访问标签。标签写高了,算法会认为你可以 O(1) 跳转,一旦实际做不到,性能事故在运行期才暴露;标签写低了,算法只能走更慢的通用路径,也不会出大错。所以我的原则是:标签宁低勿高。
第二,迭代器越像指针越好。内部只存一个裸地址,运算符语义跟原生指针保持一致。不要为了炫技加缓存、加虚函数、加引用计数器,这些在迭代器高频调用场景里全是性能杀手。
第三,能复用标准库适配器就不要重复造轮子。std::reverse_iterator、std::istream_iterator、std::ostream_iterator这些现成的东西,比你自己写的绝大多数版本都稳定。真正需要上手写的,是那些标准库给不了你、必须绑定具体数据结构语义的迭代器。
以我个人的体会,迭代器模式在 C++ 里不是抽象的教条,而是一套非常“手艺人向”的工程契约。你把容器当作家,把算法当作客户,迭代器就是那位既能听懂客户要求、又对家里结构了如指掌的经纪人。这个中介设计得越简单、越贴近指针语义,后面所有依赖它的人都越省心。我建议你拿到一个自定义容器后,第一步就是认真写一个像样的迭代器,然后拿std::sort去压测它——这比任何设计模式书籍里的类图都能让你更快明白,这一层协议到底为什么存在。