【C++进阶】STL算法与函数对象 - 01 让算法去处理一段迭代器范围

博主介绍:程序喵大人

好文推荐:

【AIAgent项目】从零构建一个代码PRAgent

一段普通的数据处理代码,问题往往出在循环里堆了太多细节。变量名叫 i,边界写成 i < n,中间夹杂着 ifpush_back、计数器和临时状态,读者必须顺着控制流走完一遍,才能弄清这段代码究竟是在查找、筛选、排序、转换,还是在删除。STL 算法给出的改法很直接:遍历交给标准库,规则交给调用方。这样写出来的代码,函数名先表明意图,参数说明处理范围,规则对象补上业务判断,读者一眼就能抓住重点。

本章要回答的核心问题是:标准库算法为什么总是用两个迭代器来表达处理范围。算法面对的是一段 [first, last) 范围,容器负责存储元素,迭代器负责描述访问路径,算法只沿着这条路径读取、比较、写入或移动元素。这个问题表面上只涉及一个语法点,实际上决定了整套标准库的使用方式。一旦接受"算法处理范围,规则交给可调用对象"这个模型,后面的 sortfind_iftransformremove_ifaccumulate,以及 lambda、函数对象和 std::function,都可以放进同一张图里理解。

先看算法真正拿到什么

标准库算法拿到的通常是一对迭代器:一个指向待处理区间的起点,另一个指向终点之后的位置。这个 [first, last) 半开区间就是算法的工作范围,算法只承诺在其中移动、读取、比较、写入或重排元素。至于容器的名字、容量、分配器和内部节点结构,都被迭代器隔在了另一边,算法无从看到,也不需要看到。

这种设计让算法摆脱了对具体容器的依赖。同一个 std::find,既可以用在 vectordequearray 上,也可以用在原生数组甚至输入流迭代器上,前提只有一个:传入的迭代器具备算法所需的能力。算法关心的只是"当前位置能否解引用""能否前进到下一个位置""能否判断是否已经到达终点",具体容器类型完全被范围接口挡在外面。STL 之所以看起来抽象、用起来稳定,原因就在这里:它的抽象有明确的落脚点,就是迭代器能力。

回到本章的主线:API 名字只是入口,处理边界才是关键。算法不会主动处理整个容器,除非你把 begin()end() 都传进去;它也无从得知你想跳过前两个元素,除非你把起点移动到相应位置。范围一旦传错,算法照样会忠实地处理你给出的那段区间。这个特性听起来朴素,却能解释许多常见的 bug:漏掉最后一个元素、处理已经失效的位置、把来自两个容器的迭代器混成一组,本质上都是对范围契约的破坏。

一个写得好的算法调用,通常能直接读出三件事:处理哪段范围、使用什么规则、产生什么结果。手写循环同样可以表达这些信息,但循环的表达密度偏低,边界、递增、判断、动作全都挤在一处。标准算法则把这几部分拆开:范围放在参数里,规则放在谓词或比较器里,动作由算法名本身表达。读代码的人无需先解析控制流,就能明白这段代码的意图。

规则对象让算法保持通用

算法本身不懂业务。它不知道一个订单怎样才算异常,不知道一个学生怎样才算及格,也不知道两个商品应该按销量、价格还是名称排序。算法所能做的,是把遍历方式和返回语义固定下来,再将"判断"这件事交给调用方传入的可调用对象。这个可调用对象可以是普通函数、lambda、函数对象,也可以是用 std::function 包装后的对象。

谓词是最常见的一类规则对象。它接收一个元素,返回 truefalse,回答的问题只有一个:这个元素是否符合条件。count_if 依据谓词决定计数器是否加一,find_if 依据谓词决定是否返回当前位置,copy_if 依据谓词决定是否把元素写入输出范围。比较器同样属于规则对象,它接收两个元素,回答前一个是否应该排在后一个之前;sort 正是把比较器当作排序规则,通过反复调用来构造最终顺序。

这种分工带来的好处很实际:算法的流程由标准库负责,可以被充分优化和测试;业务规则则留在调用点附近,由调用方维护。你不必每次都手写一遍"从头走到尾,遇到符合条件的元素就做某事",只需把条件写成一个清晰的谓词;等条件复杂起来,再把它提取为具名函数对象。标准库从不关心规则对象内部有多少逻辑,它只关心两点:能否按约定调用,返回值是否满足算法要求。

代码示例

本章的示例文件是 代码示例/01-algorithm-range/algorithm_range.cc,刻意保持短小,只演示本章最核心的机制。

cpp 复制代码
// Copyright (c) 2026 yus3nable
// SPDX-License-Identifier: MIT

#include <algorithm>
#include <iostream>
#include <numeric>
#include <vector>

int main() {
  const std::vector<int> scores{72, 95, 61, 88, 95, 43};

  const auto first_high =
      std::find_if(scores.begin(), scores.end(), [](int score) {
        return score >= 90;
      });

  if (first_high != scores.end()) {
    std::cout << "first high score: " << *first_high << '\n';
  }

  const int passed = static_cast<int>(std::count_if(
      scores.begin(), scores.end(), [](int score) { return score >= 60; }));
  std::cout << "passed count: " << passed << '\n';

  int total = 0;
  std::for_each(scores.begin(), scores.end(),
                [&total](int score) { total += score; });
  std::cout << "total: " << total << '\n';
}

这段代码的重点不在输出格式,而在每个算法调用都把"要做什么"写在了函数名上:std::find_if 按条件找出第一个元素,std::count_if 按条件计数,std::sort 按比较规则重排,std::transform 把输入元素映射成另一种结果,std::accumulate 把整段范围折叠成一个累计值。你可以根据需求在这些算法之间切换,但没必要把它们重新写成风格各异的循环。

把同样的逻辑写成手工循环,代码短时或许看不出差别;一旦规则多起来,循环很快就会纠缠在一起------先过滤,再转换,再统计,再删除,每一步都要自己维护输出位置和中间状态。算法式写法则把这些步骤拆成若干段可以单独审查的操作,每段都有明确的输入范围和输出结果。在工程代码里,循环本身并不可怕,真正推高维护成本的,是一个循环同时承担了太多职责。

容易写错的边界

不要把算法当成容器的成员函数来理解。算法处理的是迭代器范围,而范围可以来自容器,也可以来自数组、流迭代器或自定义序列。这一点必须说清楚,因为它直接决定了你能否把算法安全地用在真实项目中。标准库算法几乎不会替你"猜意图",它只严格执行你传入的范围、规则和输出位置。范围传错了,算法不会报警;谓词带有副作用,算法不会替你梳理状态;比较器不满足要求,排序结果也就失去了意义。

另一个常见误区,是把算法名当成容器操作来理解。算法只处理范围,它通常不知道容器的 size(),也不会直接改变容器的物理大小。以 remove_if 为例,它只负责重排元素并返回新的逻辑尾,真正删除尾部元素要靠容器的 erase。再如 sort,它会重排范围内的元素,却不会替你检查这个范围是否来自支持随机访问的容器。算法越通用,调用方对范围契约的责任就越重。

返回值同样需要仔细对待。查询类算法通常返回迭代器,没找到时返回 last,因此结果必须先与 last 比较,确认有效后才能解引用。转换和复制类算法常返回输出迭代器的当前位置,方便你衔接下一段输出。删除相关算法常返回新的逻辑尾。数值算法返回累计结果,而初始值的类型会影响结果的类型。许多与算法有关的 bug,根源就在于调用方没有读懂返回值。

本章专项拆解:范围是算法的最小现场

std::find 这类查询算法最能体现范围模型。它从 first 开始向后移动,每到一个位置就解引用一次,将元素与目标值比较;比较成功就返回当前位置,走到 last 仍未成功就返回 last。整个过程里只有当前位置和终点边界,没有容器视角。只要把这条路径在纸上画出来,就能理解为什么没找到时不能解引用返回值------那个位置只是哨兵,不指向任何元素。

std::count_if 在同一条路径上多加了一层规则调用。算法每经过一个元素,就把它交给谓词:谓词返回 true,计数器加一;返回 false,算法继续前进。这里的关键在于,谓词并不控制遍历,它只回答当前元素是否符合条件。不少人写复杂 lambda 时,习惯把遍历状态和判断状态揉在一起,结果规则对象变得难以测试。更稳妥的写法是让谓词保持纯判断,遍历则完全交给算法。

std::for_each 看上去与普通循环最为接近,但边界同样清晰:算法保证按范围顺序调用可调用对象,调用方则要保证这个对象的副作用可以理解。打印、累加外部计数、写日志、做轻量统计,都适合放进去;而复杂的数据重排、跨多个容器的同步修改、需要频繁提前退出的流程,用普通循环表达更清楚。判断的标准只有一个:这个副作用能否用"对每个元素执行一次动作"说清楚。

这一层判断会直接影响接口设计。算法调用如果只暴露一段范围,调用方就无需了解底层容器;规则如果写成短小的可调用对象,测试时也可以单独喂入样例验证。教程里的 demo 看起来简单,但同一套模式在真实项目中随处可见:日志筛选、订单汇总、配置校验、索引构建、报表生成。只要把范围、规则、返回值这三件事分清楚,标准库算法就会从一堆零散的 API 名字,变成一套可以复用的数据处理语法。

为什么这比循环更适合长期维护

手写循环的优势在于直接,缺点也恰恰在于太直接。它把遍历方式、边界控制、条件判断、结果写入和状态更新全部堆在同一层。代码刚写完时,作者自然清楚每一行的含义;三个月后再有人读到,就得重新推导这段循环究竟是在筛选、查找、计数还是转换。标准算法的价值,就在于提前消除了这笔推导成本。

使用算法还有一项隐性收益:代码中的错误类型会变得更集中。看到 std::sort,审查的重点自然落在比较器和迭代器类别上;看到 std::find_if,重点落在谓词以及没找到时的处理上;看到 std::remove_if,重点落在有没有接上 erase;看到 std::accumulate,重点落在初始值类型和组合规则上。函数名本身就构成了一张审查清单,引导你把注意力放到正确的位置。

算法式写法并不意味着要消灭所有循环。复杂的状态机、需要提前退出并维护多个外部状态的流程、必须精确控制性能和内存访问模式的热路径------这些场景里,手写循环仍然有其价值。合理的做法是:普通的数据处理逻辑优先用标准算法表达,特殊控制流再回到循环。这样一来,代码库里留下的循环本身就成了一种信号,提醒读者这里有标准算法难以表达的业务结构。

和前后章节的关系

《图解 STL 容器与迭代器》已经讲过容器如何存放元素,也讲过迭代器如何把容器暴露成范围。本章把这条线继续往前推:范围一旦出现,算法便能在其上工作;算法一旦需要判断,谓词、比较器、lambda 和函数对象便接了上来。容器、迭代器、算法和 callable 从来不是四块孤立的知识,它们共同构成了 STL 的主干。

这条主干在工程代码中非常实用。读取一批记录之后,常见的处理是:先用 copy_if 挑出有效记录,再用 transform 映射成轻量结构,接着用 sort 排序、用 unique 配合 erase 去重、用 accumulate 汇总总量,最后把格式化规则封装进一个函数对象复用。其中每一步都可以写成循环,但每一步都有一个更准确的算法名字。代码越长,这种表达上的优势就越明显。

本章同时也为后面的 callable 模型埋下了线索:lambda 会生成闭包对象,适合在调用点附近承载短规则;函数对象可以保存状态和配置,适合承载可复用的规则;std::function 通过类型擦除统一调用接口,代价是引入间接调用的开销。只有先理解了算法,这些工具的存在才会显得顺理成章。

工程判断

在实际项目中,可以用一条简单的标准来判断是否该用标准算法:如果代码正在处理一段明确的范围,且每个元素的处理规则可以用一个短小的谓词、比较器或转换函数表达,就优先考虑标准算法;如果代码需要维护复杂状态、跨多个范围做不规则跳转,或者每次迭代都要依据前面的结果改变控制路径,手写循环会更清楚。这个判断与风格偏好无关,取决于数据流能否被算法名字准确表达。

接口层面的承诺同样值得关注。接收迭代器范围的函数,比接收具体容器的函数更通用;以模板参数接收谓词的函数,比固定接收 std::function 的函数更容易被内联;需要在运行期切换规则的模块,则更适合使用 std::function 或明确的策略对象。标准库提供了丰富的表达工具,工程判断的重点在于:让接口暴露足够的能力,同时为未来调整实现留出空间。

迁移练习:从循环拆出范围、规则和结果

把一段手写循环改成算法调用时,不必急着搜索函数名。可以先把循环拆成三个问题:它处理哪段元素?判断规则是什么?结果是什么形态?如果结果是第一个符合条件的位置,优先考虑 find_if;如果结果是一个数量,优先考虑 count_if;如果结果是逐项执行的动作,优先考虑 for_each。想清楚这三点再动笔,替换会稳妥得多。

还有一个容易忽略的细节:手写循环习惯用整数下标表示起点和终点,算法调用则用迭代器表示边界。迁移时要先确认原循环的区间属于闭区间、开区间还是半开区间。C++ 标准库默认采用 [first, last),因此把 i <= last_index 这样的下标循环改成迭代器范围时,终点要移动到最后一个元素之后。

写完这类代码后,可以按三个步骤自检。第一,检查范围边界是否来自同一个序列------firstlast 不能分属不同容器。第二,检查规则对象能否用两三个样例单独解释清楚,复杂到需要依赖外部上下文才能读懂的规则,应当提取为具名对象。第三,检查返回值是否用到位,尤其是迭代器、输出位置和新的逻辑尾。这三步与具体算法无关,适合直接放进 code review 清单。

迁移老代码时,还要留意保留原循环里的观察点。如果原循环在某个分支里打印日志、更新指标或跳过异常数据,改成算法之后,必须明确这些观察点安置在哪里。许多重构之所以失败,往往是旧循环里随手塞进去的副作用没有被盘点清楚,与算法选型关系不大。可以先把循环拆成五块------输入范围、过滤规则、转换规则、输出位置、附带副作用------再决定哪些交给算法,哪些保留为显式语句。这样改完的代码,才不会变成一串看似漂亮却难以阅读的嵌套调用。

调试体验也值得纳入考量。手写循环可以在循环体里随意打断点,算法调用则更依赖规则对象本身的可读性。规则对象写得越短,调试越容易;一旦它包含多层条件、外部状态和隐式修改,就该给它起个名字,并把中间判断拆成局部变量。调试时还有个实用技巧:先用小输入构造一个只有三五个元素的范围,逐个验证规则的返回值,再接回真实数据流。这个习惯能让你避免在庞大的数据集合里追踪一个极小的谓词错误。

性能判断同样要落在具体路径上。标准算法通常不会天然比手写循环慢,编译器往往会把它们内联成相近的机器码。真正可能带来开销的,是复杂的可调用对象、std::function 的间接调用、输出容器的反复扩容、过多的临时对象,以及算法链之间产生的不必要中间结果。教学文章不必提前展开汇编层面的分析,但要让读者建立正确的直觉:算法负责表达意图,性能则取决于范围、数据布局、规则复杂度和编译器优化的共同作用。

最后,要把异常和边界输入完整跑一遍:空范围应得到自然的结果,只有一个元素的范围不应越界,所有元素都不匹配时返回值要能被正确处理,所有元素都匹配时输出位置和容器大小也要符合预期。标准库算法的行为非常稳定,项目中出问题的地方,通常是调用前提没有写清楚。把这些边界样例写进 demo 或测试,读者才会真正把算法当成工程工具,形成超出函数名记忆的结构化理解。

发布前还可以做一轮语义校验:把每个算法调用读成一句中文,看读起来是否顺畅------"在这段范围里找到第一个符合条件的元素""把这段输入转换到输出范围""把满足条件的元素压到前面并返回逻辑尾""用这个初始值折叠整段输入"。如果哪一句读着别扭,通常说明算法选型、变量命名或规则对象的边界需要调整。好的 STL 代码不靠复杂度营造高级感,它应该让读者一眼就看清数据从哪里来、经过什么规则、到哪里去。

还有一个实践细节:不要把多个算法强行串成一条难以断点的长表达式。训练营教程中的示例会刻意分步书写,每一步都用一个有含义的变量接住中间结果。这样写虽然多出几行,却更便于讲解和排错。等读者熟悉了数据流,再去学习 ranges 或管道式组合会顺理成章。标准库算法的基础越扎实,日后面对 C++20 ranges 时,越不会只把它看成语法糖,而能看出范围、视图、惰性求值与算法之间的真实关系。

本章的最终目标,是让读者形成一套可以迁移的阅读顺序:先读算法名,判断它属于查询、排序、转换、删除、累计还是调用动作;再读范围,确认它处理的是整段容器、子区间、临时数组还是输出迭代器;最后读规则对象,确认业务判断是否短小、稳定、可测试。这个顺序一旦养成,即使面对陌生的算法,也能先猜出它的大致行为,再查文档核实细节。学习 STL 算法,最忌讳把每个函数当成孤立的记忆点;稳妥的做法,是把它们全部放回"范围"和"规则"这两条主线里。

阅读标准库文档时,也可以沿用这个顺序。先看参数表中对迭代器类型的要求------输入迭代器、前向迭代器、双向迭代器、随机访问迭代器;再看复杂度说明,确认算法会扫描几遍范围、是否会高频比较或调用规则对象;最后看返回值和迭代器失效说明,确认调用之后哪些位置还能继续使用。这样读文档,远比逐字背函数签名有效,也更贴近真实工程中的使用方式。

每章的代码 demo 都应当服务于这套阅读顺序:先证明机制,再承接工程判断。机制讲清楚了,接口选择才会稳定。读者日后写项目时,也应把这套顺序落到命名上------范围变量写清来源,谓词名字写清条件,输出变量写清结果形态。命名一旦贴合数据流,算法调用本身就是一段可读的技术说明。在团队协作的代码里,甚至可以把算法调用旁的注释压到最少,因为函数名、变量名和规则对象名已经承担了主要的解释工作,注释只需补充标准边界、性能取舍和业务例外。这样的代码,复查起来会轻松得多。

下一章将进入排序,因为 std::sort 会立刻暴露迭代器能力上的差异。学到这里,不妨把本章当作一张底图:范围是算法的工作区域,规则对象是算法的判断入口,返回值是算法与调用方交接结果的位置。后续每一章都会在这张底图上添一层细节,最终目标是让数据处理代码更清楚、更稳,也更贴近 C++ 标准库本来的设计意图。

码字不易,欢迎大家点赞,关注,评论,谢谢!

相关推荐
Keven_111 小时前
算法札记:二叉树前序、中序、后序遍历及对应序列重要性质
数据结构·算法·深度优先
cui_ruicheng1 小时前
Python数据分析(十三):Seaborn 统计可视化
开发语言·python·信息可视化·数据分析
Delite8021 小时前
微库仑氧化法硫氯检测:油品分析中测量误差的控制要点
算法
宵时待雨1 小时前
优选算法专题12:队列
算法·leetcode·职场和发展
tianyu2341 小时前
双指针循环匹配金额——打款记录与账单的自动对账算法
java·算法·双指针循环·匹配金额
大尚来也2 小时前
HTML5 Web Worker:彻底解决JS阻塞页面卡顿问题
开发语言
sycmancia2 小时前
Qt——复习篇painter
开发语言·qt
旖旎夜光2 小时前
LeetCode 738:单调递增的数字(贪心算法) —— 题解
数据结构·c++·算法·leetcode·贪心算法