C++自定义迭代器实战:适配器模式让STL算法识别列式存储
先说个真实场景。之前业务里有一个自研的列式日志存储底层按列存时间戳、级别、消息文本一行日志的数据分散在不同缓冲区里。业务代码想复用std::count_if、std::find_if这些标准库算法结果发现算法根本不认识这个存储结构。标准库算法只认迭代器不认任何“容器类型”。想要让算法跑起来就得按迭代器协议补一层适配这就是自定义迭代器配合适配器模式的核心价值。这篇文章就围绕这条线展开先拆解迭代器协议到底是什么再讲适配器模式怎么落地到自定义迭代器上最后给出一个完整的列式存储迭代器示例和一套避坑清单。适合正在跟 STL 算法较劲、或者想给自研容器接上标准库生态的 C 开发者不管你是上手阶段还是已经踩过几个编译错误应该都有参考价值。1. 为什么标准库算法只认迭代器不认容器1.1 算法与容器的解耦设计先看一个最简单的例子templateclass InputIt, class T InputIt find(InputIt first, InputIt last, const T value);注意这个签名里没有出现任何容器类型只有“迭代器区间”。也就是说标准库算法在设计层面从不过问数据到底存在std::vector、std::list还是自研的列式存储里它只要求你提供两个迭代器一个指向开头一个指向结尾并且这两个迭代器能支持“解引用当前位置”“移动到下一个位置”“比较是否到达末尾”这几件事。这个设计的妙处在于算法的关注点从“数据形态”转移到了“访问方式”。你可以把算法当成一个只认路书的司机路书只需要保证每隔一段能读到路标、能向前挪动至于路是柏油路还是土路司机不关心。放到 C 的世界里迭代器就是这份路书。标准库算法被设计成模板编译期通过静态多态来匹配迭代器能力而不是像 Java、C# 那样要求迭代器必须继承某个抽象接口。这种设计让自定义迭代器变得极其轻量你不需要继承任何基类只需要保证类型长相和行为符合算法期望即可C 领域管这叫鸭子类型。1.2 自定义容器什么时候必须写迭代器很多人的第一反应是既然标准库容器都有迭代器那我直接用std::vector不就行了确实如果数据可以规规矩矩地装进标准容器自己写迭代器属于没事找事。但有几类场景下标准容器根本没法直接用列式存储数据按列分开存放一行数据需要从多个列缓冲区里组合出来语义上是“行”物理上却不是连续结构。数据库游标查询结果可能流式返回底层可能是网络包或磁盘页但业务希望用 STL 算法做过滤、统计。加密或压缩数据原始数据经过编码解引用迭代器时需要实时解码出逻辑值。内存映射的特定格式文件文件里可能有头部、索引、记录区直接按偏移量读取比拷贝到标准容器更高效。这些场景的共同点是底层数据形态与“标准容器 连续对象”的假设不一致但业务代码又希望复用标准库那几十个已经打磨好的算法。这时候写一个自定义迭代器相当于给算法递上一份它认识的路书。需要说明的是下面所有内容并不是要求你给每个自研容器都硬造迭代器而是当你确实需要“让算法认识新数据结构”时知道这条适配链路该怎么铺。2. 迭代器协议拆解给算法验明正身的那些类型2.1 五个关键类型到底是什么一个迭代器类型想要被标准库算法正常使用历史上必须暴露五个类型别名C20 之后pointer变得可选但理解它们仍然很重要类型别名含义算法拿它干什么value_type解引用后得到的元素类型声明临时变量、返回值类型difference_type两个迭代器之间的距离类型std::distance的返回类型、指针运算reference*it表达式的类型访问元素、接收解引用结果pointer指向元素的指针类型std::addressof(*it)等取地址场景iterator_category迭代器能力分类标签编译期选择算法策略、约束算法复杂度很多人写自定义迭代器时最容易犯的错就是把reference直接写成T然后发现*it返回临时对象时编译不过。实际上reference不一定是真正的左值引用它可以是“代理类型”。最典型的例子是std::vectorbool它的reference是一个内部代理类负责表示单个 bit并不是真正的bool。理解这一点是适配器模式实现的关键当你不能给出真正的引用时就用一个轻量代理对象作为reference只要这个代理能用、可比较、可转换算法照样能工作。2.2 迭代器分类标签选错了会怎样iterator_category是迭代器协议里信息量最大的一个类型它直接对应迭代器的五种能力级别输入迭代器、输出迭代器、前向迭代器、双向迭代器、随机访问迭代器。标准库算法的很多实现会根据这个标签做“标签派发”选择不同的内部策略。选错标签最直接的后果是算法复杂度退化。举个例子std::distance在随机访问迭代器上可以直接return last - first;复杂度 O(1)在前向迭代器上只能while (first ! last) first, n;复杂度 O(N)。如果你的迭代器明明支持随机跳转却把标签写成forward_iterator_tagstd::distance就会老老实实地一格一格往后数。反过来更糟糕如果你的迭代器实际上做不到随机访问却把标签写成random_access_iterator_tag后果不是编译错误而是各种诡异行为。比如std::sort内部会使用it n、it - n这种运算你的迭代器如果没有真正实现这些运算编译直接失败就算硬凑出来性能也会和承诺完全不符。所以我给的建议是标签不是荣誉勋章而是能力承诺。承诺了双向能力就必须支持--it承诺了随机访问就必须支持it n。图一时省事承诺过头早晚会在某个算法里付出代价。2.3 类型别名在算法内部如何被使用以std::remove_if为例它的内部会通过std::iterator_traitsIt::value_type拿到元素类型并对区间里的元素做移动赋值。如果你的value_type给错了算法可能无法编译如果你的reference是临时代理类型但value_type又是另一个类型算法里某些类型推导也会出幺蛾子。这也是为什么标准库算法都是模板类型别名的正确性是在编译期被校验的。与其记住一堆规则不如直接掌握校验工具。C20 里可以用概念concept在编译期断言static_assert(std::forward_iteratorMyIterator);这一行比任何文档都有说服力。写了好几个.h文件之后先别急着跑算法把这句话贴上概念没过就说明你的迭代器还缺东西编译器会把具体缺口报出来。3. 适配器模式的落地把列式日志行包装成标准迭代器3.1 需求场景一个按列存储的日志系统假设我们有这样一个日志存储class LogStore { public: void add(std::int64_t ts, int level, std::string msg) { ts_.push_back(ts); level_.push_back(level); msg_.push_back(std::move(msg)); } std::size_t rows() const { return ts_.size(); } std::int64_t tsAt(std::size_t i) const { return ts_[i]; } int levelAt(std::size_t i) const { return level_[i]; } const std::string msgAt(std::size_t i) const { return msg_[i]; } private: std::vectorstd::int64_t ts_; std::vectorint level_; std::vectorstd::string msg_; };这里三个字段分别存在三个std::vector里。从物理布局看它是标准的列式存储写入时对缓存友好批量压缩也方便。但从算法角度看它不提供“一行日志”的连续对象标准库算法没法直接用。需求是不需要把数据完整拷贝成std::vectorLogRow而是让算法能像遍历普通容器一样遍历这个列式存储。3.2 核心设计LogRowIterator 的骨架适配器模式在这个场景里的做法是设计一个迭代器对象内部持有底层存储的指针和当前行号每次解引用时“即时组装”出一个代表当前行的轻量视图对象。先定义行视图class LogRow { public: LogRow(const LogStore* store, std::size_t row) : store_(store), row_(row) {} std::int64_t timestamp() const { return store_-tsAt(row_); } int level() const { return store_-levelAt(row_); } const std::string message() const { return store_-msgAt(row_); } private: const LogStore* store_; std::size_t row_; };注意LogRow本身不是一个“独立数据”它是底层存储的视图。它很轻量只存了一个指针和一个行号所有访问都是转发到底层存储。再写迭代器class LogRowIterator { public: using iterator_category std::forward_iterator_tag; using value_type LogRow; using difference_type std::ptrdiff_t; using pointer const LogRow*; using reference LogRow; LogRowIterator() default; LogRowIterator(const LogStore* store, std::size_t row) : store_(store), row_(row) {} reference operator*() const { return LogRow(store_, row_); } LogRowIterator operator() { row_; return *this; } LogRowIterator operator(int) { auto tmp *this; row_; return tmp; } friend bool operator(const LogRowIterator a, const LogRowIterator b) { return a.store_ b.store_ a.row_ b.row_; } friend bool operator!(const LogRowIterator a, const LogRowIterator b) { return !(a b); } private: const LogStore* store_ nullptr; std::size_t row_ 0; };这里有几个设计决策值得展开讲。第一reference是LogRow而非LogRow因为operator*每次返回的都是新建的临时对象。这正好呼应了第一节讲的代理迭代器模式reference不一定是真引用。第二iterator_category我选了forward_iterator_tag。因为LogStore是不可变的迭代器可以反复遍历同一段数据多遍扫描没问题满足前向迭代器的核心要求。我没有选random_access_iterator_tag因为LogRowIterator并没有实现it n这样的运算而且底层是三个独立vector逻辑上也不存在“稳定地址”的随机访问语义。第三我没有写operator-。这是代理迭代器常见的取舍因为operator*返回临时对象没有办法安全地返回指向它的指针。标准库算法在实际实现中更依赖*it和std::addressof(*it)而不是it-所以实际影响很小。如果你在自定义迭代器里遇到“箭头运算符无法实现”的困惑记住这不是缺陷而是代理迭代器的正常特征。3.3 自由函数与命名空间安排有了迭代器还需要提供begin和end自由函数让范围for和后续的 ranges 算法能找到它们inline LogRowIterator begin(const LogStore store) { return LogRowIterator(store, 0); } inline LogRowIterator end(const LogStore store) { return LogRowIterator(store, store.rows()); }这两个函数最好放在LogStore所在的命名空间里这样 ADL实参相关查找在查找begin(store)和end(store)时能自动发现它们。for (const auto row : store)才能正常工作。3.4 从 input_iterator 到 forward_iterator 的取舍这里展开一个不少初学者会纠结的问题既然例子里的LogStore是不可变的那我直接声明input_iterator_tag不行吗从编译角度看很多算法确实能跑起来但问题出在语义上。输入迭代器是“单遍”的它承诺的是你只能从头到尾走一遍迭代器拷贝之后两个拷贝的解引用结果不能保证独立。这让你在遍历过程中不能保存一个旧迭代器、继续用新迭代器推进然后回头比较两者指向的元素。前向迭代器则要求“多遍”安全同一个范围内的多次遍历应该得到相同的结果迭代器的拷贝在解引用时是独立且有效的。LogStore是不可变的列式存储天然满足前向迭代器的多遍要求所以声明forward_iterator_tag是在告诉算法你可以在这个区间上做多遍扫描哪怕代价是某些优化路径走不了。选择标签的本质是把“底层数据特性”翻译成“算法能利用的能力承诺”。4. 完整可运行示例与逐步解释4.1 最小实现跑起来把上面的代码拼到一起配上一点测试数据就能立刻看到效果#include algorithm #include iostream #include string #include vector // ... LogStore、LogRow、LogRowIterator、begin/end 定义见前文 ... int main() { LogStore logs; logs.add(1001, 0, session started); logs.add(1002, 2, database timeout); logs.add(1003, 0, session ended); logs.add(1004, 1, retry in progress); auto warnCount std::count_if(begin(logs), end(logs), [](const LogRow r) { return r.level() 2; }); std::cout warning count: warnCount \n; auto it std::find_if(begin(logs), end(logs), [](const LogRow r) { return r.message().find(timeout) ! std::string::npos; }); if (it ! end(logs)) { std::cout found timeout at ts it-timestamp() \n; } return 0; }注意上面it-timestamp()能工作吗我前面说过LogRowIterator没有实现operator-那it-timestamp()怎么编译实际上这里有个隐藏细节如果迭代器没有提供operator-编译器不会自动帮你调用(*it).timestamp()。但在我给的代码里我没有写operator-所以上面代码里写it-timestamp()会怎样答案是LogRowIterator没有operator-这段代码不能编译。那为什么很多示例里it-可以正常使用因为标准容器的迭代器都实现了operator-。对于代理迭代器你需要在文中明确如果要使用it-语法就必须提供一个“箭头代理对象”或者干脆用(*it).timestamp()。为了不让示例误导这里把访问方式改成(*it).timestamp()if (it ! end(logs)) { std::cout found timeout at ts (*it).timestamp() \n; }这样就和前面“不提供operator-”的取舍保持一致了。4.2 复用几十种算法的收益上面的代码跑起来之后你会发现一个很有成就感的事实一旦LogStore有了合法的迭代器标准库所有基于前向迭代器的算法都能直接套用。比如auto maxTs std::max_element(begin(logs), end(logs), [](const LogRow a, const LogRow b) { return a.timestamp() b.timestamp(); }); auto allInfo std::all_of(begin(logs), end(logs), [](const LogRow r) { return r.level() 0; }); auto countInfo std::count_if(begin(logs), end(logs), [](const LogRow r) { return r.level() 0; });不用为每个算法单独写循环不用把数据倒腾到临时vector性能上也不会因为“先拷贝再遍历”产生额外开销。这就是适配器模式给的杠杆一次适配处处复用。4.3 性能与正确性层面的细节operator*每次返回一个LogRow临时对象表面上看似乎有构造开销但LogRow内部只有两个字段构造成本几乎可以忽略。真正要避免的是在operator*里拷贝std::string或其他重对象。LogRow::message()返回的是const std::string所以“组装一行”并不复制消息文本只是复制指针和索引。如果你的value_type本身是个重量级对象解引用时每次都要深拷贝那性能就会很糟糕。这时候有两种优化思路一是把真正重的数据放到底层存储里value_type只做视图二是用移动语义配合iter_move定制点把“按需生成”的对象移交给算法。前面例子已经走了第一种思路这也是代理迭代器的常见形态。5. 复杂玩法C20 概念约束与 ranges5.1 把列式存储塞进 ranges 算法C20 的 ranges 算法让使用体验更顺滑因为你可以直接传整个范围对象#include ranges auto errorRows std::ranges::filter_view(logs, [](const LogRow r) { return r.level() 2; }); for (const auto row : errorRows) { std::cout error: row.message() \n; }logs之所以能被filter_view接收前提是LogStore被视为一个 range。这要求它对ranges::begin(logs)和ranges::end(logs)可用而这两个函数会经由 ADL 找到我们定义的begin和end。可以用静态断言确认它确实满足概念static_assert(std::forward_iteratorLogRowIterator); static_assert(std::ranges::forward_rangeLogStore);如果断言失败编译器会告诉你缺了哪个表达式这比在算法内部报一堆模板错误直观得多。5.2 迭代器定制点iter_move 与 iter_swapC20 ranges 引入了iter_move和iter_swap两个定制点专门处理“怎么把迭代器指向的值搬走”和“怎么交换两个迭代器指向的值”。对于普通迭代器iter_move(it)默认等价于std::move(*it)iter_swap(a, b)默认等价于swap(*a, *b)。但代理迭代器的*it是临时对象默认行为不一定高效。比如你的value_type里有一个大字符串默认iter_move可能先拷贝一次。解决办法是为迭代器所在命名空间提供自定义iter_move重载。但这个话题比较深我建议先把基础迭代器跑通再考虑定制点优化。方向上记住一句话代理迭代器写得好不好最后往往要看iter_move和iter_swap这两个定制点有没有被好好对待。5.3 半开区间与哨兵模式C20 还推出了哨兵sentinel概念允许end返回一个与迭代器不同类型的“终止标记”。示例里end(logs)返回的是LogRowIterator已经足够。但如果你的终止条件不是“行号到达某个值”而是“某字段满足某个条件”哨兵模式就能派上用场。哨兵的核心在于只需要定义operator让迭代器和哨兵可比算法就能在到达终点时停下来。这给自定义迭代器提供了很大的灵活性也让“无限数据源 条件终止”的写法成为可能。不过这是另一个话题了这里先提一个方向。6. 常见问题与避坑手册6.1 编译错误清单写自定义迭代器时错误信息往往很长核心问题躲在一堆模板展开的后面。这里列几个高频错误和排查思路错误表现常见原因处理方式no match for operator*迭代器没有正确实现operator*或const限定不对给operator*加const并确认返回类型与reference一致no type named value_type in iterator_traits迭代器内部缺少必要的类型别名补全value_type、difference_type、reference等static assertion failed: forward_iterator迭代器不满足 C20 概念要求按编译器提示补齐operator、默认构造、operator返回类型等算法内部卡在swap相关错误*it返回临时对象无法原地交换检查iterator_category是否承诺过高或提供iter_swap定制点reference cannot be a temporary某些代码尝试绑定auto x *it;改用auto x *it;或const auto x *it;理解代理迭代器语义这里特别提醒auto x *it;这种写法对普通迭代器没问题但对代理迭代器是编译错误因为*it返回临时对象。初学者第一次遇到时很容易怀疑是自己的迭代器写错了实际上这是代理迭代器的正常限制。6.2 性能陷阱迭代器的性能问题往往不来自迭代器本身而来自标签选错导致的算法策略变化。一个典型例子是std::distance。如果迭代器标签是random_access_iterator_tagstd::distance就是 O(1) 减法如果是forward_iterator_tag就只能 O(N) 递增。如果数据规模是百万级这个差距会非常明显。反过来你的迭代器明明只能前向移动却硬标成随机访问std::sort这类算法会把随机访问运算用在你的迭代器上编译期或许能过但每次it n都得从当前位置一格一格走复杂度还不如老老实实承认自己是前向迭代器。还有一个容易被忽视的点operator()的前缀版本应该返回LogRowIteratoroperator(int)后缀版本返回旧值拷贝。如果两者搞混或者后缀版本返回引用通常会因为返回悬空引用导致 undefined behavior而且是那种很难排查的运行时崩溃。6.3 经验速查表迭代器分类最低要求典型算法示例注意事项输入迭代器*it、it、比较std::find、std::count_if单遍扫描不能保存状态反复遍历前向迭代器输入迭代器 多遍安全std::search、std::adjacent_find可以反复扫描同一区间双向迭代器前向迭代器 --itstd::reverse、std::prev支持反向移动随机访问迭代器双向迭代器 itnstd::sort、std::binary_search需要支持下标访问和距离运算复杂度承诺最强6.4 如果项目还在 C17 怎么办不是所有人都在用 C20但核心技巧在 C17 下完全可用。区别主要是两点第一不能用概念做静态断言但可以用std::iterator_traitsIt搭配 SFINAE 做检测或者干脆跑几个算法作为“编译期冒烟测试”。实测下来先写一个小的static_assert(std::is_same_vstd::iterator_traitsIt::iterator_category, std::forward_iterator_tag)能提早发现一半的问题。第二C20 ranges 的某些便捷写法比如管道表达式不可用但手写迭代器配合begin/end自由函数依然能让std::find_if等算法正常使用也依然支持范围for。核心收益并没有少太多。还有一个 C17 时代的习惯问题不少人会用std::iterator作为基类来省事但这个类模板从 C17 起就被标记为不建议使用不要在新代码里依赖它。直接手写类型别名也就多几行而已。7. 写在最后的实操体会自定义迭代器这东西第一次写会觉得很玄编译错误能淹没整个屏幕但写过一两个之后就会发现它的规律性其实非常强。我的个人经验是不要急着写算法调用先把迭代器类型扔到概念断言或者一个最简单的std::distance里跑一下确认“身份正确”再往复杂算法上靠。还有一个非常实用的小技巧如果你不确定iterator_category该怎么选可以先按“底层数据最保守的遍历能力”来声明前向就是前向双向就是双向千万不要为了跑某个算法而虚报能力。虚报换来的只是短期编译通过付出的是长期调试成本。最后再分享一个调试习惯在写LogRowIterator这类代理迭代器时多写几个“本来不该用”的测试比如拷贝迭代器、保存旧迭代器、连续几次*it解引用确认结果是稳定的。前向迭代器的多遍保证不是嘴上说说是要经得起这些基础操作考验的。项目上线半年后再回头改迭代器那才是真的痛苦。