前言
「仿函数」这个词在中文技术圈里几乎人人会用,但定义经常被说糊。常见的说法是「能像函数一样调用的东西」——按这个标准,函数指针、std::function、lambda、成员函数指针全都算仿函数,概念就被稀释到没有信息量了。
更准确的定义是:仿函数(functor)就是函数对象(function object),指重载了函数调用运算符operator()的类类型的实例。它是一个对象,所以可以有成员变量、可以有构造和析构、可以有不同的类型;它又能用f(args)的形式调用,所以在语法上和函数无缝互换。这两点合起来,才是它在标准库里被大量使用的真正原因。
为什么标准库不直接用函数指针?两个原因:函数指针不能携带状态(想带上下文只能塞一个void*,既丢类型也丢安全),而且通过函数指针调用难以被内联,编译器通常只能发出一句间接跳转。函数对象的调用目标在编译期就是确定的,operator()是普通成员函数,内联和优化都好做。
本文以 C++17 为基准,示例在 GCC 13、Clang 17、MSVC 19.3x 上应能编译。文中涉及标准库的部分以标准规定为准,涉及实现取舍(比如std::function什么时候会做堆分配)会明确说明属于实现细节。
一、函数指针的局限与函数对象的引入
先看一个朴素的需求:把数组里所有元素加一个「基数」。用函数指针会立刻卡住——函数没法记住这个基数,除非把上下文也用额外参数传进来。
#include <algorithm> #include <iostream> #include <vector> // 函数指针版本:base 必须由调用方额外传,用法别扭 int add_base(int value, int base) { return value + base; } // 函数对象版本:状态存在对象里,调用签名干净 struct Add { int base = 0; int operator()(int value) const { return value + base; } }; int main() { std::vector<int> v{1, 2, 3, 4}; std::vector<int> out(v.size()); Add add10{10}; std::transform(v.begin(), v.end(), out.begin(), add10); for (int x : out) { std::cout << x << ' '; } // 11 12 13 14 std::cout << '\n'; add10.base = 100; // 状态可以改,也可以就地复用 std::transform(v.begin(), v.end(), out.begin(), add10); for (int x : out) { std::cout << x << ' '; } // 101 102 103 104 std::cout << '\n'; }Add就是一个最小的仿函数:一个成员变量装状态,一个operator()提供调用接口。std::transform的第四个参数可以是任何可调用对象,语法上完全一致。
这里有一个必须注意的写法细节:int operator()(int value) const。const不是装饰,而是必需的。标准库容器(std::map、std::set)会把比较器存进对象里,并且在自身的const成员函数中访问它,此时比较器是const的,operator()不带const就调用不了,直接编译错误。算法里虽然大多是按值传递、调用的是非 const 副本,但为了通用性,只读谓词的operator()一律加const。
二、与标准库的配合:谓词、比较器与内置仿函数
标准库里的「函数对象」按用途分三类:
| 角色 | 英文名 | 签名要求 | 典型算法 |
|---|---|---|---|
| 一元谓词 | unary predicate | bool(T) const | std::find_if、std::count_if、std::remove_if |
| 二元谓词 | binary predicate | bool(T, T) const | std::sort、std::unique |
| 操作 | operation | 任意 | std::transform、std::for_each、std::accumulate |
<functional>还提供了一组现成的仿函数,从 C++14 起都有了透明版本(transparent,即std::less<>这种不带具体类型的特化):
#include <algorithm> #include <functional> #include <iostream> #include <vector> struct Sum { long total = 0; int calls = 0; void operator()(int value) { total += value; ++calls; } }; struct IsEven { bool operator()(int v) const { return v % 2 == 0; } }; int main() { std::vector<int> v{5, 2, 8, 1, 9, 4}; // for_each 按值接收,返回的是内部副本 —— 必须接住返回值 Sum s = std::for_each(v.begin(), v.end(), Sum{}); std::cout << "total=" << s.total << " calls=" << s.calls << '\n'; const auto even = std::count_if(v.begin(), v.end(), IsEven{}); std::cout << "even=" << even << '\n'; std::sort(v.begin(), v.end(), std::greater<int>{}); // 降序 for (int x : v) { std::cout << x << ' '; } std::cout << '\n'; std::sort(v.begin(), v.end(), std::less<>{}); // 升序,透明版本 for (int x : v) { std::cout << x << ' '; } std::cout << '\n'; }输出分别是total=29 calls=6、even=3,然后是9 8 5 4 2 1和1 2 4 5 8 9。
几个关键点:
std::for_each返回的是传入仿函数的副本。标准明确规定它返回被移动的那个谓词对象,所以有状态仿函数的结果要从返回值里取,而不是从你传进去的那个对象里取。这一点在下面「常见坑点」里还会展开。
std::less<>是透明比较器。std::less<int>的operator()签名是bool operator()(const int&, const int&) const,只吃int;而std::less<void>的operator()是模板,能比较任意可比较的两种类型,用在std::sort上效果一样,真正的价值是在std::map<std::string, int, std::less<>>里可以拿const char*直接查找而不必先构造std::string。
仿函数可以就地构造。上面三处都传的是临时对象IsEven{}、std::greater<int>{},没有预先命名的变量。这是仿函数相对函数指针的另一个便利:它就是个普通对象,随用随建。
三、lambda、std::function 与仿函数的关系
lambda 表达式(lambda expression)并不是独立于仿函数的东西。标准规定,lambda 表达式会构造出一个唯一的、匿名的类类型(闭包类型,closure type),捕获的变量成为它的成员,operator()是这个类的成员函数。换句话说,lambda 是编译器替你写好的仿函数。
#include <functional> #include <iostream> int main() { int base = 10; auto lam = [base](int v) { return v + base; }; // 闭包类型,唯一 std::cout << lam(5) << '\n'; // 15 std::function<int(int)> f = lam; // 类型擦除:存入统一类型 std::cout << f(7) << '\n'; // 17 std::function<int(int)> g = [](int v) { return v * 2; }; // 另一种可调用对象也能装 std::cout << g(7) << '\n'; // 14 }四种「可调用对象」的对比:
| 特性 | 函数指针 | 函数对象 | lambda | std::function |
|---|---|---|---|---|
| 能否携带状态 | 不能 | 能 | 能(捕获列表) | 能 |
| 调用是否可内联 | 通常不能 | 可以 | 可以 | 通常不能 |
| 类型数量 | 每个签名一个 | 每个类一个 | 每个表达式一个 | 每种签名一个 |
| 能否运行期替换 | 能(换指针) | 不能 | 不能 | 能 |
| 主要开销 | 间接调用 | 无 | 无 | 类型擦除(间接调用,可能堆分配) |
有两个细节特别容易踩:
其一,每个 lambda 表达式有唯一类型。即使两个 lambda 的签名和函数体一模一样,它们的类型也不同,因此不能互相赋值,也不能一起塞进同一个std::vector。要统一存放就只能走std::function。
其二,std::function的推导指引在 C++17 只覆盖函数指针。std::function<int(int)>的模板参数不能省——C++17 的推导指引只支持从函数指针和成员指针推导,从 lambda 推导是 C++23 才加入的。所以写全签名是必须的。
四、有状态仿函数的复制语义
仿函数可以带状态,但状态和对象的复制绑在一起,这是它最容易出人意料的性质。
#include <algorithm> #include <functional> #include <iostream> #include <vector> struct Counting { mutable int calls = 0; // mutable 允许在 const 成员函数里修改 bool operator()(int v) const { ++calls; return v % 2 == 0; } }; int main() { std::vector<int> v{1, 2, 3, 4, 5, 6}; Counting c; const auto n = std::count_if(v.begin(), v.end(), c); // 传进去的是副本 std::cout << "matched=" << n << " caller calls=" << c.calls << '\n'; // 输出:matched=3 caller calls=0 —— 调用方这个对象的计数是 0 Counting c2; const auto n2 = std::count_if(v.begin(), v.end(), std::ref(c2)); // 传引用包装 std::cout << "matched=" << n2 << " caller calls=" << c2.calls << '\n'; // 输出:matched=3 caller calls=6 }c.calls是 0,因为算法按值接收谓词,累加发生在算法内部的副本上;如果算法内部再复制几次,那就更数不清了。要拿回状态有两条路:用std::ref传引用包装(std::reference_wrapper提供了转发operator()的能力,所以它本身也是合法谓词),或者干脆改用std::for_each并接住它的返回值。
顺带说一句mutable:operator()是const的,普通成员在函数体里不可修改,所以计数成员必须声明为mutable。这本身没问题,但要注意mutable会绕开const保护,用多了会让类型的行为变得难以推理。
常见坑点
1. 谓词的 operator() 忘了 const
// ❌ 放入 std::set / std::map 时编译失败(比较器在 const 上下文被调用) struct Cmp { bool operator()(int a, int b) { return a < b; } }; // ✅ 只读谓词一律加 const struct Cmp { bool operator()(int a, int b) const { return a < b; } };2. 有状态仿函数的状态没传回来
// ❌ 算法按值拷贝谓词,counter 的修改留在内部副本里 Counter counter; std::count_if(v.begin(), v.end(), counter); std::cout << counter.count; // 仍然是 0 // ✅ 用 std::ref,或者接住 for_each 的返回值 std::count_if(v.begin(), v.end(), std::ref(counter));3. 比较器写成非严格弱序
// ❌ 用 <= 违反不可自反性,std::sort 的前提被破坏,这是 UB std::sort(v.begin(), v.end(), [](int a, int b) { return a <= b; }); // ✅ 必须用 < std::sort(v.begin(), v.end(), [](int a, int b) { return a < b; });标准要求排序算法的比较器构成严格弱序(strict weak ordering)。用<=会让等价元素互相「小于」,算法内部的划分逻辑会走进未定义行为——不是「顺序不稳定」这么轻,标准不保证任何行为。
4. 在谓词里改元素
// ❌ 谓词修改了元素,破坏了算法对区间的假设 std::remove_if(v.begin(), v.end(), [](int& x) { x *= 2; return x > 10; }); // ✅ 谓词只做判断,修改交给 transform std::transform(v.begin(), v.end(), v.begin(), [](int x) { return x * 2; }); std::remove_if(v.begin(), v.end(), [](int x) { return x > 10; });5. 把 std::function 用在热点路径上
// ❌ 每轮循环都通过类型擦除间接调用,内联不了 void run_loop(const std::function<int(int)>& f); // ✅ 泛型接口可直接内联 template <class F> void run_loop(F f);std::function会做类型擦除(type erasure),调用变成间接调用;捕获体积超过实现内部的小对象缓冲区时还会发生堆分配。这个缓冲区大小是实现定义的(libstdc++、libc++、MSVC STL 各不相同),不要假定它有多大。
6. 想把不同的 lambda 放进同一个容器
// ❌ 每个 lambda 表达式都有唯一类型,第一个元素定下的类型装不下第二个 auto a = [](int) { }; auto b = [](int) { }; // std::vector<decltype(a)> v{a, b}; // 编译错误:b 的类型不是 decltype(a) // ✅ 需要统一类型就用 std::function std::vector<std::function<void(int)>> handlers; handlers.push_back([](int) { }); handlers.push_back([](int) { });7. 用已被移除的旧绑定工具
// ❌ std::bind1st / std::bind2nd / std::ptr_fun / std::mem_fun 已被移除, // std::not1 / std::not2 也相继被弃用并移除 std::not1(std::ptr_fun(is_even)); // ✅ 用 lambda 或 std::bind 加占位符 auto not_even = [](int v) { return !is_even(v); };这批老工具在 C++11 起就被弃用,并在后续标准里陆续移除,具体到哪个版本各家标准草案的整理节奏不同,但结论一致:新代码不要用,它们是用来适配std::unary_function那套已废弃基类的。
8. 捕获 this 的 lambda 被当成回调长期保存
// ❌ 对象销毁后回调仍持有悬垂的 this class Session { public: void start() { callback_ = [this] { handle(); }; } // 保存了 this std::function<void()> callback_; }; // ✅ 明确生命周期,或者用 shared_from_this / weak_ptr 保护总结
| 概念 | 本质 | 关键约束 |
|---|---|---|
| 仿函数(函数对象) | 重载了operator()的类类型实例 | 只读谓词的operator()应加const |
| 函数指针 | 只带代码地址,不带状态 | 难以内联;上下文要靠void*传 |
| lambda | 编译器生成的匿名闭包类型 | 每个表达式类型唯一;C++17 下不可默认构造无捕获 lambda |
std::function | 类型擦除的可调用对象容器 | 间接调用,可能堆分配,不适合热路径 |
| 状态与复制 | 算法按值接收仿函数 | 状态要回传必须用std::ref或接返回值 |
仿函数的全部价值可以归成一句话:它是一个对象,所以能带状态、能有自己的类型;它又可以被调用,所以能和算法无缝对接。lambda 让它写起来像函数,std::function让它能被统一存储,但底层机制始终是那个重载了operator()的类。写自己的仿函数时,记住三条:operator()加const、比较器必须是严格弱序、有状态就别指望算法会把状态还给你。