C++ std::function 函数包装器

在 C++ 中,能够像函数一样加上小括号 () 直接调用的目标有很多种,比如普通函数、Lambda 表达式,以及重载了调用运算符的函数对象。虽然它们的调用写法看起来一样,但底层的数据类型却各不相同。

如果我们使用传统的函数指针来保存或传递它们,一旦遇到捕获了外部局部变量的 Lambda 表达式,函数指针就无法接收了。为了把这些不同类型的可调用目标统一管理起来,C++11 在 <functional> 头文件中提供了通用的函数包装器 std::function。

std::function 的基本用法

std::function 本质上是一个类模板,定义在 <functional> 头文件中。只要可调用目标的 “参数类型” 和 “返回类型” 相同,无论是普通函数、Lambda 表达式还是函数对象,都可以装进同一个 std::function 对象中。

语法:

std::function<返回类型(参数类型列表)> 包装器名 = 可调用目标;

说明:

初次看到尖括号里的写法时,小伙伴们只需要按 “先看小括号左边、再看小括号里面” 的顺序来拆读就很直观了。小括号左边写的是目标函数的 “返回类型”,小括号里面写的是 “参数类型列表”,其中形参名字可以省略。比如:

  • std::function<int(int, int)>:表示包装 “接收两个 int 参数、并返回 int 结果” 的可调用目标。
  • std::function<void()>:表示包装 “不接收任何参数、也没有返回值” 的可调用目标。

对比传统的函数指针声明 int (*p)(int, int) 可以看出,std::function 把变量名干干净净地放在最右边,阅读起来清楚得多。

示例 1:包装普通函数与 Lambda

#include <functional>
#include <iostream>

int add(int a, int b) {
    return a + b;
}

int main() {
    std::function<int(int, int)> calc;

    // 1. 包装普通函数
    calc = add;
    std::cout << calc(10, 20) << '\n';

    // 2. 包装不捕获变量的 Lambda
    calc = [](int a, int b) {
        return a * b;
    };
    std::cout << calc(10, 20) << '\n';

    // 3. 包装捕获外部变量的 Lambda
    int base = 100;
    calc = [base](int a, int b) {
        return a + b + base;
    };
    std::cout << calc(10, 20) << '\n';
    return 0;
}

运行结果如下。

30
200
130

分析:

在这个例子中,我们定义了一个 std::function<int(int, int)> 类型的包装器 calc。普通函数 add 和两个 Lambda 都接收两个 int 并返回 int,因此同一个 calc 可以先后保存并调用它们。

这里最关键的优势体现在第 3 步。如果我们把 calc 换成传统的函数指针,一旦尝试指向捕获了外部局部变量的 Lambda,下面这种写法在编译时就会直接报错。

int base = 100;
// 编译报错
int (*p)(int, int) = [base](int a, int b) {
    return a + b + base;
};

为什么普通函数指针装不下它呢?因为普通函数指针只保存了一个简单的代码地址,没有地方存放捕获进来的局部变量 base。而 std::function 内部会自动管理空间,把捕获的数据和调用逻辑一并保存下来。

除了普通函数和 Lambda 之外,在前面学习运算符重载时我们知道,如果一个结构体或类重载了函数调用运算符 operator(),它的对象也可以像函数一样加小括号调用。这类对象通常称为 “函数对象”,std::function 同样可以直接包装它们。请看下面例子。

示例 2:包装函数对象

#include <functional>
#include <iostream>

struct Discount {
    double rate;

    double operator()(double price) const {
        return price * rate;
    }
};

int main() {
    std::function<double(double)> calcPay = Discount{0.8};

    std::cout << "折后金额:" << calcPay(150.0) << '\n';
    return 0;
}

运行结果如下。

折后金额:120

分析:

看到 operator()(double price) 连着两对小括号时,大家可能觉得有些眼生。其实 operator() 合起来是被重载的 “函数调用运算符” 名称,后面的 (double price) 才是参数列表。

Discount{0.8} 表示创建一个折扣率为 0.8 的函数对象。它的 operator() 接收 double 并返回 double,刚好匹配包装器的类型,执行 calcPay(150.0) 时就会算出折后价 120。

std::function 的常见应用

上面我们用简单的算术计算演示了基本语法。在实际项目开发中,std::function 最常出现在 “作为回调函数参数” 以及 “配合容器管理多个操作” 这两类场景中。

1. 作为回调函数参数

在编写通用函数时,如果我们希望由调用者自己传入某一步的计算规则或筛选条件,就可以把形参声明为常量引用 const std::function<...>&。这样调用者既可以传入现成的普通函数,也可以直接传入捕获了局部变量的 Lambda 表达式。

示例 3:作为回调函数参数

#include <functional>
#include <iostream>

int doubleScore(int s) {
    return s * 2;
}

void showScore(int s, const std::function<int(int)>& rule) {
    std::cout << "最终得分:" << rule(s) << '\n';
}

int main() {
    // 传入普通函数
    showScore(45, doubleScore);

    // 传入带捕获的 Lambda
    int bonus = 15;
    showScore(80, [bonus](int s) {
        return s + bonus;
    });
    return 0;
}

运行结果如下。

最终得分:90
最终得分:95

分析:

在这个例子中,showScore() 函数的第 2 个形参写成了常量引用 const std::function<int(int)>& rule,这样可以避免传参时产生多余的对象拷贝开销。

第 1 次调用 showScore() 时我们传入普通函数 doubleScore,算出翻倍后的 90。第 2 次直接传入捕获局部变量 bonus 的 Lambda,算出加分后的 95,而 showScore() 本身不需要做任何改动。

2. 配合容器管理多个操作

假如我们想做一个根据运算符字符查找计算逻辑的映射表,由于每个 Lambda 在底层都有各自独立的类型,我们无法直接把不同的 Lambda 放进同一个 map 容器中。

而借助 std::function 把类型统一之后,就可以把普通函数和不同的 Lambda 存进同一个容器里按需调用。下面我们来看一个例子。

示例 4:在 map 中保存多个操作

#include <functional>
#include <iostream>
#include <map>

int add(int a, int b) {
    return a + b;
}

int main() {
    std::map<char, std::function<int(int, int)>> ops = {
        {'+', add},
        {'-', [](int a, int b) { return a - b; }},
        {'*', [](int a, int b) { return a * b; }}
    };

    std::cout << "12 + 4 = " << ops['+'](12, 4) << '\n';
    std::cout << "12 - 4 = " << ops['-'](12, 4) << '\n';
    std::cout << "12 * 4 = " << ops['*'](12, 4) << '\n';
    return 0;
}

运行结果如下。

12 + 4 = 16
12 - 4 = 8
12 * 4 = 48

分析:

ops['+'](12, 4) 可以分两步来拆解:先通过 ops['+'] 从映射表中取出字符 '+' 对应的加法包装器,再通过 (12, 4) 传入两个整数完成计算,最终输出 16。

外层统一包装成 std::function<int(int, int)> 后,普通函数和不同的 Lambda 就能放进同一个容器,省去了手写一长串 switch 分支。如果接收的是用户输入的字符,调用前小伙伴们记得先使用 ops.find() 确认该字符存在。

提示 除了作为函数参数和容器元素之外,std::function 还经常用作结构体或类的成员变量,用来保存按钮点击、任务完成通知等外部注册的事件回调。

std::function 的空状态检查

如果我们在定义 std::function 变量时没有赋初始目标,或者将它赋值为 nullptr,此时这个包装器就处于 “空状态”,内部没有绑定任何可调用代码。

需要特别注意的是,小伙伴们千万不要直接加小括号去调用一个处于空状态的 std::function。一旦直接调用空包装器,程序在运行时就会触发 std::bad_function_call 错误并直接崩溃终止。因此在不确定包装器是否已绑定目标时,调用前一定要先做非空判断。

示例 5:检查非空后再调用

#include <functional>
#include <iostream>

int main() {
    std::function<void()> task;

    if (task != nullptr) {
        task();
    } else {
        std::cout << "task 当前为空,跳过调用\n";
    }

    // 绑定具体的任务逻辑
    task = []() {
        std::cout << "正在执行保存任务...\n";
    };
    if (task != nullptr) {
        task();
    }

    // 重新置为空状态
    task = nullptr;
    if (task == nullptr) {
        std::cout << "task 已重新置空\n";
    }
    return 0;
}

运行结果如下。

task 当前为空,跳过调用
正在执行保存任务...
task 已重新置空

分析:

从运行结果可以看出,未赋初值的 task 默认为空,第 1 次检查条件不成立,程序跳过了调用。绑定 Lambda 任务后再次判断成立并正常执行,最后执行 task = nullptr; 又可以随时清空已绑定的目标。

与普通指针一样,std::function 在条件判断中也会自动转换为布尔值:非空时转为 true;为空时转为 false。因此对于上面的非空检查,下面 2 种方式是等价的。

// 方式 1(推荐)
if (task != nullptr) {
    task();
}

// 方式 2
if (task) {
    task();
}

初学阶段我们更推荐使用方式 1,把 != nullptr 明确写出来,阅读代码时能直观看出这里是在检查包装器是否为空。

常见问题

1. 既然 std::function 什么都能装,平时在函数里保存单个 Lambda 还需要使用 auto 吗?

需要,而且我们更推荐优先使用 auto。如果只是在函数内部临时保存一个 Lambda(如 auto fn = [](int x) { return x * 2; };),直接使用 auto 让编译器推导即可,没有额外的包装开销。

只有当我们需要 “把多种不同可调用目标统一成同一种类型” 时,才需要使用 std::function,比如声明回调函数形参、作为类的成员变量或放进容器中。

2. 可以使用 == 比较两个 std::function 里装的是不是同一个函数吗?

不能。std::function 只支持与 nullptr 进行 == 或 != 比较,用来检查包装器本身是否为空。它不支持直接写 fn1 == fn2 来比较两个包装器对象,这样写会直接编译报错,因为任意的 Lambda 表达式和函数对象并没有统一的相等比较规则。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号