☰
一文详解C++仿函数
2026/10/8 9:03:41 网站建设 项目流程

前言

「仿函数」这个词在中文技术圈里几乎人人会用,但定义经常被说糊。常见的说法是「能像函数一样调用的东西」——按这个标准,函数指针、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 predicatebool(T) conststd::find_if、std::count_if、std::remove_if
二元谓词binary predicatebool(T, T) conststd::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 }

四种「可调用对象」的对比:

特性函数指针函数对象lambdastd::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、比较器必须是严格弱序、有状态就别指望算法会把状态还给你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询