ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++迭代器模式深度解析:从STL实现到自定义容器实战

C++迭代器模式深度解析:从STL实现到自定义容器实战 说到C里的迭代器模式很多人第一反应是这不就是STL里天天在用的东西吗确实C标准库是把迭代器模式落地得最彻底的一套代码vector有迭代器list有迭代器map有迭代器连原生的数组都能用指针当迭代器用。但也正因为太常见反而让很多人忽略了它背后的设计意图——迭代器模式到底解决了什么问题为什么C标准库非要搞出这么一套约定来如果让你自己设计一个自定义容器又该怎么把它写进去这篇文章我想换一个角度不从“设计模式教科书”出发而是从一个C开发者的实际视角把迭代器模式重新梳理一遍。会先讲清楚这个模式的核心价值再给出从基础版到现代C写法的完整实现然后把实战中容易踩的坑整理一遍。不管你是刚学C不久、还在搞懂begin()和end()是怎么配合的还是已经在写业务代码、想给自定义数据结构设计通用遍历接口这篇文章应该都能给你一些可以直接拿去用的思路。1. 先想清楚迭代器模式到底在解决什么问题1.1 一个很容易被忽视的设计痛点假设你现在要写一个“待办事项列表”第一版用数组存数据struct TodoItem { string title; bool done; }; TodoItem items[100]; int itemCount 0;遍历的时候你是这么写的for (int i 0; i itemCount; i) { cout items[i].title endl; }一切正常代码能跑逻辑也直观。但问题来了如果产品经理说“列表性能不行要改成链式结构”你就要把这段遍历代码全部改掉。更麻烦的是如果这类遍历在项目里有几十处每一处都要跟着改。也就是说你遍历数据的代码和“数据到底是怎么存储的”这件事被强耦合在一起了。迭代器模式的核心思想就是把“怎么访问数据”和“数据内部怎么组织”拆开。客户端只跟迭代器打交道迭代器负责把容器内部的结构细节藏起来。这样不管容器底层是连续内存、链表还是树对调用方来说体验都是一样的拿begin()取起点用operator往下走用operator*取值。这个思路说起来很简单但实际价值非常大——它让你可以从“具体容器”中解脱出来写出真正通用的代码。1.2 C标准库早就把答案写在脸上了在C标准库里迭代器不是一个类而是一组约定。你不需要继承某个Iterator基类只要你的类型支持operator*、operator、operator!这些运算符它就可以当迭代器用。举个例子这是标准库中几种完全不同底层结构的容器vectorint v {1, 2, 3}; listint lst {1, 2, 3}; mapstring, int m {{a, 1}, {b, 2}, {c, 3}};一旦你要遍历它们代码却惊人地一致// vector 遍历 for (auto it v.begin(); it ! v.end(); it) { cout *it ; } // list 遍历 for (auto it lst.begin(); it ! lst.end(); it) { cout *it ; } // map 遍历 for (auto it m.begin(); it ! m.end(); it) { cout it-first : it-second ; }调用方根本不需要知道list的节点是通过指针串起来的也不需要知道map底层是红黑树。迭代器把这些底层差异全部屏蔽掉了。更妙的是C11之后连这个循环都可以简化成range-forfor (auto item : v) { ... }range-for底层翻译过来仍然是迭代器操作它依赖的就是begin()和end()这一组函数的存在。换句话说只要你给自定义容器实现了迭代器接口它就能自动获得类似标准容器的遍历体验甚至能直接用标准库里的排序、查找、统计等算法这是迭代器模式在C里最厉害的地方。1.3 什么样的项目场景真正需要迭代器模式不是所有的场景都需要自己写迭代器。以我个人的经验判断标准其实很明确如果你只是在一个小函数里遍历一个vector直接用下标访问最简单别为了“设计模式”去做过度封装。但如果有下面这些情况迭代器模式就值得认真考虑第一你要设计一个自定义集合类。比如一个存放游戏单位对象的“对象池”或者一个内部存储结构可能变化的业务容器。对外暴露统一的遍历接口底层结构调整时调用方代码完全不用动。第二你需要支持多种遍历方式。比如一个二叉树既要有前序遍历、中序遍历还要有逆序遍历。如果把这些遍历逻辑全部塞进容器类容器会越来越臃肿分别实现几种迭代器互相之间完全解耦后续加新遍历方式也不用动原有代码。第三你希望自己的容器能被标准库算法直接使用。比如std::sort、std::find、std::accumulate。这些算法对迭代器有明确的等级要求你只要让自定义迭代器满足相应的运算符约定标准库算法就能直接操作你的数据结构。搞清楚这些场景之后再来看迭代器模式的核心组件和设计思路会更容易理解它为什么要这么设计。2. 迭代器模式的核心组件与设计思路2.1 四个参与角色聚合对象、迭代器、客户端、创建入口经典的GoF设计模式定义中迭代器模式有四个参与者聚合对象、迭代器、客户端以及负责创建迭代器的工厂方法。在C里这四个角色的分工可以这样理解聚合对象解决“数据存哪儿”的问题。它负责管理真正的数据集合可以是数组、链表、树甚至是从文件或者网络流中读取的数据源。聚合对象不需要自己提供遍历逻辑它只需要提供数据访问能力以及一个创建迭代器的入口。迭代器解决“怎么遍历”的问题。它内部保存着“当前遍历到哪个位置”的状态并提供向后移动和读取当前元素的操作。关键点在于迭代器保存的只是一个“位置状态”不是容器数据的一份拷贝。客户端是使用者的角色。它只面向迭代器写代码不直接接触容器内部的存储结构。这也是为什么客户端代码可以在底层存储改变时保持不变。创建入口在C里通常就是begin()和end()。它们分别负责获取指向第一个元素的迭代器和指向末尾之后位置的迭代器。末尾之后的位置不表示任何元素它只是一个“哨兵”用来和普通迭代器做比较判断遍历是否结束。这里有一个重要的细节迭代器保存的是“遍历状态”而不是“容器快照”。换句话说如果你在遍历过程中往容器里添加了新元素迭代器不一定会立刻看到它甚至可能失效。这一点在后面的问题排查部分还会详细讲。2.2 为什么遍历逻辑不能写死在容器里有些人可能觉得直接在容器类里写一个forEach方法不就行了吗为什么要专门抽出迭代器来原因在于单一职责原则。容器的核心职责是管理数据谁会存、谁会取、什么时候扩容、什么时候释放。遍历则属于另一类完全不同的职责你用什么顺序访问数据、怎么记录访问位置、遍历到什么时候结束。如果这两种职责混在一起容器类就会随着遍历方式的增加而膨胀。比如一个二叉树类你给它加一个中序遍历方法再加一个前序遍历方法再来一个逆序遍历方法……每加一种遍历方式都要修改容器类本身违反开闭原则。而且同一个时刻容器只能有一种遍历状态如果有两个线程或者两块逻辑需要同时遍历同一个容器就会互相踩踏。如果把遍历抽成迭代器情况就完全不同了。每来一种需求就写一个新的迭代器类容器本身不动。多个迭代器同时存在、分别维护各自的遍历位置互不干扰。从使用者的角度拿到迭代器就像拿到一个便携式的“浏览工具”这个工具可以随时借给不同的调用方使用。这个设计思路和现实中的“书架与图书管理员”很像。你的书架是数据的存储位置书的摆放方式决定了底层结构但你去查一本书不需要关心书架内部每一层怎么排列只需要有一个管理员告诉你“从这里开始沿着这个顺序逐本往下找”。管理员就是迭代器书架就是聚合对象。2.3 C迭代器的五种能力等级为什么这么重要C的迭代器有一个非常独特的设计是Java、Python等语言的迭代器概念里没有的迭代器被分成了五个能力等级能力从弱到强分别是迭代器类型支持的操作典型容器输入迭代器单次读取、向后移动istream_iterator输出迭代器单次写入、向后移动ostream_iterator前向迭代器多次读取、向后移动forward_list、unordered_map双向迭代器读取、前后移动list、set、map随机访问迭代器任意跳转、下标访问、比较大小vector、deque、array为什么要分等级因为不同的容器底层结构决定了它天然能支持的操作不一样。vector的数据是连续内存所以它的迭代器可以像指针一样直接加一个数字跳到指定位置这就是随机访问迭代器。list的节点是通过指针串起来的想走到第5个元素必须一路走过去因此它只能提供双向迭代器不能随机跳转。这个分级直接影响你写的代码能不能被标准库的某个算法接受。比如std::sort要求随机访问迭代器你拿list的迭代器去调用std::sort编译期就会报错。这不是坏事它是在告诉你list的底层结构不适合排序算法需要换一种排序方式。理解了这个分级之后当你自己设计容器和迭代器时就有了一个明确的目标你的迭代器应该参照哪个等级来设计能提供随机访问能力就尽量提供底层结构做不到那就老老实实做成双向迭代器不硬撑。这样写出来的迭代器更符合C社区的习惯也能被标准库算法正确识别。3. 从基础版到现代写法帮你逐步落地这套模式3.1 经典GoF风格实现先用最直白的继承方案把流程跑通如果你想快速理解迭代器模式的原始结构先看看经典的面向对象写法。下面这个例子是用C实现一个简单的书架容器书架里存放图书名称并提供一个书架迭代器#include iostream #include vector #include memory #include string class Iterator { public: virtual ~Iterator() default; virtual bool hasNext() const 0; virtual std::string next() 0; }; class Aggregate { public: virtual ~Aggregate() default; virtual std::shared_ptrIterator createIterator() const 0; }; class BookShelfIterator : public Iterator { public: explicit BookShelfIterator(const class BookShelf* shelf) : m_shelf(shelf), m_index(0) {} bool hasNext() const override { return m_index m_shelf-getCount(); } std::string next() override { return m_shelf-getBookAt(m_index); } private: const class BookShelf* m_shelf; size_t m_index; }; class BookShelf : public Aggregate { public: void addBook(const std::string book) { m_books.push_back(book); } size_t getCount() const { return m_books.size(); } std::string getBookAt(size_t index) const { return m_books[index]; } std::shared_ptrIterator createIterator() const override { return std::make_sharedBookShelfIterator(this); } private: std::vectorstd::string m_books; };客户端的使用方式void printBooks(const BookShelf shelf) { auto it shelf.createIterator(); while (it-hasNext()) { std::cout it-next() std::endl; } }这个写法结构清晰完全符合GoF描述的迭代器模式。hasNext()负责判断是否还有元素next()负责返回当前元素并推进位置。但说实话这种基于虚函数的写法在现代C工程中已经被很少使用了。为什么因为C不是Java。Java的集合框架从设计之初就统一继承了Iterable接口所有集合共用一套迭代器接口。但在C这套体系里虚函数调用是运行期多态每次调用hasNext和next都会有一次间接跳转性能上有损失更重要的是它打破了值语义迭代器要包一层shared_ptr或者unique_ptr才能传递用起来很不方便。所以这个经典写法的主要意义在于教学帮助你理解迭代器模式的角色划分。真正在工程里落地还需要换一种更符合C风格的实现方式。3.2 模板化迭代器让自定义容器也能被遍历现代C实现迭代器模式核心思路不是“继承一套接口”而是“满足一组约定”。什么叫约定就是只要你的类型实现了特定的运算符和成员函数编译器就认为它是一个迭代器。对于最简单的正向迭代器最少需要四样东西解引用运算符operator*、递增运算符operator、比较运算符operator!以及一个对应类型的别名。下面这个例子是我自己实现一个固定长度数组容器并给它配上模板迭代器的完整代码#include iostream templatetypename T class FixedArray; templatetypename T class FixedArrayIterator { public: using value_type T; using pointer T*; using reference T; explicit FixedArrayIterator(pointer ptr) : m_ptr(ptr) {} reference operator*() const { return *m_ptr; } pointer operator-() { return m_ptr; } FixedArrayIterator operator() { m_ptr; return *this; } bool operator!(const FixedArrayIterator other) const { return m_ptr ! other.m_ptr; } bool operator(const FixedArrayIterator other) const { return m_ptr other.m_ptr; } private: pointer m_ptr; }; templatetypename T, size_t N class FixedArray { public: using value_type T; using iterator FixedArrayIteratorT; iterator begin() { return iterator(m_data); } iterator end() { return iterator(m_data N); } T operator[](size_t index) { return m_data[index]; } const T operator[](size_t index) const { return m_data[index]; } private: T m_data[N]; };注意这里的FixedArrayIterator是针对底层连续存储设计的所以它内部保存一个裸指针就行。遍历容器时可以像标准容器一样使用int main() { FixedArrayint, 5 arr; for (int i 0; i 5; i) { arr[i] i * i; } for (auto it arr.begin(); it ! arr.end(); it) { std::cout *it ; } std::cout std::endl; // 直接使用range-for for (int val : arr) { std::cout val ; } std::cout std::endl; return 0; }这段代码能跑的关键在于三个细节第一begin()和end()这两个成员函数返回的是一个迭代器对象第二迭代器对象支持operator!让range-for能判断循环何时结束第三range-for在内部展开后会用operator!来判断终止条件而不是用operator或者operator。这里有一个很容易踩的坑很多人给迭代器写了operator却忘了写operator!。普通代码里用it arr.end()没问题但range-for底层用的是it ! arr.end()如果没写operator!编译器会报错。这种模板迭代器的写法性能上更接近原生指针没有任何虚函数开销同时保持了接口的统一性。它也是标准库内部设计迭代器时采用的核心思路。如果用一句话总结C迭代器的价值我觉得应该是它让“如何组织数据”和“如何遍历数据”彻底解耦但代价是你必须遵循一套严格但简单的运算符约定。3.3 如果你的迭代器要支持标准库算法自定义迭代器最爽的时刻是它能直接配合标准库算法使用。比如上面那个FixedArray如果你想对里面的元素排序直接写#include algorithm std::sort(arr.begin(), arr.end());但这里有一个前提std::sort要求随机访问迭代器它内部会用it 5、it - it这种操作。如果FixedArrayIterator只实现了operator和operator*std::sort在编译期就会拒绝它。要让迭代器支持随机访问需要额外实现一批运算符operator、operator-、operator、operator-、operator[]还有operator等比较运算符。下面是一个示例templatetypename T class FixedArrayIterator { public: using value_type T; using pointer T*; using reference T; using difference_type std::ptrdiff_t; explicit FixedArrayIterator(pointer ptr) : m_ptr(ptr) {} reference operator*() const { return *m_ptr; } FixedArrayIterator operator() { m_ptr; return *this; } FixedArrayIterator operator(int) { FixedArrayIterator tmp *this; m_ptr; return tmp; } FixedArrayIterator operator--() { --m_ptr; return *this; } FixedArrayIterator operator(difference_type n) { m_ptr n; return *this; } FixedArrayIterator operator-(difference_type n) { m_ptr - n; return *this; } friend FixedArrayIterator operator(FixedArrayIterator it, difference_type n) { it n; return it; } friend difference_type operator-(const FixedArrayIterator a, const FixedArrayIterator b) { return a.m_ptr - b.m_ptr; } reference operator[](difference_type n) const { return m_ptr[n]; } bool operator!(const FixedArrayIterator other) const { return m_ptr ! other.m_ptr; } bool operator(const FixedArrayIterator other) const { return m_ptr other.m_ptr; } private: pointer m_ptr; };写得越完整迭代器的能力等级越高能被标准库算法复用的范围也就越广。只实现了operator和operator*的迭代器只能配合std::find这种“正向单次遍历”的算法使用补齐了随机访问能力之后std::sort、std::binary_search这类算法就都可以直接用了。所以在设计之前先想清楚你的容器需要支持哪些算法再有针对性地实现相应的运算符。从前面提到的五种迭代器能力等级出发最低满足需求就好不用一上来就全实现避免过度设计。4. 实操心得与常见问题排查4.1 迭代器失效C容器使用中最经典的坑在实战中迭代器模式最常见的问题不是编译不过而是运行期“迭代器失效”。迭代器失效的意思是迭代器所指向的位置已经不再有效了继续对它解引用或者递增会产生未定义行为。一个非常经典的场景是vector扩容std::vectorint numbers {1, 2, 3, 4, 5}; auto it numbers.begin() 2; std::cout *it std::endl; // 正常输出 3 numbers.push_back(6); // 发生扩容重新分配内存 numbers.push_back(7); // 再次扩容 std::cout *it std::endl; // 未定义行为it已经失效为什么因为vector扩容时会重新申请一块更大的内存然后把原有元素拷贝过去最后释放旧内存。留在旧内存里的迭代器指向的是一块已经释放的内存继续访问它就会读到脏数据甚至直接程序崩溃。list、map这类基于节点的容器相对安全一点插入元素通常不会让已有迭代器失效但删除当前迭代器指向的节点时这个迭代器也会失效。删除操作是重灾区。比如你想把vector里所有偶数删掉如果这样写for (auto it numbers.begin(); it ! numbers.end(); it) { if (*it % 2 0) { numbers.erase(it); // 迭代器失效后续it是未定义行为 } }这段代码第一次删除偶数之后循环里的it就是未定义行为了程序可能崩溃也可能正常运行但结果是不可信的。正确做法是使用erase返回的迭代器赋值给itfor (auto it numbers.begin(); it ! numbers.end();) { if (*it % 2 0) { it numbers.erase(it); // erase返回下一个有效迭代器 } else { it; } }这就是C迭代器的一个关键特点迭代器一旦失效后续一切操作都不安全必须通过合法的返回值重新获得有效的迭代器。4.2 自定义迭代器时最容易踩的编译期与运行期陷阱自己写迭代器的时候有很多细节不亲自试一遍很难发现。第一个陷阱是忽略了const容器和const迭代器的区别。如果你的容器只在const对象上被访问比如const FixedArrayint, 5那么arr.begin()必须是const成员函数并且返回的迭代器需要能解引用为const T。否则你无法在只读函数里遍历容器。第二个陷阱是迭代器operator*返回了临时对象。如果你写的是T operator*() const { return *m_ptr; }那就会导致每次解引用都返回一个拷贝不仅性能差还会让*it x这种写操作无法编译。正确的做法是返回引用T operator*() const { return *m_ptr; }第三个陷阱是迭代器没有处理“空容器”的情况。空容器begin()和end()返回的位置相同遍历循环一开始就不满足条件这本身没问题。但如果你在迭代器构造函数里对指针做了“指向空位置”的处理比如把nullptr特判成end就要格外小心否则空容器和非空容器在比较时会逻辑混乱。第四个陷阱是range-for循环中的“引用悬挂”。如果容器返回的不是真正的元素而是按值返回的代理对象那么for (auto item : container)会绑定到一个临时对象上编译期就会报错。这种问题在实现一些视图类型、过滤迭代器、变换迭代器时尤其常见。第五个陷阱是并发访问。多个线程同时往容器里写数据再加上一个线程读数据这种场景下即使你的迭代器实现得再完善也无法保证线程安全。迭代器模式的线程安全问题本质上要靠容器的并发控制机制来解决迭代器本身只需要保证单线程场景下的逻辑正确。4.3 什么时候该用什么时候别硬上讲完坑再聊聊我个人的判断标准。写自定义迭代器最直接的理由是有“通用算法”的需求。如果你的容器只在一个模块内部使用遍历逻辑只有一处那直接用下标或者指针就够了强行套迭代器模式反而增加代码量。但如果你希望容器可以被多个模块通过统一的方式遍历甚至想直接配合标准库算法那迭代器模式就是最合适的方案。“支持多种遍历顺序”也是一个明确信号。比如你的二叉树需要前序、中序、后序三种遍历方式用迭代器分别实现比在容器类里堆三个方法要清晰得多。每个迭代器各自维护状态容器本身不用改。还有一点如果你写的迭代器是给团队用的公共组件务必做好文档和单元测试。至少应该覆盖这些场景空容器遍历、单个元素容器、多个元素容器、遍历中途插入元素、遍历中途删除元素、多个迭代器同时遍历同一容器。这些测试能提前暴露迭代器失效、悬挂引用、越界访问等问题写起来麻烦但收益极大。5. 最后分享一个我实际写迭代器的习惯从我自己写自定义迭代器的经验来看有一个习惯真的能省掉很多麻烦在写完迭代器的第一版后别急着塞进业务代码先拿一个最简单的标准库算法试跑一遍。比如用std::find找出容器里某个元素用std::count统计符合条件的元素个数。这些算法对迭代器的要求不高但能立刻暴露最常见的几类问题——operator!写没写、operator*返回值类型对不对、begin和end能不能正确翻译成迭代器。另外一个习惯是我会给迭代器内部那个指针类型定义一个明确的别名比如using pointer T*。这样在实现operator、operator!这些运算时写起来更清晰后续如果要修改底层存储方式也只需要改一处。迭代器模式的本质不是让你去背一个类的继承结构而是理解“通过统一的访问方式解耦数据存储与遍历逻辑”这个思路。C用一个独特的运算符约定把这个思路打磨到了极致。把你的容器变成一个“能被标准算法操作的对象”这本身就是一门很实用、也很值钱的功夫。
返回列表