【C++进阶】STL算法与函数对象 - 09 函数对象保存状态并复用规则

博主介绍:程序喵大人

好文推荐:

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

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

【C++进阶】STL算法与函数对象 - 02 sort为什么需要随机访问迭代器

【C++进阶】STL算法与函数对象 - 03 谓词和比较器把判断逻辑交给算法

【C++进阶】STL算法与函数对象 - 04 find、count和any_of把查询写成意图

【C++进阶】STL算法与函数对象 - 05 transform、copy和for_each把遍历从循环里抽出来

【C++进阶】STL算法与函数对象 - 06 remove_if 和 erase 配合完成真正删除

【C++进阶】STL算法与函数对象 - 07 数值算法:折叠、扫描与成对归约

【C++进阶】STL算法与函数对象 - 08 lambda 捕获让临时规则贴近调用点

定义一个类,放一个 int 成员做计数器,重载 operator() 让它每次被调用时自增一次,再把这个对象传给 std::for_each 遍历五个元素。跑完以后读计数器,结果却是 0:

cpp 复制代码
#include <algorithm>
#include <iostream>
#include <vector>

class VisitCounter {
 public:
  void operator()(int) { ++count_; }
  int count() const { return count_; }

 private:
  int count_ = 0;
};

int main() {
  std::vector<int> values{1, 2, 3, 4, 5};
  VisitCounter counter;
  std::for_each(values.begin(), values.end(), counter);
  std::cout << counter.count() << '\n';  // 输出 0
}

for_each 内部确实调了五次 operator(),但算法按值接收函数对象,自增都发生在副本上,你手里的 counter 没被动过。第一次遇到这个现象的人几乎都会觉得是 bug,但它完全符合标准对函数对象按值传递的描述。

这个现象引出的问题正是本章的主线:函数对象------重载了 operator() 的类实例------如何在算法中被使用,它的复制语义会带来什么后果,以及一条带状态的规则什么时候该从 lambda 里提出来,长成一个有名字、有配置、能被整个项目引用的类型。

对象怎么会像函数一样工作

C++ 允许任何类重载函数调用运算符 operator()。一旦重载了这个运算符,类的实例就能出现在调用语法里:写下 expensive(product),编译器把它解析成 expensive.operator()(product)。调用一个对象,本质上就是对它执行一次普通的成员函数调用,只不过函数名被省略了,语法看起来和调用一个普通函数没有区别。operator() 本身没有任何特殊限制:可以标 const,可以带任意数量的参数,也可以重载出多个版本。谓词版本通常收一个元素返回 bool,比较器版本收两个元素返回先后关系。

标准库算法靠这一点保持通用。以 std::count_if 为例,它的第三个模板参数类型叫 UnaryPredicate,算法内部只做一件事:对范围里的每个元素调用 pred(element),检查返回值是否为 true。这个调用表达式能通过编译,参数就合格;至于传进来的是函数指针、lambda 闭包还是手写的类对象,模板不关心,也不需要关心。这种只检查表达式是否合法、不检查类型出身的设计,让规则对象和算法彻底解耦:算法库只需要写一份,规则可以有无数种写法。

性能层面还有一个差别值得知道。传函数指针时,算法内部持有的是一个地址,每次调用必须经过一次间接跳转,跳转目标要到运行期才确定,编译器几乎无法内联。传函数对象时,模板在实例化之后类型完全确定,调用是直达 operator() 的静态调用,编译器通常能把整个函数体内联进算法主循环。工程上常有人默认手写循环比标准库算法快,实际上算法配合函数对象生成的机器码经常和手滚循环没有差别,有时因为内联更充分还会占优。

把配置写进构造函数

函数对象区别于普通函数的核心在于:它能持有数据成员。一条规则往往带着参数------价格下限、重试上限、匹配前缀、排序方向。普通函数没有地方安放这些参数,要么诉诸全局变量,要么给每次调用追加额外参数,两条路都不好维护。函数对象的做法顺理成章:构造函数负责把配置收进来存成私有成员,operator() 执行规则时直接读取成员。配置在对象的一生中只注入一次,之后每个调用点都干干净净,只需要写 pred(element) 而不用再传额外的上下文。

和上一章的 lambda 放在一起看,这个模型的对应关系很清楚。写下 min_price { return p.price >= min_price; },编译器在幕后做的事情和手写类几乎一样:生成一个匿名类型,给按值捕获的变量开一个数据成员,再补一个默认标 const 的 operator()。lambda 是这个模型的语法糖版本,函数对象是手工版本。语法糖胜在就地书写、没有样板代码;手工版胜在能起一个名字、能在构造函数里做参数校验和预计算、能添加辅助成员函数,还能放进头文件被整个项目引用。

复用是手工版最直接的回报。本章 demo 里的 PriceAtLeast 一旦写好,std::count_if 拿它统计高价商品数量,std::find_if 拿它找第一件高价商品,std::copy_if 拿它导出一份高价清单,std::partition 拿它把高价商品挪到区间前部,std::all_of 拿它检查是否全场高价。五个算法消费同一条规则,定义只有一份。哪天业务把"高价"的口径从固定 300 改成按类目分级,改动只发生在类的内部,所有调用点自动跟进。如果当初是 lambda,同一段逻辑抄在五个地方就是五份互不相识的副本,口径变化时只能靠人工逐处对齐,漏掉任何一处都是潜在的业务 bug。

比较器也能长成对象

谓词回答的是"这个元素合不合格",比较器回答的是另一个问题:"两个元素谁该排在前面"。比较器同样可以做成函数对象:operator() 接收两个参数,返回第一个是否应该排在第二个之前。std::sort 拿到这样一个对象后,会在内部成百上千次地调用它,用返回值织出最终顺序。排序算法本身对业务一无所知,按价格排还是按库存排,全靠比较器的回答来决定。

比较器有一条硬性约束:必须满足严格弱序(strict weak ordering)。具体含义是三点------任何元素和自己比较必须得到 false;如果 a 排在 b 前面,那么 b 不能再排在 a 前面;先后关系必须能传递。标准把这条列为排序类算法的前置条件,违反了它,算法行为失去一切保证,工程上直接按未定义行为对待。最经典的翻车方式是把比较写成 a.price <= b.price 这种带等号的版本:两个价格相等的元素互相都声称对方该排在前面,排序算法内部的循环假设当场被击穿,轻则顺序错乱,重则越界崩溃。正确做法始终是用严格小于或严格大于,等价的元素必须返回 false。

把比较器做成对象还有额外的配置空间。构造时注入排序方向、次要排序键、空值处理策略,operator() 内部按配置分支,一个类就能覆盖一族排序需求。标准库在 functional 里也备了一批无状态的现成比较器,std::less 和 std::greater 最常用,排内置类型时直接拿来即可。它们和手写比较器走的是同一条 operator() 通道,std::sort 对待它们的方式没有任何区别。

代码示例

本章的示例文件是 代码示例/09-function-object/function_object.cc,全文如下。

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

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

struct Product {
  std::string name;
  int price = 0;
  int stock = 0;
};

class PriceAtLeast {
 public:
  explicit PriceAtLeast(int min_price) : min_price_(min_price) {}

  bool operator()(const Product& product) const {
    return product.price >= min_price_;
  }

 private:
  int min_price_ = 0;
};

int main() {
  std::vector<Product> products{
      {"keyboard", 399, 12}, {"mouse", 129, 3}, {"monitor", 1299, 1}};

  const PriceAtLeast expensive(300);
  const int count =
      static_cast<int>(std::count_if(products.begin(), products.end(), expensive));
  std::cout << "expensive count: " << count << '\n';

  std::sort(products.begin(), products.end(),
            [](const Product& a, const Product& b) {
              return a.price < b.price;
            });
  std::cout << "cheapest: " << products.front().name << '\n';
}

先看数据结构和规则类。Product 是一个普通聚合体,三个字段分别是名称、价格和库存。PriceAtLeast 是本章的核心角色:构造函数标了 explicit,阻止 int 隐式转换成 PriceAtLeast,调用点必须写出 PriceAtLeast{300} 这样的显式构造;min_price_ 是配置状态,构造时注入后只读不写;operator() 标了 const,说明调用规则这件事本身不会改变规则对象的状态。这个 const 很重要------它允许对象本身被声明为 const 之后仍然能被调用,也向读者和编译器同时传达了一个信号:这个函数对象是纯判断,不带副作用。

再看两次算法调用。std::count_if 沿着 products 的迭代器范围遍历一遍,把每个元素交给 expensive,返回 true 就计数一次。三件商品里 keyboard 的价格 399 和 monitor 的价格 1299 都不低于 300,只有 mouse 的 129 不过线,输出 expensive count: 2。随后 std::sort 登场,这一次规则写成了 lambda,按 price 做升序比较。排完之后 products.front() 是价格最低的 mouse,输出 cheapest: mouse。

两种写法并排放在一起是刻意的安排。PriceAtLeast 是手写的具名类,sort 旁边的 lambda 是编译器生成的匿名类,但在算法眼里它们严格同构:都带着 operator(),都按值传进模板参数,都在算法内部被一视同仁地调用。标准库对可调用对象的协议只有这一条------能以 pred(element) 或 comp(a, b) 的形式合法调用即可,满足它,规则长什么样都行。PriceAtLeast 在这个 demo 里只被消费了一次,但它已经具备复用的全部条件:换成 find_if、copy_if、partition,规则本体一行都不用改。

算法拿到的是一份副本

现在可以回到开头的悬念。标准库算法按值接收函数对象:std::count_if 的第三个参数是值形参,传进去的 expensive 在进入算法的那一刻就被复制了。标准还有一条更进一步的说明------除非个别算法另行承诺,接收函数对象的算法可以在内部自由复制这些对象,想复制几份就复制几份。算法体内被反复调用的,始终不是你手里的那份原件,而是它的副本。原件上的成员从头到尾不会被任何算法内部的逻辑触碰到。

回到计数器的例子。把带 ++n_ 的函数对象传给 std::for_each,自增动作全部发生在算法内部的副本身上,原件的 n_ 保持 0。这个行为是正确的,和标准完全一致,只是和直觉相反。很多人第一次遇到这件事时会怀疑是编译器 bug 或者自己的代码写错了,但真正的原因在于对"按值传递"这个前提的忽视:你递出去的是一份拷贝,而不是一个引用。

标准留了几个正规的出口来解决这个问题。第一个出口是 std::for_each 的返回值:它是标准算法中唯一一个把处理完范围的函数对象再移动出来返回的算法,写 auto used = std::for_each(first, last, counter),used 身上的计数就是完整结果。第二个出口是 std::ref:用 reference_wrapper 把对象包一层再传进去,包装本身是可调用的,调用会穿透包装落在原对象身上,统计就记在了原件里。

这两个出口都能用,但工程上更值得推荐的是第三条路:让运行期结果走算法自身的返回值。count_if 返回计数,accumulate 返回折叠结果,minmax_element 返回位置对------标准算法本来就为常见统计需求准备好了出口。规则对象只负责纯判断,统计结果通过算法返回值交接,数据流只剩一个方向,没有隐藏的写入点。把统计逻辑偷偷塞进谓词的可变成员里,再靠 std::ref 或返回值捞回来,代码虽然能跑,可读性和可测性却打了折扣;换算法或换执行策略时还容易出问题,因为标准对谓词的调用次数只给复杂度层面的约束,具体调几次、调哪份副本,都不应该成为业务逻辑的前提条件。

还有一条标准层面的约束值得一并记住:谓词不得通过参数修改元素。算法把迭代器解引用后的元素交给谓词,是让你检查它的值,不是让你顺手改它;比较器的两个参数同理,都该是只读视角。规则对象最健康的形态是纯函数式的------同样的输入永远给出同样的回答,不依赖外部可变状态,也不修改任何东西。

无状态与有状态的分工

按身上是否持有数据成员,函数对象分成两种。无状态对象没有数据成员:std::less、std::plus 这类标准库现货,以及自己写的只做纯判断的类型(比如一个判断奇偶的 IsEven),都属于这一类。复制无状态对象等于复制一个空壳,任意两份实例完全等价,算法内部怎么复制都不会产生语义差异,行为和普通函数一致,还额外获得了内联的机会。有状态对象带着数据成员,配置参数、缓存、统计信息都挂在自己身上,表达能力更强,代价是必须把复制语义想清楚。

有状态对象内部还有一条重要的分界线:配置状态和运行状态。配置状态在构造时注入,调用期间只读不写,PriceAtLeast 的 min_price_ 就是典型代表。这种状态对复制免疫,因为所有副本读到的值一致,无论哪份被调用结果都一样。运行状态在调用期间被修改------计数器、缓存命中记录、最近一次匹配结果都属于这类。它对复制敏感:副本之间各自累计、互不知晓,开头的计数器悬案正是发生在这里。一条实用的设计准则是,配置状态放心放进数据成员,运行状态要么走算法返回值,要么通过 std::ref 显式共享,绝不含糊地塞进成员里指望调用方事后能看到。

lambda 那边也有对应物。带 mutable 的按值捕获 lambda 同样能持有运行状态,而且问题比手写类更隐蔽:状态没有名字,外部无法访问,测试无法注入,连调试时都要多费点劲才能看到值。状态越重要、生命周期越长,就越应该给它一个类型、一个名字和一组明确的接口,这正是函数对象相对匿名闭包的结构性优势所在。

规则什么时候该有自己的名字

本章的核心判断落在这里:一条带状态的规则,什么时候该从 lambda 提升成具名的函数对象。有几个信号比较实在。

第一个信号是复用需求。同一条规则出现在第二个调用点时就值得警惕,出现在第三个调用点时就应该动手提取。复制粘贴的 lambda 每多一份,将来口径漂移的风险就翻一倍------某天修改了其中一处,另外几处忘了同步,产生的 bug 往往很晚才被发现,因为代码看起来没有任何关联。

第二个信号是捕获列表膨胀。当 lambda 的方括号里塞了三个以上的变量,规则需要的上下文已经不适合用一行捕获列表来表达了。构造函数是更好的安放处,参数校验、预计算、查表这些准备工作都能名正言顺地写进去,而不是挤在 lambda 体内的前几行。

第三个信号是可测试性。函数对象可以独立实例化,喂几个样例输入,断言返回值,测试代码直接贴着规则定义走。藏在算法调用深处的 lambda 没有独立实体,想单独测试它就必须绕到算法外面搭一个完整场景,测试的成本和脆弱性都会升高。

第四个信号偏向设计层面:规则本身是一个领域概念。"价格不低于 300"在电商代码里不是循环体内的临时判断,它是一条有业务含义的定价筛选规则。给它 PriceAtLeast 这个名字之后,规则就成了领域对象,可以进头文件、进命名空间、进文档、进 code review 的讨论清单。新同事读到 std::count_if(first, last, PriceAtLeast{300}) 时获得的信息量,和读一个三行匿名 lambda 完全不在同一个层面------算法名字表达遍历意图,规则对象的名字表达业务意图,两层名字都在场时,数据处理代码才真正做到自解释。

反方向的信号同样需要说清楚。只服务一个调用点、逻辑只有两三行、捕获一两个变量的规则,留在 lambda 里是最经济的选择,提成一个类反而逼着读者跳到别的文件去找定义,增加了阅读时的跳转成本。判断的轴心只有一条:这条规则是调用点的局部实现细节,还是业务中站得住脚的独立概念。前者匿名就好,后者值得一个名字。上一章讲 lambda 捕获,强调的是规则贴近调用点的表达力;本章讲函数对象,强调的是规则的复用、命名和独立测试能力。两章合起来覆盖的是同一个设计判断的两端。

写进项目之前的几条边界

在真实项目中使用函数对象时,有几条边界值得过一遍。operator() 尽量保持 const 和纯判断:不修改被检查的元素,不修改对象自身的可变成员,不依赖全局可变状态。比较器在上线前过一遍严格弱序自检,特别盯住带等号的边界写法------前面已经说过,a.price <= b.price 是排序算法最经典的翻车根源。对象本身保持轻量,因为算法按值接收,每次传入都会触发一次复制,复制成本会摊进每次算法调用前的准备开销里。如果对象确实需要引用大块共享数据(比如一张配置表或一个词典),让它持有指针或引用成员,把重量留在对象外面,同时保证被指向的数据活得比算法调用久------悬空的引用成员比复制开销可怕得多。

运行期统计要有明确的归属路径。首选算法自身的返回值,其次是 std::for_each 递回来的那份函数对象,再其次才是 std::ref 包装出来的共享原件。三条路按可读性排序,越靠后越需要在注释里写清楚状态归谁管。并发场景还要多想一层:一旦执行策略切换到并行版本(比如传入 std::execution::par),规则对象可能被多个线程同时调用,任何写入成员的运行状态都会撞上数据竞争。这时候纯函数式的无状态或只读状态的规则对象几乎是唯一安全的选择。

放到系列的主线里看,本章补上了可调用对象版图中的关键一块。容器解决了数据放哪、怎么暴露成范围的问题;第 01 章确立了算法处理范围的契约;第 03 章把谓词和比较器请进了算法的参数列表;第 08 章让临时规则以 lambda 的形态贴着调用点生长;本章则让规则自己长大------有名字、有配置、有独立的测试路径、有跨调用点的复用价值。容器、范围、算法、规则四块拼齐之后,STL 的数据处理语法才算完整。

但函数对象也有自己的边界:类型在编译期就固定了。PriceAtLeast 是 PriceAtLeast,每个 lambda 是各自独立的匿名闭包类型,函数指针又是另一种类型,它们彼此互不相识。模板接口不在乎这一点,因为它按调用表达式实例化,来者不拒;可一旦接口需要在运行期统一保存、传递、替换这些形态各异的可调用对象------比如策略注册表、事件回调表、跨模块的任务队列------类型各不相同就变成了真正的障碍。下一章的 std::function 处理的正是这个问题:它用类型擦除把普通函数、lambda、函数对象藏进同一个签名后面,接口从此只需要问一句话------能不能按这个签名调用。

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

相关推荐
致Great6 小时前
DeepSeek Harness插件开发实战教程:我让它自己写了一个 arXiv 搜索插件
算法
ShineWinsu6 小时前
对于C++:auto_ptr、unique_ptr、shared_ptr的模拟实现
c++·面试·笔试·智能指针·unique_ptr·shared_ptr·auto_ptr
hsjiasb7 小时前
FreeRTOS学习(二十七)——任务栈与栈溢出检测
开发语言·stm32·学习·学习笔记·freertos
丰锋ff8 小时前
3.1 Qt事件之事件处理器
开发语言·qt
多弗朗皮卡丘8 小时前
C语言梦开始的地方7:函数
c语言·开发语言
月华路8 小时前
G1 垃圾回收:脏卡队列、记忆集与并发精化线程机制
java·开发语言
XLYcmy9 小时前
京东 算法实习一面 下+手撕
c++·python·llm·概率论·数据处理·训练·codebert
gugucoding9 小时前
47. 【Java】Java内存模型(JMM)与可见性
java·开发语言
m0_380743879 小时前
从零调用 Claude 教程
开发语言·python·node.js