Chapter 33: Function Objects
函数对象把“可调用行为”放进一个拥有具体类型、对象状态和生命周期的对象里。对 STL 来说,这个设计把排序规则、筛选规则、变换规则和策略参数统一交给模板参数或普通函数参数传递,使算法和容器能够在编译期看到调用对象的类型,在运行期读取调用对象携带的状态。
本章围绕一个贯穿场景展开:一组 Record 需要按分数排序,随后按名字查找,再按阈值筛选。这个场景会同时触发比较器、透明比较器、状态仿函数和算法谓词四类问题。读完本章后,读者应能判断一个可调用对象应写成普通函数、函数对象、lambda,还是后续章节中的 std::function 包装;本章只处理函数对象自身的对象模型和 STL 接入路径。
C++ 标准草案把 function object type 定义为能出现在函数调用表达式位置的对象类型,并说明算法接口接收 function object,从而同时支持函数指针和拥有 operator() 的类对象。这个定义的工程含义是:STL 算法看到的参数是一个可以被拷贝、移动、存储、实例化和调用的对象,函数指针只是其中一种可调用输入。相关标准措辞可从 C++ draft: function objects 回溯,标准库内置函数对象列表可从 cppreference: Function objects 查阅。
33.1 function object model 与 operator()
函数对象的核心形状是一个类对象提供 operator()。调用语法看起来像函数调用,语义上仍然是对一个对象执行成员函数调用。这个差异决定了函数对象能携带状态、能参与重载决议、能被模板实参推导识别,也能在容器或算法内部被保存为成员对象。
下面的比较器服务贯穿场景:先按 score 降序排序,分数相同再按 id 升序稳定区分记录。它的 operator() 是 const 成员函数,表示比较一对元素时读取比较策略和元素内容,并保持比较器对象的可观察状态稳定。
#include <algorithm>
#include <string>
#include <vector>
struct Record {
int id;
std::string name;
int score;
};
struct ByScoreThenId {
bool operator()(const Record& lhs, const Record& rhs) const {
if (lhs.score != rhs.score) {
return lhs.score > rhs.score;
}
return lhs.id < rhs.id;
}
};
void sort_records(std::vector<Record>& records) {
std::sort(records.begin(), records.end(), ByScoreThenId{});
}
这段代码中,ByScoreThenId{} 创建的是一个临时比较器对象。std::sort 接收它之后,可以把它按值保存到内部调用路径中。算法调用比较器时执行的是 comp(a, b),这个表达式会进入 ByScoreThenId::operator()。比较逻辑因而成为算法的可插入策略,同时仍保留普通对象的类型和生命周期。
函数对象和普通函数的第一层差异是状态位置。普通函数的行为主要由函数体和参数决定;函数对象的行为还可以由对象成员决定。即使 ByScoreThenId 没有数据成员,它仍然是一个可命名类型。这个类型能作为模板参数的一部分参与实例化,因此 std::sort 面对 ByScoreThenId 和面对另一个比较器类型时,会得到不同的实例化路径。
函数对象和虚函数对象的差异在调用绑定时机。std::sort 这类算法通常是函数模板,比较器类型在编译期确定。编译器看到具体 operator() 后,拥有内联、常量传播和删除空对象存储开销的机会。这里的关键词是“机会”:是否内联仍取决于优化级别、函数体、调用频度和编译器判断,正文中的结论只说明类型信息为优化提供条件。
函数对象的第二层差异是对象语义。算法通常按值接收比较器或谓词,这意味着可调用对象应满足被拷贝或移动后仍能保持调用语义。若一个函数对象内部保存指针、引用、文件句柄或统计状态,调用者需要先判断这些成员在算法复制后是否仍指向有效资源,复制后的多个函数对象是否共享同一份状态。
33.2 标准比较器与透明比较器
标准库提供 std::less、std::greater、std::equal_to 等比较类函数对象,它们把语言内置运算符包装成可传给算法和容器的对象。以 std::less<T> 为例,主模板调用 lhs < rhs;C++14 之后的 std::less<> 使用 void 特化,调用时从实参推导参数类型。标准草案中 less<void> 的调用形式包含 is_transparent 类型成员,并使用转发后的两个实参执行 < 运算;可对照 C++ draft: comparisons.less。
比较器进入排序算法和有序容器时,还要满足严格弱序(strict weak ordering)。这个要求可以用四个检查点落地:同一对象和自身比较应为假;若 comp(a, b) 为真,则反向比较应为假;若 a 在 b 前且 b 在 c 前,则 a 应在 c 前;等价关系 !comp(a, b) && !comp(b, a) 应具备自反、对称和传递。cppreference 的 Compare named requirement 给出了这组要求的接口化描述。
有序容器中的比较器还有一个额外工程边界:比较器定义的是键空间的排序关系,容器用它定位节点、判断等价键和维护树结构。对 std::set<Record, ByScoreThenId> 来说,两个 Record 若分数和 id 都相同,即使 name 不同,也会被比较器视为等价键。容器语义跟 operator== 分离,键等价由比较器诱导出来。
透明比较器解决的是异构查找的参数类型问题。没有透明比较器时,std::set<std::string> 的查找接口通常需要构造或传入 std::string 这类 key 类型对象。使用带 is_transparent 的比较器后,有序容器的查找模板能接受另一种可比较类型,例如用 std::string_view 或字符串字面量查找 std::string 键。标准库的 std::less<> 是常见透明比较器;自定义比较器也可以提供 using is_transparent = void; 并补齐异构参数重载。
下面的例子把查找键从完整 User 对象降到名字视图。比较器同时支持 User 与 User、User 与 std::string_view、std::string_view 与 User 三组比较,容器才能在树搜索过程中把节点值和外部查询值放入同一套顺序关系中。
#include <set>
#include <string>
#include <string_view>
struct User {
std::string name;
int id;
};
struct ByName {
using is_transparent = void;
bool operator()(const User& lhs, const User& rhs) const {
return lhs.name < rhs.name;
}
bool operator()(const User& lhs, std::string_view rhs) const {
return lhs.name < rhs;
}
bool operator()(std::string_view lhs, const User& rhs) const {
return lhs < rhs.name;
}
};
void lookup_user(const std::set<User, ByName>& users) {
auto it = users.find("alice");
(void)it;
}
透明比较器的正确性检查要围绕同一排序关系展开。三组重载都应比较 name,并且产生同一套等价类。若 User 与 User 按 name 排序,User 与 std::string_view 却按 id 或字符串长度排序,树搜索会沿着与建树时不同的方向前进,查找结果就会失去可靠依据。
std::equal_to 的位置也要区分。它适合表达相等判断或无序容器的 key equality;有序排序和 std::sort 的比较器参数需要的是“是否排在前面”的二元关系。把 std::equal_to<> 传给排序算法无法建立严格弱序,算法的前提被破坏。判断比较器时先问它是否定义顺序,再问它是否支持异构参数,最后才考虑它是否来自标准库内置对象。
33.3 stateful functor
状态仿函数是在函数对象内部保存参数、缓存或策略的对象。它适合把“调用时反复需要的上下文”从算法循环中抽出来,使算法只接收一个可调用对象,同时让调用对象在每次 operator() 执行时读取同一份配置。
筛选分数时,阈值属于策略状态。把阈值保存在函数对象中,比在外层写多个手工循环更容易复用,也让 std::count_if、std::copy_if 这类算法继续只关心输入区间和谓词调用。
#include <algorithm>
#include <cstddef>
#include <vector>
struct ScoreAtLeast {
int threshold;
bool operator()(const Record& record) const {
return record.score >= threshold;
}
};
std::size_t count_passing(const std::vector<Record>& records, int threshold) {
return static_cast<std::size_t>(
std::count_if(records.begin(), records.end(), ScoreAtLeast{threshold})
);
}
这个 ScoreAtLeast 的状态是值类型 int,复制后语义稳定。算法内部复制谓词时,每个副本携带同样的阈值;结果只取决于元素内容和阈值。这样的状态仿函数适合进入顺序算法,也适合迁移到一些并行算法的只读谓词场景。
状态仿函数也可以保存引用,但引用成员会把算法调用的正确性绑定到外部对象生命周期。下面的形式能表达“阈值来自配置对象”,但调用者要保证 Config 在整个算法执行期间有效,并且比较过程中不会被并发修改。
struct Config {
int pass_score;
};
struct PassByConfig {
const Config& config;
bool operator()(const Record& record) const {
return record.score >= config.pass_score;
}
};
状态仿函数内部的可变状态要分使用场景。把阈值、排序方向、投影字段这类配置保存在对象中,通常是稳定做法;把统计结果、日志缓冲或累加器保存在谓词对象中,需要先检查算法是否返回最终函数对象、是否可能复制多个副本、是否使用执行策略。std::for_each 的顺序重载会返回函数对象,可用来取回内部状态;许多查询和排序算法只承诺使用谓词参与判断,副作用的观察顺序和副本合并方式不应成为业务结果来源。
状态仿函数还影响异常安全。若状态成员的拷贝构造会抛异常,算法在接收或复制谓词时可能还没开始处理元素就失败。若 operator() 会抛异常,算法对元素已经做出的修改是否保留,要回到具体算法的异常安全承诺。排序比较器抛异常时,区间可能已经发生部分重排;筛选类只读算法抛异常时,输入区间通常保持原对象状态,但输出迭代器对应的目标区间可能已经写入部分结果。
33.4 函数对象与算法
STL 算法通过函数对象接收调用者插入的行为:排序算法接收比较器,查询算法接收谓词,变换算法接收变换函数,归约算法接收二元运算。算法负责遍历区间、移动迭代器、处理复杂度边界;函数对象负责给定一个或一组元素时返回本次决策。
继续使用 Record 场景,排序、筛选、变换可以共享同一份数据,但各自接收不同可调用对象。这个组合展示了算法和函数对象的责任分界:算法控制“访问哪些元素、按什么顺序访问、写到哪里”;函数对象控制“这次比较、筛选或映射的结果是什么”。
#include <algorithm>
#include <iterator>
#include <string>
#include <vector>
struct ProjectName {
std::string operator()(const Record& record) const {
return record.name;
}
};
std::vector<std::string> passing_names(std::vector<Record> records, int threshold) {
std::sort(records.begin(), records.end(), ByScoreThenId{});
std::vector<Record> passing;
std::copy_if(
records.begin(), records.end(),
std::back_inserter(passing),
ScoreAtLeast{threshold}
);
std::vector<std::string> names;
std::transform(
passing.begin(), passing.end(),
std::back_inserter(names),
ProjectName{}
);
return names;
}
std::sort 对比较器的要求比 std::copy_if 对谓词的要求更强。排序比较器要建立严格弱序,因为算法会根据比较结果交换元素位置,并把这些局部比较组合成全局有序结果。筛选谓词只需要对单个元素返回可转换为 bool 的结果,算法用它决定当前元素是否写入输出区间。
std::transform 中的函数对象承担映射责任。它接收一个 Record,返回一个 std::string。这里的返回值会进入 std::back_inserter(names) 触发插入。函数对象的返回类型、输出迭代器的写入能力、目标容器的元素类型共同决定这条路径能否编译。模板错误经常出现在这个交界处:可调用对象能被调用,但返回值无法写入目标位置。
算法调用函数对象时,值类别和引用类别也会传递到 operator() 的参数匹配中。上面的三个函数对象都接收 const Record&,表示只读访问元素。若函数对象接收 Record& 并修改元素,调用者要检查算法是否允许通过当前迭代器写入元素,以及修改是否会破坏后续算法前提。有序容器的键值迭代器通常提供受限访问,排序过程中修改比较所依赖的字段会直接破坏比较结果的一致性。
函数对象还把错误从运行时提前到模板实例化期。若 ProjectName::operator() 返回 int,而输出迭代器写入 std::vector<std::string>,编译器会在实例化 std::transform 的赋值表达式时报告错误。这个错误看起来可能位于标准库模板内部,阅读时应沿着“算法需要调用什么表达式、该表达式的实参类型是什么、返回值写到哪里”回到用户传入的函数对象。
33.5 工程使用场景
函数对象适合出现在四类工程位置:模板参数、排序规则、查找规则和策略注入。判断顺序先看可调用对象是否需要状态,再看调用点是否处于模板热路径,再看规则是否要被容器长期保存,最后看接口是否要隐藏具体类型。
在模板参数位置,函数对象的具体类型能进入实例化路径。算法模板、策略类模板、容器模板都能把比较器或策略对象作为类型参数保存。此时函数对象适合表达“编译期可见的行为”。这种做法常用于排序、投影、哈希、等价判断和分配策略旁路的轻量配置。
在排序规则位置,比较器必须先通过严格弱序检查。工程判断顺序是:比较字段是否稳定;等价类是否符合业务语义;反向比较是否互斥;传递性是否能跨三个对象成立;比较过程中是否读取外部可变状态。只要某一项无法说明,排序算法和有序容器的前提就没有建立。
在查找规则位置,透明比较器要额外检查异构重载是否共享同一关系。std::less<> 适合键类型本身已经支持跨类型 < 的场景;自定义透明比较器适合用对象中的某个字段作为查找键,例如用 User::name 接收 std::string_view 查询。异构查找的收益来自减少临时 key 构造和表达更准确的查询输入类型。
在策略注入位置,状态仿函数适合保存只读配置。阈值、大小写规则、排序方向、字段投影、区域设置句柄都可以成为成员。若策略要跨线程共享,成员类型应提供清晰的只读或同步语义。若策略需要在调用中累积结果,优先把累积结果放入算法明确返回的对象、输出迭代器或外部受控状态,而非依赖谓词对象副本的隐式合并。
函数对象和 lambda 的选择属于接口可维护性问题。局部一次性规则使用 lambda 通常更短;被多个算法复用、需要命名、需要测试、需要作为容器模板参数暴露的规则,更适合写成具名函数对象。函数对象和 std::function 的选择属于类型擦除问题:调用点能保留具体类型时,函数对象保持类型信息;接口必须在运行时保存不同可调用类型时,才进入 std::function 这类包装器的讨论范围。
函数对象的最终检查顺序可以固定为五步。第一步,确认调用签名:算法或容器会用哪些实参调用它。第二步,确认对象状态:成员是否值语义稳定,引用是否有足够生命周期。第三步,确认语义前提:比较器要满足严格弱序,谓词要给出稳定布尔判断,变换函数要返回可写入输出位置的类型。第四步,确认复制移动:算法保存或复制函数对象后语义是否保持。第五步,确认版本边界:std::less<> 这类透明 void 特化来自 C++14,ranges 受约束比较器来自 C++20,无序容器的异构查找还依赖哈希和相等谓词的透明支持。
最小自检任务
阅读下面代码,判断 BadCompare 和 ByLimit 分别适合放入哪个 STL 调用位置,并说明需要怎样修改才能形成稳定的工程写法。
#include <algorithm>
#include <vector>
struct Item {
int id;
int score;
};
struct BadCompare {
int pivot;
bool operator()(const Item& lhs, const Item& rhs) const {
return (lhs.score % pivot) < (rhs.score % pivot);
}
};
struct ByLimit {
int limit;
bool operator()(const Item& item) const {
return item.score >= limit;
}
};
void run(std::vector<Item>& items) {
std::sort(items.begin(), items.end(), BadCompare{10});
auto n = std::count_if(items.begin(), items.end(), ByLimit{80});
(void)n;
}
答案要点
ByLimit 适合做筛选谓词。它按值保存阈值,operator() 只读元素并返回稳定布尔结果;算法复制它以后,每个副本仍携带同一个 limit 值。它用于 std::count_if 时,调用签名是 bool(const Item&),符合本章的状态仿函数模型。
BadCompare 的风险在排序语义。score % pivot 只按余数建立等价类,多个不同分数会被视为等价;这本身可以成立,但业务若需要按真实分数排序,就会得到与预期不一致的顺序。更大的检查点是 pivot:当 pivot 为 0 时,比较表达式产生未定义行为;当外部需求要求完整顺序时,还应加入稳定的 tie-breaker,例如余数相同后再比较 score 和 id。
稳定修改可以写成 ByScoreModuloThenScore,在构造时保证 pivot > 0,并在比较中依次比较余数、原始分数和 id。若业务目标只是筛选分数阈值,使用 ByLimit 进入 std::count_if 或 std::copy_if;若目标是全局排序,比较器必须先说明它诱导出的顺序关系和等价类。
本章知识点总结
- 函数对象:函数对象是能出现在函数调用表达式位置的对象,STL 用它把行为当作类型和对象传递。
- 调用模型:
operator()让对象具备调用语法,同时保留成员状态、生命周期和复制移动语义。 - 模板路径:算法模板看到具体函数对象类型后,拥有内联和常量传播的优化条件。
- 对象状态:函数对象的成员会随对象复制进入算法内部,值成员通常比引用成员更容易保持稳定语义。
- 标准比较器:
std::less、std::greater、std::equal_to等对象把语言运算符包装成标准库可接收的调用对象。 - 严格弱序:排序算法和有序容器的比较器必须建立稳定顺序关系,否则全局有序结果缺少前提。
- 透明比较器:带
is_transparent的比较器能支持异构查找,所有异构重载应共享同一排序关系。 - 状态仿函数:状态仿函数适合保存阈值、排序方向和字段投影这类只读策略参数。
- 算法责任:算法负责区间遍历、复杂度和写入路径,函数对象负责单次比较、筛选或变换决策。
- 错误定位:模板报错应沿着调用签名、实参类型、返回值写入位置回到用户提供的函数对象。
- 工程选择:一次性局部规则适合 lambda,具名复用规则适合函数对象,运行时隐藏类型才进入 callable wrapper。
- 判断顺序:先查调用签名,再查状态生命周期,再查语义前提,再查复制移动,最后查标准版本边界。