博主介绍:程序喵大人
- 35 - 资深C/C++/Rust/Android/iOS客户端开发
- 10年大厂工作经验
- 嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手
- 《C++20高级编程》《C++23高级编程》等多本书籍著译者
- 更多原创精品文章,首发gzh,见文末
- 👇👇记得订阅专栏,以防走丢👇👇
😉C++基础系列专栏
😃C语言基础系列专栏
🤣C++大佬养成攻略专栏
🤓C++训练营
👉🏻个人网站
好文推荐:
【C++进阶】STL算法与函数对象 - 01 让算法去处理一段迭代器范围
【C++进阶】STL算法与函数对象 - 02 sort为什么需要随机访问迭代器
从一批学生记录里数出 90 分以上的人数,手写循环是大多数人的选择:
cpp
int excellent = 0;
for (std::size_t i = 0; i < students.size(); ++i) {
if (students[i].score >= 90) {
++excellent;
}
}
这段循环里真正属于业务判断的只有一行:students[i].score >= 90。剩下的全是遍历脚手架:下标从零开始、每次自增、和容器大小比边界、手工维护一个计数器。规则被埋在控制流中间,读者必须先把脚手架在脑子里拆掉,才能看见它。排序的手写版本症状相同,交换条件同样被循环结构裹得严严实实。
再看同一件事的算法写法:
cpp
const int excellent = static_cast<int>(std::count_if(
students.begin(), students.end(),
[](const Student& student) { return student.score >= 90; }));
脚手架整体消失了:函数名说明意图是计数,前两个参数说明处理范围,第三个参数就是那条业务规则,一个字符都没有变。count_if 本身并不知道多少分算优秀,它只是在每个元素的位置回头调用一次你递进去的规则,再统计得到了多少个 true。sort 面对顺序问题时也一样,它不知道两个学生谁该排前面,全靠调用方提供的比较规则。标准库算法把一件事拆成了两半:遍历的骨架由算法固定下来,判断的内容由调用方以可调用对象的形式递进去。
本章要回答的问题随之明确:算法怎样知道什么叫符合条件,什么叫排在前面。答案是两类规则对象的分工。谓词接收一个元素,返回布尔值,表达"这个元素算不算数";比较器接收两个元素,返回布尔值,表达"前一个该不该排在后一个之前"。这两段规则可以来自普通函数、lambda 或函数对象,算法对来源一视同仁,只对调用方式和返回值提出要求。把这个交接方式理解透,后面的 lambda 捕获、函数对象状态、std::function 类型擦除才有落点。
算法和规则之间只认签名
先看清楚交接是怎么发生的。count_if 的第三个参数在模板里只是一个类型,算法内部真正执行的操作可以概括为一句话:每到需要判断的地方,就把元素当作实参调用一次规则对象,再根据返回结果决定下一步动作。规则对象因此只需要满足一个条件:能按约定的签名被调用。谓词的约定是"给一个元素,返回能当作布尔值使用的结果",比较器的约定是"给两个元素,返回布尔值"。满足签名的任何东西都能上岗,普通函数、lambda、重载了调用运算符的对象,乃至 std::function 包装后的统一接口,算法一概不分辨。
这个设计把算法的通用性推到了极限。算法作者写下 count_if 时,不可能预知有人会拿它数及格的学生、数超时的订单、数空字符串,他只需要把"数符合规则的元素"这个流程写对。判断的全部自由度都通过签名留在参数里,由调用方在各自的项目里补全。前两章看到的范围契约解决了"处理哪些元素"的问题,本章的签名契约解决的是"按什么规则处理"的问题,两者合起来就是标准库算法的完整接口观。
签名契约还有一个直接的性能推论。规则对象的类型在编译期就已确定,模板会为每种规则实例化出专用版本的算法,谓词调用和比较器调用全部内联进遍历循环,不存在函数指针那样的运行时间接。count_if 配一个短小 lambda,编译产物和手写循环相差无几,这正是标准库敢于鼓励"能用算法就别写循环"的底气:表达力提升的同时,运行时成本没有增加。上一章对比 qsort 时看到的内联优势,根源就在这套签名契约上。
谓词把筛选条件压缩成一个布尔答案
谓词是出现频率最高的规则对象。它的职责被刻意压缩得很小:看一眼元素,回答真或假。find_if 用它决定要不要停在当前位置,count_if 用它决定计数器加不加一,copy_if 用它决定元素要不要写进输出范围,remove_if 用它决定元素该不该被挪去尾部,any_of、all_of、none_of 用它汇总整段范围的判断结果。这一族算法共享同一种规则形态,学会写一个谓词,等于同时学会了这一整族算法的用法。

谓词还有个值得记住的特征:它不控制遍历。从头走到尾、每个元素停一次、走到 last 结束,这些全是算法的事,谓词对遍历的节奏没有任何发言权,它只在被叫到的那一刻对当前元素表态。这个分工对写出好规则有直接的指导意义:遍历状态属于算法,判断逻辑属于谓词,两者混在一起的写法,比如试图在谓词里记录"已经跳过了几个元素",几乎总是规则变质的开始。
谓词家族里还有一个支流。unique、equal、find_end 这类算法接受二元谓词,一次接收两个元素,回答"它们是否应当视为相同"。二元谓词和比较器长相几乎一样,职责却完全不同:它只判断匹配与否,不定义任何顺序关系。把两者混淆是真实存在的错误,拿排序用的比较器去喂 unique,去重逻辑会按顺序规则而不是相等规则工作,结果微妙地出错。判断的标准很直接:算法需要顺序时给比较器,需要匹配时给二元谓词,需要筛选时给一元谓词。
以 count_if 为例把这条路径走一遍。算法从 first 出发,解引用得到当前元素,把元素交给谓词,谓词返回 true 就让计数器加一,然后前进到下一个位置,重复直到 last。标准承诺谓词在这段过程里对每个元素恰好被调用一次,不多也不少。这个承诺让 count_if 的谓词成了少数可以容忍轻微观察性副作用的场合,但依赖调用次数的习惯仍然要克制,因为换一个算法,承诺就可能不同。

纯度因此成为谓词的第一条质量标准。理想谓词只做读取和判断,不修改元素,不修改外部状态,同样的元素喂进去永远得到同样的答案。这样的谓词可以单独拎出来测试:构造三五个有代表性的元素,逐个验证返回值,对错立见。反过来,在谓词里打印日志、累加外部计数器、改写捕获的变量,语言层面都拦不住你,但规则的难度会立刻翻倍,因为你面对的不再是一个布尔函数,而是一段寄生在别人遍历里的隐秘控制流。
谓词从哪儿来,算法一概不关心。一个普通的自由函数 bool IsExcellent(const Student& s) 可以当谓词,一个 lambda [](const Student& s) { return s.score >= 90; } 可以当谓词,重载了调用运算符的类实例同样可以。三种形态在算法眼里完全等价,区别只在工程属性:函数便于跨文件复用,lambda 贴近调用点,类实例能携带状态。第 08 章和第 09 章会分别展开后两种形态,本章先把它们当成同一种东西:按签名被调用的规则。
比较器表达的是相对顺序
比较器是另一种形态的规则:一次接收两个元素,回答"a 是否应该排在 b 前面"。排序家族是它的主要雇主,std::sort、stable_sort、partial_sort、nth_element 都靠它提供顺序依据;二分查找家族的 lower_bound、upper_bound、binary_search 也接受同一形态的规则,用它判断目标值落在范围的哪一段。std::sort 不传比较器时使用默认的小于号,效果等于按升序排;想要降序,把 std::greater<> 传进去即可。
比较器的返回值语义值得细看。返回 true 的含义明确:a 严格排在 b 前。返回 false 却有两种可能:b 在 a 前,或者两者等价,单次调用区分不了。想知道确切答案,需要反过来看 comp(b, a) 的结果,两次都返回假才能判定等价。排序算法内部正是这样使用比较器的,它从不直接询问"是否相等",只通过双向比较推断。理解了这一点,后面 lower_bound 和 upper_bound 的边界语义就顺理成章了。
比较器这个概念的影响面比排序算法本身大得多。map 和 set 的模板参数里那个 Compare 就是它,关联容器靠它决定键的摆放位置;priority_queue 同样靠它决定谁先出队。学会写比较器,等于同时掌握了排序算法、二分查询和有序容器的规则语言,这也是本章把它和谓词并列处理的原因。顺带一提 std::less<> 这种不带类型参数的形式,它被称为透明比较器,参数类型留到调用点推导,map<std::string, ...> 用它做键比较时,拿字符串字面量查找就不必先构造一个 std::string,省掉一次无谓的分配。容器细节留待容器章节展开,这里先记住,比较器的写法选择也会真实地影响开销。

升序和降序的方向感值得单独练一下。用小于号当比较器,语义是"小的排前面",得到升序;用大于号,语义变成"大的排前面",得到降序。初学者常在这里犯迷糊,以为是排序方向被反转了,其实算法从头到尾只做升序意义上的"按规则排前面",方向完全由规则的定义决定。用反向迭代器遍历排好的升序范围也能得到降序视图,但那是读取顺序的变换,和排序规则是两回事,修改规则才是改变顺序的正路。
严格弱序是比较器的生死线
比较器必须满足一组数学性质,标准称之为严格弱序。用平实的语言拆开是四条:第一,反自反,任何元素和自己比较,结果必须是假;第二,非对称,a 在 b 前成立,b 在 a 前就必须不成立;第三,可传递,a 在 b 前且 b 在 c 前,就必须有 a 在 c 前;第四,等价关系可传递,a 与 b 分不出先后、b 与 c 分不出先后,那么 a 与 c 也必须分不出先后。普普通通的小于号天然满足全部四条,所以大多数时候你意识不到这些约束的存在,直到亲手写坏一条规则。
上一章留下的 <= 悬念在这里正式解决。把比较器写成 a.score <= b.score,反自反当场破产,因为任何元素和自己比都返回真;非对称同样保不住,两个同分元素互相声称自己在前。排序算法的分区逻辑把这两条性质当公理使用,公理被破坏后,标准对这种情况的定性是未定义行为:结果错乱、陷入死循环、越界写内存,都在可能之列。
<= 之外还有一个隐蔽的陷阱:用减法表达比较。return a.score - b.score > 0; 看起来和大于号等价,在分数这种小范围整数上也确实没问题,但换成无符号整数,减法先回绕再比较,结果全错;换成可能很大的有符号整数,减法溢出在 C++ 里属于未定义行为。比较就用比较运算符本身,<、> 直接表达意图,不存在中间结果溢出的机会。这条经验同样适用于排序之外的任何比较逻辑。

浮点数给严格弱序出了一道经典难题。NaN 与任何值比较都返回假,于是按小于号看,NaN 和一切元素等价。等价关系要求可传递,1.0 与 NaN 等价、NaN 与 2.0 等价,按传递性 1.0 应与 2.0 等价,事实却是 1.0 < 2.0。含 NaN 的浮点范围送进 sort,前提已经不满足,行为失去保证。工程做法很明确:排序前先把 NaN 过滤掉,或者显式约定它们的去向,别让特殊值混进比较逻辑。
工程上最棘手的是,错误的比较器经常看起来能跑。数据量小的时候,排序实现可能走在简单路径上,错误的规则未必立刻引爆,测试全绿;数据量一大、内部策略一切换,问题才在生产环境出现。这里说的是常见实现的行为规律,属于排障线索而非标准承诺,但它解释了一类真实的线上事故模式。检查一条比较器是否合法只需几秒钟,排查一次排序崩溃可能要几个小时,账非常好算。
严格弱序里还藏着一个深刻的设计:等价的定义。!comp(a, b) && !comp(b, a) 成立时,称 a 和 b 在这条规则下等价。等价不等于相等,两个同分学生在"只比分数"的规则下等价,用 == 判断却是两个人。stable_sort 保持的正是等价元素的原有次序,lower_bound 和 upper_bound 划出的边界也是等价元素的边界。多键排序的本质,就是在主键判出等价之后启动次级规则继续区分,这个概念马上要用。
多键排序从主键开始逐级裁决
多键需求的原型很朴素:按分数降序,同分时按名字字典序升序。落笔时的思考顺序应当和裁决顺序一致:先比主键,主键能分出先后就直接给答案,分不出再启动次键,次键也平手才返回假。写成代码就是一条链式判断:a.score != b.score 时返回 a.score > b.score,否则返回 a.name < b.name。注意两个键的方向可以不同,降序还是升序由每个键自己的比较符表达。
这条链上有三个高频错误。一是主次键写反,先比了名字再比分数,结果和意图完全颠倒;二是方向写错,想要降序却用了 <;三是在某个键上用了 <= 或 >=,直接跌回上一节的陷阱。三个错误单独看都"差不多能跑",编译不会拦,小规模数据甚至可能给出貌似合理的结果,只有对着预期输出逐条核对时才会现形。

还有一种更紧凑的写法值得知道:return std::tie(b.score, a.name) < std::tie(a.score, b.name);。std::tie 把若干成员打包成引用的元组,元组的 < 按字典序逐键比较,方向和键序全部编码在打包顺序里,想写反都难。这个声明在 <tuple> 里。本章示例刻意使用显式的链式判断,是为了让裁决顺序在阅读时一览无余,工程代码里两种写法都常见,选团队读起来顺的那种。
键再多一两个,组织方式不变,可读性边界却开始出现。三键、四键的链式判断每层都要重复"相等才继续"的骨架,眼睛容易看花,这时 std::tie 的优势被放大,键序和方向一目了然。另一种思路是把整个比较逻辑提成具名函数,用注释写清裁决顺序,再用单元测试逐个键验证。规则越长,越值得拥有一个名字,这也是第 09 章再往前一步的主题。
代码示例
本章的示例文件是 代码示例/03-predicate-comparator/predicate_comparator.cc,用同一个学生数组演示两种规则对象:count_if 配一个谓词统计优秀人数,sort 配一个比较器完成多键排序。
cpp
// Copyright (c) 2026 yus3nable
// SPDX-License-Identifier: MIT
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
struct Student {
std::string name;
int score = 0;
};
int main() {
std::vector<Student> students{
{"Bob", 82}, {"Alice", 95}, {"Tom", 82}, {"Cindy", 95}};
const int excellent = static_cast<int>(std::count_if(
students.begin(), students.end(),
[](const Student& student) { return student.score >= 90; }));
std::cout << "excellent count: " << excellent << '\n';
std::sort(students.begin(), students.end(),
[](const Student& a, const Student& b) {
if (a.score != b.score) {
return a.score > b.score;
}
return a.name < b.name;
});
for (const Student& student : students) {
std::cout << student.name << '=' << student.score << ' ';
}
std::cout << '\n';
}
第一段调用里,谓词 score >= 90 对四个学生各执行一次,命中 Alice 和 Cindy,输出 excellent count: 2。谓词在这里是一个典型的纯判断:读一个字段,做一次比较,不碰任何外部状态,拿出来单独测试只需要一条学生记录。
第二段调用里,比较器被 sort 反复调用,次数随元素规模按 n log n 增长,每次回答一对学生的先后。推演最终结果:95 分的 Alice 和 Cindy 排在前面,两人按名字比较,Alice < Cindy 成立,Alice 在前;82 分的 Bob 和 Tom 同理,Bob 在前。输出为 Alice=95 Cindy=95 Bob=82 Tom=82。值得留意的是,两次调用之间规则对象互不相关,谓词和比较器各自独立履职,这正是规则对象模型的魅力:每段判断都小而完整,组合起来就能驱动复杂的处理流程。
还有一个值得想透的点:四个元素的排序只需几次比较,但具体按什么顺序问,由实现决定,标准不做承诺。关键在于,只要比较器满足严格弱序,任何询问顺序都收敛到同一个最终结果。规则的正确性保证了结果的确定性,调用方完全不需要关心算法的内部调度。反过来说,规则一旦有缝,不同的实现、不同的数据规模就可能给出不同的错误表现,这正是不合法比较器难以排查的深层原因。
读这段示例时还可以注意一个细节:两段算法调用的规则都写在调用点上,读者不需要跳转到别处就能看清判断内容。这就是谓词和比较器带给代码的可读性红利,判断逻辑出现在它被使用的位置。等规则复杂到内联放不下,再把它提走命名,第 08 章会系统讨论这个从调用点到具名规则的迁移过程。
规则按值传递意味着什么
标准库算法普遍按值接收规则对象。count_if 的谓词、sort 的比较器,在函数签名里都是值传递,你递进去的 lambda 会被复制一份供算法内部使用。这个设计的第一个推论是,规则对象应当廉价可复制:函数指针、无捕获 lambda、只捕获了几个小对象的 lambda 都符合预期,背着庞大状态的规则对象则会在每次调用算法时被完整复制。
第二个推论更重要:算法内部使用的是副本,它和调用点的原对象从此分道扬镳。如果规则对象在调用过程中积累了状态,比如自带一个计数器,这些积累发生在副本身上,算法结束后原对象什么都看不到。想在谓词里统计调用次数、在比较器里记录比较路径,按值复制会让这些统计全部失真。for_each 是标准设计好的例外,它在遍历结束后把规则对象返回给调用方,状态有正规的取回通道,其余算法没有这条路。真正需要长期持有状态、对外提供查询接口的规则,归宿是第 09 章的函数对象。
复制成本失控时还有一条正规出路:std::ref。它把规则对象包进一个引用包装器,包装器本身廉价可复制,算法内部复制的是包装而非本体,规则对象的状态始终只有一份。sort(v.begin(), v.end(), std::ref(comparator)); 这样的写法在规则对象较重时很实用。需要记住的是,引用包装器不延长生命周期,被包装的对象必须活得比算法调用久,这一条和第 08 章 lambda 按引用捕获的约束完全同源。
按值传递还框定了规则对象的生命周期模型:副本只在算法调用期间存活,调用结束即刻销毁,所以规则引用的外部对象只需要活过这一次调用。一旦规则被存起来留待以后执行,比如塞进任务队列、注册成事件回调、包进 std::function,约束就从"活过一次调用"升级为"活过每一次调用",悬空引用的风险随之而来。这是第 08 章按引用捕获和第 10 章类型擦除共同面对的课题,本章只需先建立这个时间感:规则的寿命由它的使用方式决定。

还有一条写规则时的硬边界:不许通过参数修改元素。谓词拿到的是遍历中的真实元素,比较器拿到的两个引用同样指向范围内部,排序进行到一半时改动元素的值,等于在算法眼皮底下换掉它正在使用的数据,一切顺序假设立刻失效。规则对象只读取、不写入,这条约束虽然没有语法强制,却是所有算法正常工作的前提。lambda 默认的调用运算符是常量成员函数,恰好从类型层面鼓励了这种只读习惯,动用 mutable 绕开它之前,最好先想清楚状态最终流向哪里。
规则写一次,多处调用
同一条谓词可以喂给一整族算法。把"分数达到 90"写成具名规则之后,find_if 用它找第一个优秀者,count_if 用它统计人数,copy_if 用它导出名单,all_of 用它检查是否全员达标,意图在一处定义,四处使用。比较器的复用面同样宽:sort 用过的比较器,可以直接交给 partial_sort、nth_element、lower_bound 和 binary_search。
复用带来一条硬前提:规则必须前后一致。binary_search 的调用前提是范围按同一套顺序排好,用分数降序排过的数组,拿默认小于号去做二分查找,结果没有任何保证。集合类算法如 set_union、set_intersection 也要求两段输入按同一个比较器有序。排序用什么规则,查询就得用什么规则,这条对应关系在代码里没有任何机制替你检查,全靠调用方自觉维护。
一致性在日常代码里最常见的失守点,是排序和查询分写在两个文件里。排序的地方传了自定义比较器,查询的地方忘了传,默认小于号接管,二分查找在顺序不符的范围上给出错误答案,而这类 bug 在测试数据恰好简单时完全隐形。防御办法很朴素:自定义了排序规则的模块,把同一条规则连查询版本一起导出,让调用方拿不到"半个规则"。排序与查询的规则对象成对出现,应当成为代码评审的固定检查项。

规则写到什么程度该起名字,是个实际的分寸问题。只用一次、三五个词就能读完的规则,留在调用点写成 lambda 最自然;用到两次以上,或者复杂到需要注释才能解释清楚时,提成具名函数或函数对象更划算。名字本身就是文档,HasPassingScore 比任何内联表达式都更能说明业务意图。这个分寸感是第 08 章和第 09 章的主线,本章先建立一个意识:规则对象是可以沉淀、可以复用的资产,值得像对待函数一样对待它。
规则沉淀下来之后,组织方式也有讲究。跨文件复用的比较器适合收进专门的头文件,按领域命名空间归组,名字直接对齐业务语言,ByDeadlineAsc、ByPriorityDesc 这类命名让调用点几乎不需要注释。时间久了,项目里会长出一个小型规则库,新需求来了先查有没有现成规则,这个习惯对保持代码库的表达力非常划算。
把规则交给算法之前先过三遍
谓词的自查可以浓缩成几个问题。返回值是不是干净的布尔语义,同样的元素喂进去是否永远得到同样的答案,有没有偷偷修改元素或外部状态,有没有依赖被调用的次数和顺序,捕获的外部对象在算法执行期间是否确定存活。这几个问题过一遍,谓词出问题的概率就降了一个量级。
比较器的自查同样有三个固定动作。第一,comp(x, x) 对任意元素都返回假吗,这一个问题就能拦下 <= 这类事故;第二,comp(a, b) 为真时 comp(b, a) 必然为假吗;第三,随手挑三个元素验证传递性成立吗。多键比较器再加一问:主键等价时才启动次键,这个顺序想清楚了吗。等价和相等的区别也要在心里放好,排序、稳定排序、二分边界的语义全都建立在等价之上。
两类规则还有一条共同的质量标准:尽量让规则本身可以独立测试。把谓词和比较器当作普通函数对待,喂样例、验输出,正确性在接入算法之前就确定下来。排序结果不对时,先单独测试比较器再怀疑算法,这个排查顺序能省掉一大半时间。规则对象做到小、纯、有名字这三件事,算法层的 bug 会少得让你不习惯。

最后把视野拉回整章。谓词把筛选条件压缩成布尔答案,比较器把顺序规则压缩成两个元素的先后裁决,算法负责除判断以外的一切。这套分工一旦成为本能,读标准库代码的方式就会改变:看到算法调用,先找范围,再找规则,两件事都清楚,这段代码就没有秘密。谓词就位之后,最先受益的是查询家族,find、count、any_of 这批算法能把搜索意图直接写进函数名里,下一章就看它们怎样把查询写成意图。
码字不易,欢迎大家点赞,关注,评论,谢谢!