91、STL与迭代器模式:遍历抽象、内部迭代器、外部迭代器

迭代器这东西,说白了就是让程序员能用统一的方式去遍历不同的容器。我刚开始学STL那会儿,觉得迭代器就是个指针的升级版。后来用久了才发现,它背后藏着的设计思想——迭代器模式,才是真正值钱的东西。

今天咱们就聊聊这个模式在C++ STL里是怎么落地的。我会结合自己踩过的坑,把内部迭代器和外部迭代器这两个概念掰开揉碎了讲清楚。

一、迭代器模式的核心:遍历抽象

先问个问题:为什么STL要把算法和容器分开?

你想想看,如果每个容器都自己实现一套排序、查找、复制,那代码得写成什么样?vector.sort()list.sort()map.sort()……每个容器的接口还不一样,程序员得记多少东西?

迭代器模式解决了这个问题。它定义了一个统一的遍历接口,让算法不关心底层容器是什么。这就是所谓的「遍历抽象」。

核心思想:将遍历行为从容器中抽离出来,通过迭代器这个中间层,实现算法与容器的解耦。

我在项目中遇到过这样一个场景:需要把一段业务逻辑同时应用在vector和list上。如果没有迭代器,我得写两套遍历代码。有了迭代器,一套模板搞定。这就是抽象的力量。

二、外部迭代器:STL的默认选择

STL里用的迭代器,属于「外部迭代器」。什么意思?就是遍历的控制权在调用者手里。

std::vector<int> vec = {1, 2, 3, 4, 5};
for (auto it = vec.begin(); it != vec.end(); ++it) {
    std::cout << *it << " ";
}

你看,begin()end()++it*it,这些操作全由你控制。你想跳着遍历?可以。你想提前终止?没问题。这就是外部迭代器的特点——灵活。

外部迭代器的优点很明显:

  • 控制权在用户手里:可以随时暂停、恢复、跳过
  • 支持多个迭代器同时遍历:比如同时遍历两个容器做比较
  • 延迟计算:迭代器只在你需要时才产生下一个元素

但缺点也有:你得自己管理迭代器的生命周期,用完了要记得释放。嗯,这里要注意,迭代器失效是个大坑。

我曾经踩过的坑:在遍历vector时插入了新元素,导致迭代器失效,程序直接崩溃。后来养成了习惯:遍历过程中如果可能修改容器,要么用下标,要么重新获取迭代器。

三、内部迭代器:把遍历逻辑交给容器

内部迭代器正好相反。遍历的控制权在容器或算法手里,你只需要提供一个回调函数。

STL里没有直接提供内部迭代器,但我们可以用std::for_each来模拟:

std::vector<int> vec = {1, 2, 3, 4, 5};
std::for_each(vec.begin(), vec.end(), [](int x) {
    std::cout << x << " ";
});

你看,你只管写「对每个元素做什么」,至于怎么遍历、什么时候停止,那是for_each的事。这就是内部迭代器的风格。

内部迭代器的优点:

  • 代码更简洁:不需要显式管理迭代器
  • 不容易出错:迭代器失效的风险由容器内部处理
  • 适合函数式编程风格:配合lambda表达式,代码很优雅

缺点嘛,灵活性差一些。你不能在遍历到一半时突然说「我不走了,我要回去」。控制权不在你手里。

我的建议:如果遍历逻辑简单,用内部迭代器(for_each、range-based for)更省心。如果需要精细控制,比如同时遍历两个容器做比较,那就用外部迭代器。

四、两种迭代器的对比

特性 外部迭代器 内部迭代器
控制权 调用者 容器/算法
灵活性 高(可暂停、跳过、多路遍历) 低(只能顺序执行回调)
代码简洁性 一般
迭代器失效风险 高(需手动管理) 低(由内部处理)
STL支持 原生支持(begin/end) 通过算法模拟(for_each等)
适用场景 复杂遍历、多容器同步 简单遍历、函数式风格

五、STL迭代器的分类

STL把迭代器分成了五类,每一类代表不同的遍历能力。我习惯用一张图来理解:

STL迭代器分类与能力层级 输入迭代器 只读,单向,一次遍历 输出迭代器 只写,单向,一次遍历 前向迭代器 可读写,单向,多次遍历 双向迭代器 可读写,双向遍历 随机访问迭代器 可读写,任意位置访问 容器对应关系 输入迭代器:istream_iterator 输出迭代器:ostream_iterator 前向迭代器:forward_list, unordered_set/unordered_map 双向迭代器:list, set, map 随机访问迭代器:vector, deque, array, string 能力从下往上递增 上层包含下层的所有能力

这张图我画了很多次才满意。你看,从下往上,能力越来越强。随机访问迭代器能做所有事,但代价是底层数据结构更复杂。vector用随机访问迭代器,list只能用双向迭代器,这就是为什么list不支持operator+

六、实战:自定义迭代器

有时候STL提供的迭代器不够用,得自己写。我记得有一次需要遍历一个自定义的矩阵类,就自己实现了一个行迭代器。

template<typename T>
class Matrix {
    std::vector<T> data_;
    size_t rows_, cols_;
public:
    class RowIterator {
        T* ptr_;
        size_t stride_;
    public:
        RowIterator(T* ptr, size_t stride) : ptr_(ptr), stride_(stride) {}
        
        T& operator*() { return *ptr_; }
        RowIterator& operator++() { ptr_ += stride_; return *this; }
        bool operator!=(const RowIterator& other) const { return ptr_ != other.ptr_; }
    };
    
    RowIterator begin() { return RowIterator(data_.data(), cols_); }
    RowIterator end() { return RowIterator(data_.data() + rows_ * cols_, cols_); }
};

写自定义迭代器时,有几个要点:

  • 必须提供operator*operator++
  • 必须提供operator!=operator==
  • 最好继承std::iterator_traits来提供类型信息
  • 注意迭代器失效的边界条件

避坑指南:自定义迭代器一定要遵循STL的约定。我曾经写过一个迭代器,operator++返回了void,结果在std::copy里编译不过。后来查了文档才知道,前向迭代器的operator++必须返回引用。

七、迭代器适配器:让遍历更灵活

STL还提供了一些迭代器适配器,用来改变迭代器的行为。我常用的有三个:

// 反向迭代器
std::vector<int> vec = {1, 2, 3, 4, 5};
for (auto it = vec.rbegin(); it != vec.rend(); ++it) {
    std::cout << *it << " ";  // 输出:5 4 3 2 1
}

// 插入迭代器
std::vector<int> src = {1, 2, 3};
std::vector<int> dst;
std::copy(src.begin(), src.end(), std::back_inserter(dst));

// 流迭代器
std::istream_iterator<int> cin_it(std::cin), eof;
std::vector<int> input(cin_it, eof);

反向迭代器我用的最多。有时候需要从后往前遍历,直接rbegin()就搞定了,不用自己写循环。插入迭代器在std::copy里特别好用,省去了手动resize的麻烦。

八、总结

迭代器模式是STL的基石。它把遍历抽象成了一个接口,让算法和容器可以独立演化。外部迭代器给了你最大的控制权,内部迭代器让代码更简洁。两者没有绝对的好坏,看场景选就行。

我个人习惯:简单遍历用range-based for(内部迭代器风格),复杂操作用显式迭代器(外部迭代器风格)。写库代码时,尽量提供迭代器接口,让用户有选择的自由。

嗯,迭代器这东西,用多了自然就熟了。关键是理解它背后的设计思想——抽象、解耦、统一接口。这些思想不光在STL里有用,在其他地方也一样。

最后说一句:如果你刚开始学STL,别急着看那些花哨的特性。先把迭代器搞明白,后面的路就好走了。我当年就是吃了这个亏,跳过了迭代器直接学算法,结果代码写得一塌糊涂。


公众号:蓝海资料掘金营,微信deep3321