ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

一文详解C++仿函数

一文详解C++仿函数 前言「仿函数」这个词在中文技术圈里几乎人人会用但定义经常被说糊。常见的说法是「能像函数一样调用的东西」——按这个标准函数指针、std::function、lambda、成员函数指针全都算仿函数概念就被稀释到没有信息量了。更准确的定义是仿函数functor就是函数对象function object指重载了函数调用运算符operator()的类类型的实例。它是一个对象所以可以有成员变量、可以有构造和析构、可以有不同的类型它又能用f(args)的形式调用所以在语法上和函数无缝互换。这两点合起来才是它在标准库里被大量使用的真正原因。为什么标准库不直接用函数指针两个原因函数指针不能携带状态想带上下文只能塞一个void*既丢类型也丢安全而且通过函数指针调用难以被内联编译器通常只能发出一句间接跳转。函数对象的调用目标在编译期就是确定的operator()是普通成员函数内联和优化都好做。本文以 C17 为基准示例在 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::vectorint v{1, 2, 3, 4}; std::vectorint 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::accumulatefunctional还提供了一组现成的仿函数从 C14 起都有了透明版本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::vectorint 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::greaterint{}); // 降序 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; }输出分别是total29 calls6、even3然后是9 8 5 4 2 1和1 2 4 5 8 9。几个关键点std::for_each返回的是传入仿函数的副本。标准明确规定它返回被移动的那个谓词对象所以有状态仿函数的结果要从返回值里取而不是从你传进去的那个对象里取。这一点在下面「常见坑点」里还会展开。std::less是透明比较器。std::lessint的operator()签名是bool operator()(const int, const int) const只吃int而std::lessvoid的operator()是模板能比较任意可比较的两种类型用在std::sort上效果一样真正的价值是在std::mapstd::string, int, std::less里可以拿const char*直接查找而不必先构造std::string。仿函数可以就地构造。上面三处都传的是临时对象IsEven{}、std::greaterint{}没有预先命名的变量。这是仿函数相对函数指针的另一个便利它就是个普通对象随用随建。三、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::functionint(int) f lam; // 类型擦除存入统一类型 std::cout f(7) \n; // 17 std::functionint(int) g [](int v) { return v * 2; }; // 另一种可调用对象也能装 std::cout g(7) \n; // 14 }四种「可调用对象」的对比特性函数指针函数对象lambdastd::function能否携带状态不能能能捕获列表能调用是否可内联通常不能可以可以通常不能类型数量每个签名一个每个类一个每个表达式一个每种签名一个能否运行期替换能换指针不能不能能主要开销间接调用无无类型擦除间接调用可能堆分配有两个细节特别容易踩其一每个 lambda 表达式有唯一类型。即使两个 lambda 的签名和函数体一模一样它们的类型也不同因此不能互相赋值也不能一起塞进同一个std::vector。要统一存放就只能走std::function。其二std::function的推导指引在 C17 只覆盖函数指针。std::functionint(int)的模板参数不能省——C17 的推导指引只支持从函数指针和成员指针推导从 lambda 推导是 C23 才加入的。所以写全签名是必须的。四、有状态仿函数的复制语义仿函数可以带状态但状态和对象的复制绑在一起这是它最容易出人意料的性质。#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::vectorint 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; // 输出matched3 caller calls0 —— 调用方这个对象的计数是 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; // 输出matched3 caller calls6 }c.calls是 0因为算法按值接收谓词累加发生在算法内部的副本上如果算法内部再复制几次那就更数不清了。要拿回状态有两条路用std::ref传引用包装std::reference_wrapper提供了转发operator()的能力所以它本身也是合法谓词或者干脆改用std::for_each并接住它的返回值。顺带说一句mutableoperator()是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::functionint(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::vectordecltype(a) v{a, b}; // 编译错误b 的类型不是 decltype(a) // ✅ 需要统一类型就用 std::function std::vectorstd::functionvoid(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); };这批老工具在 C11 起就被弃用并在后续标准里陆续移除具体到哪个版本各家标准草案的整理节奏不同但结论一致新代码不要用它们是用来适配std::unary_function那套已废弃基类的。8. 捕获 this 的 lambda 被当成回调长期保存// ❌ 对象销毁后回调仍持有悬垂的 this class Session { public: void start() { callback_ [this] { handle(); }; } // 保存了 this std::functionvoid() callback_; }; // ✅ 明确生命周期或者用 shared_from_this / weak_ptr 保护总结概念本质关键约束仿函数函数对象重载了operator()的类类型实例只读谓词的operator()应加const函数指针只带代码地址不带状态难以内联上下文要靠void*传lambda编译器生成的匿名闭包类型每个表达式类型唯一C17 下不可默认构造无捕获 lambdastd::function类型擦除的可调用对象容器间接调用可能堆分配不适合热路径状态与复制算法按值接收仿函数状态要回传必须用std::ref或接返回值仿函数的全部价值可以归成一句话它是一个对象所以能带状态、能有自己的类型它又可以被调用所以能和算法无缝对接。lambda 让它写起来像函数std::function让它能被统一存储但底层机制始终是那个重载了operator()的类。写自己的仿函数时记住三条operator()加const、比较器必须是严格弱序、有状态就别指望算法会把状态还给你。
返回列表