前言
std::function是 C++11 引入的通用多态函数包装器,定义在<functional>中。它可以把函数指针、lambda、std::bind的结果、重载了operator()的类对象,统一存成同一种类型,例如std::function<int(int)>。它的价值在于类型擦除:调用方不需要知道具体可调用对象是什么类型,只需要知道签名。
围绕它有四个常见误解。第一,「std::function是零开销抽象」——它内部通常有一次间接调用,还可能需要堆分配,是否可接受取决于调用频率。第二,「空的std::function调用什么也不做」——调用空对象会抛出std::bad_function_call。第三,「什么可调用对象都能装」——C++17 的std::function要求可调用对象可拷贝构造,捕获了std::unique_ptr的 lambda 装不进去。第四,「std::function就是函数指针」——函数指针只是它支持的一种输入,二者的存储模型和开销完全不同。
本文讲清类型擦除的实现思路、构造与调用方式、成员函数指针的适配、性能边界,以及 C++23 的std::move_only_function作为补充。所有示例以 C++17 为基准。
一、类型擦除与对象模型
std::function<R(Args...)>的内部需要做两件事:保存一个任意类型的可调用对象;在调用时以R(Args...)的签名调用它。这两件事无法用普通成员变量完成,因为类型在编译期未知,所以标准库采用类型擦除:内部持有一个基类指针(或等价机制),派生类模板保存具体的可调用对象,并通过虚函数完成调用与拷贝。
由此可以推出几个行为特征。第一,std::function的大小是固定的,但标准没有规定具体值,也没有规定是否使用小对象优化(small buffer optimization, SBO)。常见实现会在对象较小且可平凡搬移时直接存放在内部缓冲区,避免堆分配;但这只是实现策略,不是可移植保证。第二,拷贝一个std::function会拷贝其内部的可调用对象;如果该对象持有资源,拷贝可能分配内存。第三,std::function的调用通常是间接调用,编译器难以像对待普通函数那样内联。
标准对模板参数的要求是:被存储的可调用对象必须满足CopyConstructible,并且能用给定的实参类型调用、返回值能转换为R。这个「可拷贝」要求是 C++17 下最大的限制,后文会讲替代方案。
二、构造、调用与成员函数
最基本的用法是把 lambda 赋给std::function:
// 适用于 C++17
#include <functional>
#include <iostream>
int main() {
std::function<int(int)> twice = [](int x) { return x * 2; };
std::cout << twice(21) << '\n';
std::function<int(int)> empty; // 空状态
std::cout << static_cast<bool>(empty) << '\n'; // 0
if (empty) {
empty(1); // 不会执行
}
}空std::function的operator bool返回 false;对它调用operator()会抛出std::bad_function_call(定义在<functional>),该异常派生自std::exception。因此「可能为空」的回调在调用前必须检查,或者用try/catch兜底。不要假设空调用是无操作。
函数指针可以直接赋值,但重载函数需要先消除歧义:
// 适用于 C++17
#include <functional>
int process(int x) { return x + 1; }
double process(double x) { return x + 0.5; }
void demo() {
std::function<int(int)> f = static_cast<int(*)(int)>(&process);
std::function<double(double)> g = static_cast<double(*)(double)>(&process);
}成员函数指针也能放入std::function,但签名要把对象作为第一个参数:
// 适用于 C++17
#include <functional>
#include <iostream>
#include <string>
struct Greeter {
std::string prefix;
void greet(const std::string& name) const {
std::cout << prefix << name << '\n';
}
};
int main() {
Greeter g{"Hello, "};
std::function<void(const Greeter&, const std::string&)> direct = &Greeter::greet;
direct(g, "Ada");
std::function<void(const std::string&)> bound = [&g](const std::string& n) {
g.greet(n);
};
bound("Bob");
}direct保存的是成员函数指针,调用时通过第一个实参提供对象;bound用 lambda 把对象固定下来。两者语义不同:前者每次调用可以传不同对象,后者绑定到g。若用std::bind(&Greeter::greet, &g, std::placeholders::_1)也能达到绑定效果,但 lambda 更直观,也更容易看出捕获的是指针还是引用。用std::bind时如果传的是对象指针,g销毁后调用就是悬垂访问;传对象副本则会拷贝,语义又不同。
std::function还提供target_type()与target<T>(),用于查询内部实际存储的类型。但 lambda 的类型是编译器生成的未命名闭包类型,无法在源码里写出T,因此对 lambda 调用target<T>()实际上拿不到;这两个接口主要用于「存的是不是某个函数指针或已知函数对象」的判断,调试时target_type().name()的输出也是实现定义的。
三、性能边界与替代方案
std::function的成本来自三处:间接调用(难以内联)、可能的堆分配(可调用对象超过内部缓冲区时)、拷贝时的深拷贝。标准不保证 SBO,因此不能假设小 lambda 一定不分配。以下场景应当谨慎:
| 场景 | 建议 |
|---|
| 高频调用的热路径 | 用模板参数或函数指针,让调用点可见 |
| 只是把回调传进函数一次 | 用模板template <class F> void run(F&& f),避免类型擦除 |
| 需要保存异构回调集合 | std::function合理 |
| 回调可能为空 | 调用前检查,或改用std::optional+ 明确的默认行为 |
| 可调用对象只能移动 | C++17 下不能用std::function |
最后一条值得展开。C++17 的std::function要求可拷贝,因此下面的代码无法通过编译:
// 适用于 C++17:这段代码无法编译
#include <functional>
#include <memory>
std::function<void()> f = [p = std::make_unique<int>(1)] { (void)*p; };lambda 捕获了unique_ptr,闭包类型不可拷贝,不满足std::function的要求。C++23 引入了std::move_only_function,允许存储仅可移动的可调用对象,签名写法类似std::move_only_function<void()>;使用它需要 C++23 及支持该特性的标准库。若受限于 C++17,可以把状态放到std::shared_ptr里再捕获,用共享所有权换取可拷贝性,但要清楚这改变了语义。
另一个替代是std::function_ref一类的「非拥有引用」视图,但截至 C++23 它尚未进入标准,C++26 才计划加入。在此之前,需要「借用可调用对象、不取得所有权」时,可以自己写一个只保存void*与函数指针的轻量包装,或直接用模板参数。不要凭印象使用不存在的标准类型。
四、完整示例:回调注册与递归
下面是一个事件分发的骨架,演示std::function的典型用法:保存多个回调、在触发时逐个调用、支持移除。为了简洁,这里用整数 id 标识回调:
// 适用于 C++17
#include <functional>
#include <iostream>
#include <string>
#include <unordered_map>
#include <vector>
class EventBus {
public:
using Handler = std::function<void(const std::string&)>;
int subscribe(Handler handler) {
int id = nextId_++;
handlers_.emplace(id, std::move(handler));
return id;
}
void unsubscribe(int id) {
handlers_.erase(id);
}
void publish(const std::string& message) const {
for (const auto& entry : handlers_) {
if (entry.second) { // 防御空回调
entry.second(message);
}
}
}
private:
int nextId_ = 1;
std::unordered_map<int, Handler> handlers_;
};
int main() {
EventBus bus;
int count = 0;
int id = bus.subscribe([&count](const std::string& msg) {
++count;
std::cout << "got: " << msg << '\n';
});
bus.publish("first");
bus.unsubscribe(id);
bus.publish("second");
std::cout << count << '\n';
}几个细节:subscribe按值接收Handler再std::move进容器,避免一次多余拷贝;publish是const成员,因此遍历时不能修改容器,这符合「发布过程中不增删订阅者」的约定;如果需要在回调里取消订阅,就必须先把回调拷贝出来再调用,或改用「标记删除、稍后清理」的策略,否则在遍历中擦除元素会使迭代器失效。
递归 lambda 是另一个常见需求。lambda 无法直接引用自身,通常先把std::function声明出来,再按引用捕获:
// 适用于 C++17
#include <functional>
#include <iostream>
int main() {
std::function<int(int)> fib;
fib = [&fib](int n) -> int {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
};
std::cout << fib(10) << '\n';
}这里必须按引用捕获fib,按值捕获会在 lambda 构造时拷贝一个尚未赋值的空std::function,调用时抛出std::bad_function_call。同时要保证fib的生命周期长于所有调用;如果把 lambda 存起来延后执行,而fib是局部变量,就会悬垂。
常见坑点
- ❌ 直接调用可能为空的
std::function。
✅ 调用前if (f)判断,或捕获std::bad_function_call并定义降级行为。
- ❌ 用 lambda 按引用捕获局部变量,把
std::function存起来异步执行。
✅ 按值捕获需要的数据,或捕获std::shared_ptr延长生命周期。
- ❌ 认为
std::function没有额外开销,在热路径里反复构造。
✅ 热路径用模板参数或函数指针;std::function放在初始化阶段构造一次。
- ❌ 把捕获了
std::unique_ptr的 lambda 赋给std::function。
✅ C++17 下改用std::shared_ptr捕获,或升级到 C++23 用std::move_only_function。
- ❌ 递归 lambda 按值捕获自身,得到空的
std::function。
✅ 先声明std::function,再按引用捕获。
- ❌ 用
std::bind绑定对象指针,对象销毁后仍调用。
✅ 用 lambda 明确捕获方式;异步场景用weak_ptr并检查是否失效。
- ❌ 把重载函数名直接赋给
std::function。
✅ 用static_cast指定函数指针类型,或包一层 lambda。
- ❌ 依赖
target_type().name()的输出做逻辑判断。
✅ 名称是实现定义的;需要类型判断时用target<T>()配合已知类型。
总结
| 主题 | 结论 |
|---|
| 本质 | 类型擦除的可调用对象包装器,签名固定 |
| 空状态 | operator bool为 false;调用抛std::bad_function_call |
| 存储要求 | C++17 要求可拷贝;仅可移动对象需 C++23std::move_only_function |
| 开销 | 间接调用,可能堆分配;标准不保证小对象优化 |
| 成员函数 | 签名首参为对象,或改用 lambda 绑定 |
| 适用场景 | 回调注册、异构回调集合;热路径优先模板 |
std::function是一个「用一点运行时开销换取接口统一」的工具。理解它的类型擦除模型,就能预判它的开销来源和拷贝语义;记住空调用会抛异常、可调用对象必须可拷贝这两条硬约束,就能避开大多数运行期和编译期错误。真正的高频路径,应该让类型在编译期可见,而不是依赖类型擦除。