CC++ & Algorithm

函数对象(仿函数)的概念与使用

较难5
语言版本:通用
概述:函数对象(又称仿函数)是一个看起来像函数、实际上是个对象的特殊“小工具”,它能让函数像带记忆的小助手一样工作,在STL算法中用处很大。

函数对象(仿函数):带记忆的小工具,让代码更聪明

你有没有遇到过这样的情况:老师让你统计全班同学的三次考试成绩,然后计算每个人的平均分。如果每次都用计算器按一遍,很麻烦,而且容易算错。如果能有一个“智能计算器”,每次输入一个成绩它就自动累加,还能记住一共输入了几次,最后直接告诉你平均分,那该多方便!

在编程里,我们经常需要把“一个动作”传给某个函数,让它在处理数据时执行这个动作。比如,C++ 的 for_each 可以遍历一个数组,对每个元素做点什么。最简单的“动作”就是普通函数,比如打印数字。但普通函数像一张一次性便签纸——写完了就扔,它不记得自己上次干了什么,也没有自己的“小本本”来记录数据(比如累计了多少次、总和是多少)。这时候,就需要一种既能像函数一样被调用,又能像对象一样拥有自己的数据(状态)的东西——这就是函数对象(Function Object),也叫仿函数(Functor)。

打个比方:普通函数就像一次性便签纸,用完就丢;而函数对象就像一个小本子,每次你用它,它都能翻开自己的记忆,记下新东西。你问它“你一共被用了多少次?”,它马上就能告诉你。

函数对象是怎么工作的?

函数对象其实就是一个(class),这个类里重载了小括号运算符 operator()。一旦一个类重载了 (),这个类的对象就可以像一个函数一样被“调用”了。看个最简单的例子:

#include <iostream>
using namespace std;

class MyFunctor {                     // 定义一个“仿函数”类
public:
    // 重载 operator(),让它能像函数一样被调用
    void operator()(int x) {
        cout << "调用了函数对象,参数是:" << x << endl;
    }
};

int main() {
    MyFunctor obj;                     // 创建一个对象
    obj(42);                           // 像函数一样调用它!
    // 输出:调用了函数对象,参数是:42
    return 0;
}

是不是很神奇?obj 明明是一个对象,却可以像函数 obj(42) 这样用,全靠 operator() 这个“魔法”。

为什么需要函数对象?它比普通函数好在哪?

你可能会问:“普通函数不是也能做同样的事吗?为什么非要搞一个对象出来?”

普通函数确实能实现“对于给定参数做某个操作”,但它有两个大缺点:

  1. 普通函数没有自己的“记忆”——它不能保存调用次数、累计总和等数据。如果想统计一个数组里所有数字的和,普通函数只能借助全局变量。但全局变量容易被其他代码意外修改,就像班里每个人都有权修改老师的点名册,容易乱。
  2. 普通函数不容易“定制”——比如你想让一个比较函数根据不同的规则排序(升序、降序或者按绝对值),普通函数只能写死,每次都要改代码。而函数对象可以在创建时设置参数,灵活切换。

函数对象的优势正好解决了这些问题:

  • 可以拥有状态:给它添加成员变量,就像在小本子上加了一页,可以记录各种数据(调用次数、累加和、阈值等)。
  • 可能更高效:编译器容易把 operator() 的调用“内联”到代码里(就像把一段话直接写进作文里,不用再翻到另一页引用),而函数指针的调用往往没那么容易内联。
  • 灵活性高:可以在构造对象时传入参数(比如比较规则),同一个类可以产生行为不同的对象。

一个经典例子:累加器(带记忆的加法器)

假设我们要统计一个数组中所有元素的总和。使用普通函数,你可能会写一个全局变量 sum,但这样不安全。使用函数对象,我们可以把状态“锁”在对象内部。

先看一个错误写法,很多新手会这样写:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

class Accumulator {
private:
    int sum;                          // 用来保存累加结果
public:
    Accumulator() : sum(0) {}         // 初始化为0
    
    void operator()(int value) {
        sum += value;                 // 每次被调用,把 value 加到 sum 上
    }
    
    int getSum() const { return sum; }
};

int main() {
    vector<int> nums = {1, 2, 3, 4, 5};
    Accumulator acc;                  // 创建累加器对象,sum=0
    
    for_each(nums.begin(), nums.end(), acc);  // 传入 acc
    
    cout << "总和是:" << acc.getSum() << endl;  // 输出什么?是0!错了!
    return 0;
}

结果却输出了 0!为什么?因为 for_each 在内部会把传入的 acc 拷贝一份,然后用拷贝后的对象去处理元素。处理完成后,原来那个 accsum 还是0。这是新手最容易犯的错误:for_each 不会修改你传入的对象本身,而是对它的拷贝进行操作

正确的做法是:for_each 会返回处理后的那个拷贝对象,所以我们应该捕获返回值。

Accumulator result = for_each(nums.begin(), nums.end(), Accumulator());
cout << "总和是:" << result.getSum() << endl;  // 输出 15

这样写,for_each 内部会创建一个匿名的 Accumulator 对象(初始 sum=0),然后用它遍历所有元素,遍历结束后返回这个对象。我们用 result 接收它,再调用 getSum() 就能得到正确结果。

函数对象还能用在哪些地方?

除了累加,函数对象还有很多用途。下面列举几个最常见的场景:

1. 用作“谓词”(Predicate)——判断真假

谓词就是返回 bool 值的函数对象。比如,找出数组中第一个大于2的元素:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

struct GreaterThanTwo {               // 判断是否大于2
    bool operator()(int x) const {
        return x > 2;
    }
};

int main() {
    vector<int> v = {1, 2, 3, 4, 5};
    auto it = find_if(v.begin(), v.end(), GreaterThanTwo());  // 查找第一个大于2的元素
    if (it != v.end()) {
        cout << "第一个大于2的元素是:" << *it << endl;       // 输出 3
    }
    return 0;
}

find_if 会遍历数组,对每个元素调用 GreaterThanTwo()(元素),直到返回 true 为止。

2. 用作“比较器”——自定义排序规则

sort 默认升序排列。如果我想降序,或者按某种特殊规则排序,可以传一个函数对象:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

struct Descending {                   // 降序比较器
    bool operator()(int a, int b) const {
        return a > b;                 // 如果 a > b,说明 a 应该排在 b 前面
    }
};

int main() {
    vector<int> nums = {5, 3, 8, 1, 9};
    sort(nums.begin(), nums.end(), Descending());
    for (int x : nums) {
        cout << x << " ";             // 输出 9 8 5 3 1
    }
    return 0;
}

3. 用作“二元操作”——两个参数,输出一个结果

比如 transform 把两个序列对应元素做运算:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

struct Adder {                        // 把两个数相加
    int operator()(int a, int b) const {
        return a + b;
    }
};

int main() {
    vector<int> a = {1, 2, 3};
    vector<int> b = {10, 20, 30};
    vector<int> result(a.size());
    
    transform(a.begin(), a.end(), b.begin(), result.begin(), Adder());
    // result 变成 {11, 22, 33}
    for (int x : result) {
        cout << x << " ";
    }
    return 0;
}

完整示例:多个函数对象实战

下面是一个完整的例子,包含了无状态的打印、有状态的平均值计算、以及自定义排序。代码中有详细注释,方便你理解。

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

// 1. 无状态的函数对象:仅仅打印元素,不需要记住任何东西
struct Printer {
    void operator()(int x) const {    // const 表示这个操作不会修改对象内部状态
        cout << x << " ";
    }
};

// 2. 有状态的函数对象:计算平均值,需要记住 sum 和 count
class AverageCalculator {
private:
    double sum;                       // 所有值的总和
    int count;                        // 一共处理了多少个元素
public:
    AverageCalculator() : sum(0), count(0) {}
    
    void operator()(int value) {
        sum += value;                 // 累加
        ++count;                      // 计数
    }
    
    double getAverage() const {
        return (count == 0) ? 0.0 : sum / count;
    }
};

// 3. 用作二元谓词的函数对象:降序比较
struct DescendingCompare {
    bool operator()(int a, int b) const {
        return a > b;                 // 如果 a > b,则 a 排在 b 前面(降序)
    }
};

int main() {
    vector<int> nums = {5, 3, 8, 1, 9};
    
    // 测试无状态的 Printer
    cout << "原始数组:";
    for_each(nums.begin(), nums.end(), Printer());
    cout << endl;
    
    // 测试有状态的 AverageCalculator
    AverageCalculator result = for_each(nums.begin(), nums.end(), AverageCalculator());
    cout << "平均值:" << result.getAverage() << endl;
    
    // 测试降序排序
    sort(nums.begin(), nums.end(), DescendingCompare());
    cout << "降序排列后:";
    for_each(nums.begin(), nums.end(), Printer());
    cout << endl;
    
    return 0;
}

输出结果

原始数组:5 3 8 1 9 
平均值:5.2
降序排列后:9 8 5 3 1 

Python 中的仿函数(__call__ 方法)

Python 没有 C++ 那样的 operator() 重载,但它有一个更直观的方式:__call__ 方法。只要一个类定义了 __call__,它的实例就可以像函数一样被调用。

下面用 Python 实现同样的功能:

from typing import List

# 1. 无状态的打印类
class Printer:
    def __call__(self, x):          # 让对象可调用
        print(x, end=' ')

# 2. 有状态的平均值计算器
class AverageCalculator:
    def __init__(self):
        self.sum = 0.0              # 总和
        self.count = 0              # 个数
    
    def __call__(self, value):
        self.sum += value           # 累加
        self.count += 1             # 计数
    
    def get_average(self):
        return self.sum / self.count if self.count != 0 else 0.0

# 3. 降序比较函数对象(Python 中通常用 lambda,这里演示类)
# 注意:Python 的 sort 不接受二元比较谓词,需要用 cmp_to_key 转换
from functools import cmp_to_key

class DescendingCompare:
    def __call__(self, a, b):
        # 返回负数表示 a<b,正数表示 a>b,0 表示相等
        return -1 if a > b else 1 if a < b else 0

def main():
    nums = [5, 3, 8, 1, 9]
    
    # 无状态打印
    print("原始数组:", end='')
    for x in nums:
        Printer()(x)                 # 也可以先创建对象再调用
    print()
    
    # 有状态平均值
    avg_calc = AverageCalculator()
    for x in nums:
        avg_calc(x)                  # 手动遍历每个元素
    print("平均值:", avg_calc.get_average())
    
    # 降序排序(使用 cmp_to_key)
    sorted_nums = sorted(nums, key=cmp_to_key(DescendingCompare()))
    print("降序排列后:", end='')
    for x in sorted_nums:
        print(x, end=' ')
    print()
    
    # 更简单的做法:直接设置 reverse=True
    nums.sort(reverse=True)
    print("降序排列后(简单方法):", end='')
    for x in nums:
        print(x, end=' ')
    print()

if __name__ == "__main__":
    main()

在 Python 中,更常见的是直接用 lambda 表达式或者普通函数来做这些事,但 __call__ 在需要保存状态时非常有用(比如装饰器、计数器等)。

新手容易犯的错误

  1. 忘记 for_each 返回的是拷贝
    就像前面累加器的例子,直接用传入的对象获取结果会得到原始状态(0),必须捕获返回值。
    正确做法auto result = for_each(... , MyFunctor());

  2. 函数对象体积过大导致拷贝性能下降
    STL 算法可能会多次拷贝函数对象(比如 sort 内部会拷贝比较器多次)。如果你的函数对象有很多成员变量(比如一个大数组),拷贝开销会很大。
    解决方法:使用 std::ref 包装引用,或者把状态设计得轻量级(比如只保存指针)。

  3. 忘记 const 正确性
    谓词(返回 bool 的函数对象)通常不应该修改自身状态,所以 operator() 应该标记为 const。如果写成了非 const,在某些算法中可能编译错误(因为算法有时用 const 对象调用)。

  4. 混淆函数对象和普通函数指针
    函数对象是类的实例,函数指针是函数的地址。两者都可以调用,但函数对象可以携带状态,函数指针不行。在模板中,函数对象的类型更灵活。

总结与相关指引

  • 函数对象:本质是一个重载了 operator() 的类,它的实例可以像函数一样被调用,同时能保存成员变量作为“记忆”。
  • 与普通函数相比:函数对象可以拥有状态、更易内联、更灵活。
  • 与 Lambda 表达式相比:Lambda 是 C++11 引入的语法糖,它会在编译时生成一个匿名的函数对象。如果你想写一个简单的操作,Lambda 更简洁;但如果需要复用的、有状态的操作,还是用函数对象更清晰。
  • 适用场景:STL 算法(for_eachsortfind_iftransform 等)中的可调用对象。

接下来,你可以学习:

  • Lambda 表达式:更简洁地创建匿名的函数对象。
  • 标准库预定义的函数对象:比如 std::plusstd::greaterstd::logical_and 等,它们实现了常见的算术、比较、逻辑操作。
  • std::function:一种通用的函数包装器,可以存储任何可调用对象(函数、函数对象、Lambda)。

掌握了函数对象,你就掌握了 C++ 泛型编程中很实用的一环,以后写代码就像搭积木一样灵活!

例题精讲

1单选题

下列关于函数对象(仿函数)的说法中,正确的是?

A函数对象是一个指向函数的指针,本质上就是函数。
B函数对象是一个重载了 operator() 的类或结构体实例,可以像函数一样被调用。
C函数对象只能用于排序算法,无法用于其他算法。
D函数对象必须定义为一个类,不能是结构体。
2判断题

函数对象可以拥有成员变量,从而在多次调用之间保存和改变状态。

3填空题
补全以下代码,使用函数对象实现对一个整数向量中所有偶数的计数。要求:定义一个函数对象类 IsEven,重载 operator() 返回布尔值;然后在 count_if 算法中使用该函数对象。

#include <iostream>
#include <vector>
#include <algorithm>

class IsEven {
public:
    bool operator()(int x) const {
        return ___;
    }
};

int main() {
    std::vector<int> v = {1,2,3,4,5,6};
    int cnt = std::count_if(v.begin(), v.end(), ___);
    std::cout << cnt << std::endl;
    return 0;
}
4单选题

相比直接使用普通函数,STL 算法中采用函数对象的主要优势不包括?

A函数对象可以通过成员变量维护状态。
B函数对象可以被内联优化,有时效率更高。
C函数对象支持重载,可以定义多个不同参数的 operator()。
D函数对象可以自动适配所有算法,无需考虑类型匹配。
5填空题
补全代码,实现一个函数对象类 GreaterThan,其构造函数接受一个阈值,重载 operator() 返回参数是否大于该阈值。然后在主函数中使用该函数对象配合 std::count_if 统计 vector 中大于 10 的元素个数。

#include <iostream>
#include <vector>
#include <algorithm>

class GreaterThan {
private:
    int threshold;
public:
    GreaterThan(int t) : threshold(t) {}
    bool operator()(int x) const {
        return ___;
    }
};

int main() {
    std::vector<int> v = {5, 12, 8, 15, 3, 20};
    int cnt = std::count_if(v.begin(), v.end(), ___);
    std::cout << cnt << std::endl;
    return 0;
}