1. 迭代器模式到底解决了什么问题
1.1 算法与容器解耦:一份代码,处处能跑
先抛个问题:你写的std::sort既能排std::vector<int>,又能排std::deque<double>,还能排原生数组,凭什么?要是没有迭代器模式,标准库就得给每种容器单独写一份排序——那代码量得膨胀到没法维护。而迭代器的精髓,就是把"取元素""跳位置"这一类行为抽象成一组统一的操作符:*、++、==、!=、--。只要一个类型实现了这套"操作协议",算法就能拿它当抽象的指针用,完全不关心背后是连续内存、链表节点还是键盘输入。
我用一个日常比喻来解释这件事:你到饭馆吃饭,菜单就是"容器",服务员就是"迭代器"。你不需要知道菜在厨房怎么存放、用哪个锅炒的,你只需要跟服务员说"我要下一道菜"。服务员把你指向的东西端上来,你吃完说"下一个",他就继续往厨房深处走。饭馆后厨可以从明档改成中央厨房,你点菜的体验完全不受影响。C++里的容器就是后厨,迭代器就是那个服务员,算法就是你点菜的套路。
这个抽象带来的直接收益有两个。第一,算法只需写一次,sort能处理任何"可以随机访问、可以交换元素"的序列;第二,容器内部实现可以自由调整,只要迭代器接口稳定,使用方代码一行都不用改。C++比 Java、Python 更"较真"的地方在于,它把这个抽象做成了编译期约定,而不是运行时接口。也就是说,迭代器的"能力"全部体现在类型中,编译器在编译时就知道这段代码能不能跑,不需要虚函数表,不需要运行时分发,性能上几乎零开销——这正是 C++ 迭代器模式最迷人的地方。
1.2 为什么C++迭代器选择"零成本抽象"
很多从 Java 转过来的朋友一开始会不习惯:Java 里的迭代器是个接口,Iterator<T>有hasNext()、next()、remove(),所有集合类都实现这个接口,用多态来统一。C++ 则完全不一样,std::vector<int>::iterator很可能就是一个裸指针的包装,它的++、*都是直接内联编译成机器指令的,根本没有运行时类型信息。原因只有一个:性能。STL 的设计哲学是"如果你不需要为抽象付钱,那就不该付钱"。虚函数调用一次可能就多几条指令的间接跳转,在百万级元素的排序里,这种开销会被放大到难以接受。
但这并不意味着 C++ 迭代器是"弱化版"的迭代器模式。它实际上是把这个模式做到了更深的层次:通过iterator_traits和标签分派,算法可以针对不同能力的迭代器选择不同实现。std::advance(it, n)对随机访问迭代器直接it += n,对单向迭代器就只能老老实实循环 n 次。这种"按能力分派"的思路,比单纯的多态接口更精细、更高效。说白了,C++ 是把设计模式的"意图"保留了下来,把"实现手段"换成了更适合自身的编译期机制。
2. 五种迭代器类别:能力矩阵决定写法
2.1 从输入到随机访问:能力逐级升级
C++ 标准把迭代器分成五种:输入迭代器、输出迭代器、前向迭代器、双向迭代器、随机访问迭代器。它们不是并列关系,而是能力逐级递增的阶梯。理解这五种类别,基本上就理解了大半个 STL 算法库的行为边界。
输入迭代器只能读取,而且只能单向移动,istream_iterator就是典型。你可以把它理解成"只读的一次性卷尺"——它允许你*it拿到当前值,++it往后走,但你无法回头,也无法对它所指向的东西赋值。输出迭代器反过来,只能写入不能读取,ostream_iterator和back_inserter都是这种。它俩就像两个单向管道,一个往程序里灌数据,一个把数据推出去。
前向迭代器则要求既能读又能写(或者至少能读),并且支持多次遍历,std::forward_list的迭代器就是典型。它好比一条单行道,你只能往前走,但可以走很多遍。双向迭代器在前向的基础上增加--,允许向前和向后移动,std::list、std::map、std::set的迭代器都属于这一类。随机访问迭代器是能力天花板,支持it + n、it - n、it[n]、it1 < it2这类操作,std::vector、std::deque和原生数组的迭代器都满足它。这一级相当于你能记住每个门牌号,直接跳到任意位置,而不是一个个数过去。
我把它们的核心能力整理成了一张表,方便对照:
| 能力 | 输入迭代器 | 输出迭代器 | 前向迭代器 | 双向迭代器 | 随机访问迭代器 |
|---|---|---|---|---|---|
| 读取当前元素 | 支持 | 不支持 | 支持 | 支持 | 支持 |
| 写入当前元素 | 不支持 | 支持 | 支持 | 支持 | 支持 |
单向++ | 支持 | 支持 | 支持 | 支持 | 支持 |
反向-- | 不支持 | 不支持 | 不支持 | 支持 | 支持 |
+= n、+ n | 不支持 | 不支持 | 不支持 | 不支持 | 支持 |
it1 - it2 | 不支持 | 不支持 | 不支持 | 不支持 | 支持 |
| 典型代表 | istream_iterator、istreambuf_iterator | ostream_iterator、插入迭代器 | forward_list迭代器 | list/map/set迭代器 | vector/deque/数组迭代器 |
看到这张表,你就明白为什么std::sort要求随机访问迭代器,而std::list不能直接排——因为快排需要频繁跳转位置,而链表的迭代器做不到。标准库为此提供了一个折中方案list::sort(),它内部实现的是归并排序。这就是迭代器类别对算法选择的直接影响。
2.2 iterator_category:告诉算法"我能做到什么"
每种迭代器都必须公开一个iterator_category类型,算法通过它来感知迭代器的能力等级。这个类型是标签分派机制的核心。当你在代码里写:
std::sort(v.begin(), v.end());编译器会检查v.begin()返回的迭代器类型,从它的iterator_category判断是否为std::random_access_iterator_tag。不符合就编译报错,把问题拦截在编译期,而不是等你运行到一半炸出一个莫名其妙的崩溃。
标签分派最经典的例子是std::advance(it, n)的实现思路。它的逻辑大致是:
template <typename Iterator> void advance_impl(Iterator& it, std::ptrdiff_t n, std::random_access_iterator_tag) { it += n; // O(1) 直接移动 } template <typename Iterator> void advance_impl(Iterator& it, std::ptrdiff_t n, std::forward_iterator_tag) { while (n--) { ++it; } // 只能一个个挪 }同一个函数名,不同类型的iterator_category参数,编译器会自动选择不同的重载版本。这种方式在 C++17 及之前是标准做法。到了 C++20,这个概念被正式化成 concept,比如std::random_access_iterator<Iter>、std::bidirectional_iterator<Iter>这类约束直接出现在算法模板签名里,报错信息也比以前友好得多。但底层的思想完全没变,迭代器仍然通过类型成员和特性萃取向算法"自报家门"。
写自定义迭代器时,iterator_category是必须提供的,否则很多算法直接拒绝编译。我会在下一节完整演示怎么实现一个能跑的迭代器,到时候每个成员都会展开说。
3. 手写一个真实可用的迭代器:整数区间(Range)
3.1 设计目标与接口清单
纸上谈兵半天,不如动手写一个。我选一个非常实用的场景:实现一个类似 Pythonrange()的整数区间迭代器。为什么选它?因为这种"生成数值序列"的需求在业务代码里太常见了——从 1 数到 100、按步长跳跃、倒着数,几乎每个项目都能碰到。更重要的是,这个例子足够简单,能把迭代器该有的接口全部展示出来,又不至于被复杂的容器结构分心。
我们的需求列表是这样的:
- 支持
Range r(1, 10),让for (auto v : r)输出 1 到 9(左闭右开)。 - 支持步长,比如
Range(0, 10, 2)输出 0、2、4、6、8。 - 支持负步长,比如
Range(5, 1, -1)输出 5、4、3、2。 - 能和 STL 算法协同工作,比如
std::copy、std::accumulate、std::find。 - 步长为 0 时明确报错,而不是无限循环或阴间行为。
按照迭代器模式的标准结构,我需要两个类:RangeIterator负责"怎么走、怎么取值",Range负责"从哪里开始、到哪里结束"。Range其实扮演的就是"聚合对象"的角色,它只需要提供begin()和end(),剩下的都交给迭代器。
3.2 完整代码与逐行拆解
先给出一份能直接编译运行的完整实现,用的是 C++17 标准:
#include <cstddef> #include <iterator> #include <stdexcept> class RangeIterator { public: using iterator_category = std::forward_iterator_tag; using value_type = long; using difference_type = std::ptrdiff_t; using pointer = const long*; using reference = long; RangeIterator() : current_(0), step_(1) {} explicit RangeIterator(long current, long step) : current_(current), step_(step) {} long operator*() const { return current_; } RangeIterator& operator++() { current_ += step_; return *this; } RangeIterator operator++(int) { RangeIterator tmp = *this; ++(*this); return tmp; } bool operator==(const RangeIterator& other) const { if (step_ > 0) return current_ >= other.current_; return current_ <= other.current_; } bool operator!=(const RangeIterator& other) const { return !(*this == other); } private: long current_; long step_; }; class Range { public: Range(long begin, long end, long step = 1) : begin_(begin), end_(end), step_(step) { if (step_ == 0) throw std::invalid_argument("step cannot be zero"); if ((step_ > 0 && begin_ >= end_) || (step_ < 0 && begin_ <= end_)) begin_ = end_; // 空区间 } RangeIterator begin() const { return RangeIterator(begin_, step_); } RangeIterator end() const { return RangeIterator(end_, step_); } private: long begin_; long end_; long step_; };接下来拆解每个细节。先看类型成员那一串using。这些是可选的,在 C++17 之前用std::iterator继承也能达到效果,但那个基类已经在 C++17 被标记为废弃,现代写法就是直接在类里写类型别名。iterator_category = std::forward_iterator_tag声明了能力等级,value_type = long告诉算法解引用得到什么类型,difference_type = std::ptrdiff_t是std::distance、std::advance这类算法使用的差值类型。reference = long这里尤为重要——它是值类型而不是引用。因为我们的迭代器是"生成值"而不是"指向内存中的某个已有对象",所以operator*返回long本身,这也是生成器式迭代器和传统容器迭代器的关键差异。
operator==和operator!=的实现有必要多说一句。我没有简单地比较current_ == other.current_,而是根据步长方向用>=或<=判断。为什么?考虑Range(1, 10, 3),它会输出 1、4、7,然后current_变成 10。如果用精确相等比较,那么end()的当前值是 10,迭代器走到 10 时两者相等,能正常结束。但假设Range(1, 10, 4),输出 1、5、9,接着current_变成 13,和end()的 10 永远不相等,循环就会失控,一直数到溢出。所以,对于"开放区间"式的迭代器,比较时必须考虑方向。正步长下,只要当前值已经越过终点,就认为到达末尾;负步长同理。这是自定义迭代器最容易被忽视的隐藏 bug。
begin()和end()的写法也很讲究。end()返回的不是一个"指向最后一个元素之后位置"的迭代器吗?这里直接用它收尾值做参数。这种设计叫作"哨兵迭代器",它不指向真实存在的元素,只作为遍历结束的标记。因为我们的比较逻辑包含了越界判断,所以这种哨兵是可靠的。
3.3 用标准库算法验证它
写完之后必须实测,代码能跑才是硬道理。我用std::accumulate求和、std::copy输出到控制台、std::find查元素,三管齐下验证它的通用性:
#include <algorithm> #include <iostream> #include <numeric> #include <vector> int main() { Range r(1, 11, 2); // 1 3 5 7 9 for (long v : r) { std::cout << v << ' '; } std::cout << '\n'; long sum = std::accumulate(r.begin(), r.end(), 0L); std::cout << "sum = " << sum << '\n'; std::vector<long> values(r.begin(), r.end()); auto it = std::find(values.begin(), values.end(), 5); if (it != values.end()) { std::cout << "found: " << *it << '\n'; } Range down(5, 1, -1); // 5 4 3 2 std::copy(down.begin(), down.end(), std::ostream_iterator<long>(std::cout, " ")); std::cout << '\n'; return 0; }实测输出完全符合预期。这里有一个非常关键的点:std::vector<long> values(r.begin(), r.end())这一行,向量构造函数接收两个迭代器,能够一次性装入整个区间。它内部做的事情其实就是一个循环push_back,但这行代码背后是 C++ 迭代器模式最完美的体现——Range根本不知道vector存在,vector也不知道Range存在,它们通过迭代器协议完成了数据转移。这就是解耦的威力。
我实际测试中还发现一个容易踩的坑:Range(10, 0)这种空区间,begin()和end()比较结果为相等,范围 for 循环直接跳过,行为正确。但如果有人误写Range(0, 10, -1),构造函数检测到正步长矛盾,会把它置为空区间,而不是默默产生一个诡异序列。这类防御性设计,在自定义迭代器里值得花几行代码埋上。
4. 迭代器适配器:标准库里被低估的四种武器
4.1 反向迭代器与base()的偏移陷阱
写完了自定义迭代器,再看标准库提供的适配器就轻松多了。适配器是迭代器模式上的装饰层:它们把一种迭代器包装成另一种行为,不改动底层容器。最常用的是反向迭代器,通过rbegin()、rend()获得。std::reverse(vec.begin(), vec.end())本质上就是利用反向迭代器在算法层面完成倒序。
反向迭代器最经典的坑是base()的语义。很多人以为rit.base()指向的是rit当前所指的元素,其实不是。拿std::vector<int> v{1, 2, 3, 4, 5}来说,v.rbegin()指向 5,但v.rbegin().base()指向的是 4 后面的位置,也就是正常迭代器视角下 5 的后继。因为反向迭代器的物理实现是"指向下一个元素,解引用时向前看一位"。这个偏移在删除元素时直接造成错误:
auto rit = v.rbegin() + 2; // 指向 3 v.erase(rit.base()); // 错!实际删除的是 4 v.erase(std::prev(rit.base())); // 对,先往前挪一步再删这种问题在代码 review 里特别容易漏掉,因为单看rit.base()的直觉很容易被误导。我的个人经验是:凡是涉及反向迭代器转正向操作,默认先画一个五元素小数组在纸上推演一遍,别靠脑子直接写。
4.2 插入迭代器:让copy省心,让容器省力
插入迭代器是输出迭代器家族的重要成员,std::back_inserter、std::front_inserter、std::inserter三个函数分别对应尾插、头插、指定位置插入。它们解决的核心痛点是:std::copy只能往已有空间写,而插入迭代器在赋值时会把值"塞进"容器,让容器自动扩容。最典型的场景是:
std::vector<int> src{1, 2, 3}; std::vector<int> dst; std::copy(src.begin(), src.end(), std::back_inserter(dst)); // 如果不用 back_inserter,就得先 dst.resize(src.size()),巨丑每次给back_inserter赋值,它内部就调用一次dst.push_back(value)。这种"赋值即插入"的抽象让算法与容器容量彻底解耦。我见过很多新手不理解为什么std::copy的第三个参数不能直接传dst.end(),实际上dst.end()指向的位置是空的、不可写的,传过去轻则未定义行为,重则段错误。插入迭代器就是为了根治这个问题而生的。顺带说一句,std::back_inserter和std::move搭配能优雅地批量转移std::unique_ptr,这个组合我在移动语义一节再展开。
4.3 流迭代器:把cin/cout当容器使
流迭代器可能是 C++ 里最"反直觉"但最有意思的迭代器适配。std::istream_iterator<int>把输入流包装成输入迭代器,std::ostream_iterator<int>把输出流包装成输出迭代器。于是你可以把标准输入当成一个无限长的只读容器,把标准输出当成一个只写容器。最经典的"读入一批整数、排序、输出"程序可以写成:
#include <algorithm> #include <iostream> #include <iterator> #include <vector> int main() { std::istream_iterator<int> in(std::cin), eof; std::vector<int> nums(in, eof); std::sort(nums.begin(), nums.end()); std::copy(nums.begin(), nums.end(), std::ostream_iterator<int>(std::cout, " ")); std::cout << '\n'; return 0; }就这么十几行,完成了传统 C 风格代码几十行的活。eof是默认构造的istream_iterator,它作为"流结束哨兵",遇到 EOF 或类型解析失败时,in就会和它相等,遍历结束。ostream_iterator的第二个参数是分隔符,我传了空格。这段代码初看有点"炫技",但它揭示了一个重要事实:迭代器协议不关心数据来源,文件、内存、标准输入、网络缓冲区,只要实现了同样的操作接口,算法就能一视同仁。这也是迭代器模式思想最彻底的一次展示。
实际使用中需要注意,istream_iterator的构造会立刻读取一个值,而解引用返回的是上一次读取的副本。也就是说,它是一次性读取、滞后解引用的快照式迭代器。如果流里混入非整数内容,比如一行文本夹杂在数字之间,迭代器会直接判定到达末尾,可能悄悄截断输入。这种场景下我会改用while (cin >> x)配合手动循环,可读性更好。
4.4 移动迭代器:批量搬运独占资源
C++11 引入了移动语义,配套出现了std::move_iterator。它的作用是把普通迭代器的解引用从"返回左值引用"变成"返回右值引用",从而在算法搬运元素时触发移动构造而不是拷贝构造。最实用的场景是转移std::unique_ptr数组或者std::string数组:
std::vector<std::unique_ptr<int>> src; std::vector<std::unique_ptr<int>> dst; dst.reserve(src.size()); // 批量移动,而不是逐元素复制 std::copy(std::make_move_iterator(src.begin()), std::make_move_iterator(src.end()), std::back_inserter(dst));这种写法把"移动整个容器"这样一个本来需要手写循环的操作,压缩成了标准算法的一行调用。make_move_iterator其实就是给迭代器加了一层"强制移动"的包装,解引用出来直接std::move(*it)。很多现代 C++ 项目里,把std::vector<std::string>从一个对象搬到另一个对象时,如果不注意用移动迭代器,就可能产生大量浪费的深拷贝。它在std::copy算法链路中的角色,和back_inserter配合起来尤其顺手——一个负责生成空间,一个负责搬运资源,各司其事。
这段我之前在老代码里踩过一次:有个接口要返回一整个std::vector<std::string>,内部实现是从一个全局缓存的vector里"筛选"一批字符串出来。一开始没加std::make_move_iterator,性能测试发现大字符串场景下多了大量拷贝开销。改成移动迭代器之后,拷贝数量直接归零。这还不算完,后来把源数据改成std::unique_ptr<std::string>管理的对象时,借助移动迭代器也顺利迁移了所有权。可以说,凡是"算法 + 迭代器适配器"能解决的问题,尽量别自己写 for 循环。
5. 避坑实录:迭代器失效与自定义迭代器的常见翻车点
5.1 迭代器失效速查表
迭代器失效是 C++ 里出现频率最高的隐晦问题之一。所谓失效,是指迭代器原本指向的元素因为容器内部结构变化而变得不可用,继续使用它会导致未定义行为。这里我根据自己的踩坑经历整理了一张速查表,按容器分类:
| 容器 | 插入操作导致失效范围 | 删除操作导致失效范围 | 备注 |
|---|---|---|---|
vector | 扩容时全部失效;非扩容时仅 pos 之后失效 | 被删点及之后全部失效 | 最危险,reallocation 会让所有迭代器指向旧内存 |
deque | 除首尾两端外全部失效;首尾插入时迭代器失效但引用不一定失效 | 被删点附近失效 | 不同实现差异大,谨慎对待 |
list | 不失效 | 仅被删点失效 | 链表节点独立,最安全的容器 |
forward_list | 不失效 | 仅被删点失效 | 与 list 类似 |
map/set/multimap/multiset | 不失效 | 仅被删点失效 | 平衡树结构稳定 |
unordered_map/unordered_set | rehash 时全部失效 | 仅被删点失效 | rehash 触发条件复杂,注意元素数量 |
最典型的翻车现场是遍历vector时删除元素。C++11 之后的erase会返回指向下一个元素的迭代器,正确的写法是:
for (auto it = v.begin(); it != v.end();) { if (predicate(*it)) it = v.erase(it); // 利用返回值更新迭代器 else ++it; }在 C++98 里erase返回 void,必须先保存下一个位置再删。很多老项目代码还留着这种旧式写法,如果混用 C++11 的容器实现,很容易产生边界 bug。我建议在新代码里一律用erase的返回值,同时留意编译器版本和标准库实现。
另一个高发问题是unordered_map的 rehash。许多新手以为它和map一样安全,实际上插入元素可能触发桶数重分配,导致所有迭代器失效。所以,边遍历边插入的场景一定不要用unordered_map,要么提前reserve,要么先收集变更再统一处理。
5.2 自定义迭代器的编译期错误修复记录
手写迭代器除了要认真对待那些小小的头文件和代码风格,更大的坑是各种编译期错误。我最常碰到的几个场景如下。
第一,iterator_category缺失或写错。如果模板算法需要随机访问,但你的迭代器只写了forward_iterator_tag,编译器会报一个长长的类型不匹配错误,信息量巨大但很难一眼看懂。解决办法是在写迭代器前先想清楚你到底支持哪些操作。只实现了++就不要贴random_access_iterator_tag,否则即使编译过了,也可能在运行时出现it + n无效操作之类的诡异行为。
第二,operator++返回值错了。前置++应该返回*this的引用,后置++应返回旧值副本。很多人图省事,两个都写成 void,导致std::advance、范围 for 循环里的迭代器没法正常工作。这个错误通常编译期就能发现,但报错信息晦涩,容易被误认为"标准库有问题"。
第三,operator*返回类型和reference不一致。在RangeIterator示例中,reference = long,operator*返回long值,这是能对上号的;但如果我错误地把reference写成long&,然后返回临时值,编译器会立刻报错——因为临时值没法绑定到非 const 引用上。这类问题甚至不会出现在小测试里,而是当你的迭代器被塞进某个大型模板算法中才暴露。
第四,比较逻辑出问题。我在 3.2 节已经演示了operator==里的步长方向判断,这是最容易在运行时才暴露的 bug。一旦区间终点无法精确命中,循环可能变成死循环或者漏掉最后一个元素,而且这种 bug 在测试数据少的时候完全发现不了。我的排查经验是:写自定义迭代器时,第一件事就是拿不同的起止值和步长组合跑一遍穷举测试,把边界值、能整除、不能整除、负数步长、空区间这五类情况全测一遍。
5.3 范围for循环的隐藏陷阱
范围 for 循环本质上就是迭代器循环的语法糖,但它把迭代器变量藏起来了,导致开发者容易忽略修改容器带来的风险。看这个反面案例:
std::vector<int> v{1, 2, 3, 4, 5}; for (auto x : v) { if (x == 3) v.push_back(100); // 可能导致迭代器失效,崩溃! }这段代码在 release 模式下可能运行正常,也可能输出乱序,还可能直接段错误。因为范围 for 展开后相当于保存了v.begin()和v.end()的快照,push_back触发扩容后,之前保存的end()迭代器指向了释放掉的旧内存,访问它就成了未定义行为。类似的坑还有在循环里调用erase、clear、assign等一切可能改变容器结构的方法。
我建议把范围 for 理解成一个"只读优化"工具,它最安全的使用场景是纯遍历不需要增删。如果确实需要在遍历过程中修改容器,老老实实写出迭代器循环并管理好失效问题。这一点和 Python 里的"遍历列表时不要删除元素"是同一个道理,只是 C++ 的后果更严重——Python 会报RuntimeError,C++ 可能无声地产生脏数据。
6. 什么时候该自己写迭代器:一点个人设计心得
聊了这么多,最后回到一个很实际的问题:作为普通 C++ 开发者,我在什么情况下真的需要自己实现迭代器?我的原则是:能用标准库就用标准库,能借std::views就不要手写。C++20 之后,ranges库已经把很多"自定义迭代器才能实现"的场景简化成了声明式写法,比如views::transform、views::filter可以直接组合出惰性求值的序列,根本不用碰迭代器底层。
但如果遇到下面三类场景,我会优先考虑自己写一个迭代器。第一,你正在设计自定义容器,那么你必须提供迭代器,否则容器没法使用任何标准算法,也没有范围 for 语法糖。第二,你需要为某个现有容器提供非标准遍历顺序,比如上面例子里的Range类,或者按棋盘马走日顺序遍历二维数组,这时候自定义迭代器可以把遍历策略封装成现成的begin()/end(),调用方根本不需要知道内部逻辑。第三,你想把某种外部数据源(数据库游标、日志流、硬件读接口)包装成 C++ 容器一样的东西,通过迭代器统一接入算法库。这种情况我实际做过,给一套嵌入式日志读取接口写过一个流式迭代器,从外部看它就是一个istream_iterator的变体,调用方用std::copy就能把日志一条条搬到文件里,整个项目耦合度大减。
有一点我想特别强调:迭代器模式可以抽象容器,但抽象不了多重遍历的一致性要求。如果你的迭代器不是"指向真实容器元素"的,而是"每次解引用都生成一个临时值",那么它本质上只是一个输入迭代器,不能冒充更强类别。我见过有人把这种生成器标成forward_iterator_tag,然后在std::equal附近出问题——因为有的算法会多次解引用、缓存结果,书里学到的"前向迭代器要支持多趟遍历"在生成器场景下根本没法保证。根据我的经验,这类"代理引用"式迭代器另一个突出的问题是,如果算法把auto& x = *it拿到以后再调用++it,x 会悬空。这种教训让我在写代理迭代器时格外谨慎,宁可把能力标低一点,也不能虚标。
最后再分享一个和迭代器相关的实用习惯:给自定义迭代器写测试时,一定要连同std::iterator_traits一起验证。直接用std::static_assert(std::is_same_v<std::iterator_traits<MyIter>::iterator_category, std::forward_iterator_tag>)这类断言,能在编译期锁定迭代器的分类语义。另外,养成"迭代器只保存必要状态"的习惯——比如RangeIterator里我故意只保留当前位置和步长,不保存终点值,因为终点只在比较时需要,而且由调用方持有。这样状态更少,复制成本更低,也不容易出现两份迭代器各自维护一个终点副本导致的不同步问题。这些细节,才是迭代器模式从"会用"走向"用得明白"的分水岭。