24、函数与函数对象:仿函数(Functor)的设计,operator()的重载,与函数指针的对比
说到函数,我们脑子里蹦出来的就是一段可执行的代码,给它参数,它返回结果。但C++里有一种东西,长得不像函数,用起来却跟函数一模一样——这就是仿函数,也叫函数对象。
我第一次接触仿函数是在做嵌入式通信协议栈的时候。当时需要把不同数据帧的解析函数注册到一个调度表里,用函数指针搞了半天,发现状态维护特别麻烦。后来同事说:「你干嘛不试试重载operator()?」嗯,从那以后,我对仿函数的理解就彻底不一样了。
什么是仿函数?
说白了,仿函数就是一个类,这个类重载了圆括号运算符 operator()。实例化之后,这个对象就能像函数一样被调用。
// 一个最简单的仿函数
class Adder {
public:
int operator()(int a, int b) {
return a + b;
}
};
// 使用
Adder add;
int result = add(3, 4); // 像函数一样调用
你看,add(3, 4) 这个写法,跟普通函数调用完全一样。但 add 其实是个对象,不是函数。
operator() 的重载规则
重载 operator() 有几个要点,我踩过坑,给你列出来:
- 参数个数不限:可以0个、1个、多个,跟普通函数一样
- 可以重载多个版本:参数类型不同,或者个数不同,都能重载
- 可以返回任意类型:void、int、甚至自定义类型都行
- 可以是模板:让仿函数更通用
// 多个重载版本的仿函数
class Printer {
public:
void operator()(int x) {
printf("整数: %d\n", x);
}
void operator()(const char* s) {
printf("字符串: %s\n", s);
}
template<typename T>
void operator()(T a, T b) {
printf("模板版本: %f, %f\n", (double)a, (double)b);
}
};
小提示:我个人习惯把仿函数的 operator() 声明为 const 成员函数,除非你需要修改内部状态。这样能保证 const 对象也能被调用。
仿函数 vs 函数指针:核心差异
很多初学者会问:「有函数指针了,为什么还要仿函数?」这个问题问得好。我当年也这么想过。
咱们直接对比一下:
| 对比维度 | 函数指针 | 仿函数 |
|---|---|---|
| 本质 | 指向代码的地址 | 重载了 operator() 的对象 |
| 状态维护 | 不能(除非用全局变量) | 可以(成员变量保存状态) |
| 内联优化 | 编译器通常无法内联 | 可以完美内联 |
| 模板支持 | 有限 | 天然支持 |
| 运行时开销 | 间接调用(跳转) | 直接调用(可内联) |
你看,函数指针说白了就是个地址,它本身不携带任何数据。而仿函数是个对象,可以有自己的成员变量。这个区别在嵌入式开发里特别重要。
带状态的仿函数
我在做传感器数据采集时遇到过这样一个场景:需要统计一段时间内的采样值,计算平均值。如果用函数指针,你得搞个全局变量来累加,多线程下还得加锁,麻烦得很。
用仿函数就优雅多了:
class AverageCalculator {
private:
double sum;
int count;
public:
AverageCalculator() : sum(0.0), count(0) {}
void operator()(double value) {
sum += value;
count++;
}
double get_average() const {
return (count == 0) ? 0.0 : sum / count;
}
};
// 使用
AverageCalculator calc;
double samples[] = {1.2, 3.4, 5.6, 2.1};
for (auto v : samples) {
calc(v); // 每次调用都在内部累加
}
printf("平均值: %f\n", calc.get_average());
重点:仿函数可以保存调用历史、累计结果、配置参数等状态信息。这是函数指针做不到的。
性能对比:为什么仿函数更快?
你想想看,函数指针调用时,编译器不知道你具体指向哪个函数,只能通过地址间接跳转。这个间接跳转,在嵌入式MCU上可能带来几十个时钟周期的开销。
而仿函数呢?编译器看到 calc(v) 时,它知道 calc 是 AverageCalculator 类型,operator() 的代码是确定的。于是它直接把函数体内联到调用点,零开销。
我曾经在一个Cortex-M4的项目里做过测试:用函数指针做回调,每秒钟10万次调用,CPU占用率比仿函数高了大约15%。对于电池供电的设备来说,这个差距很要命。
仿函数在标准库中的应用
C++标准库大量使用仿函数。比如 std::sort 的第三个参数:
// 函数指针版本
bool compare(int a, int b) { return a > b; }
std::sort(vec.begin(), vec.end(), compare);
// 仿函数版本
struct Descending {
bool operator()(int a, int b) const { return a > b; }
};
std::sort(vec.begin(), vec.end(), Descending());
还有 std::for_each、std::transform 等算法,都支持仿函数。我个人更推荐用仿函数,因为内联优化带来的性能提升是实打实的。
SVG:仿函数与函数指针对比图
避坑指南
我曾经踩过的坑:
- 仿函数对象拷贝开销:如果仿函数内部有大量数据,传值时会有拷贝。建议用引用或智能指针传递。
- lambda表达式本质也是仿函数:C++11之后的lambda,编译器会生成一个匿名的仿函数类。所以lambda和仿函数在底层是一回事。
- 不要滥用仿函数:如果只是简单的比较操作,函数指针更简洁。仿函数适合需要状态或复杂逻辑的场景。
总结
仿函数是C++里一个非常实用的工具。它把「函数」和「对象」两个概念融合在一起,既有函数的行为,又有对象的灵活性。
我个人在嵌入式项目中的经验是:
- 回调函数用仿函数,性能更好
- 需要维护状态的场景,仿函数是首选
- 配合标准库算法,仿函数能让代码更简洁
函数指针也不是一无是处。C语言环境下、或者对代码体积有极端要求的场景,函数指针依然有它的价值。但如果你在用C++,仿函数绝对是更现代、更高效的选择。
公众号:蓝海资料掘金营,微信deep3321