前言
std::function是 C++11 引入的通用可调用对象包装器。它能把普通函数、函数指针、lambda、成员函数指针、重载了operator()的类对象,统统装进同一个类型里,于是容器、回调注册表、任务队列都可以用统一的方式保存和调用。它解决的问题是“类型擦除”(type erasure):调用方只关心“能按这个签名调用”,不关心底层到底是什么类型。
最常见的误解是把std::function当成“零成本的 lambda 容器”。实际上它通常会带来一层间接调用,很多实现还会为较大的可调用对象在堆上分配内存。另一个误解是认为它可以装下任何可调用对象——标准要求目标类型满足 CopyConstructible,捕获了std::unique_ptr的 lambda 不能直接存进去;C++23 才提供std::move_only_function来解决这个问题。
本文先说明类型擦除的必要性,再给出一个基于“概念接口 + 模板模型”的迷你实现,最后讨论真实标准库实现中的小对象优化、空调用异常和复制语义。本文的迷你实现用于理解原理,不追求与标准库完全等价;具体实现的细节以标准草案与各标准库文档为准。
一、为什么需要类型擦除
假设我们要实现一个按钮回调。用户可能传函数指针,也可能传捕获了上下文的 lambda:
// 适用于 C++17
void on_click(void (*cb)(int));这个接口只能接受无捕获的 lambda,因为无捕获 lambda 可以转换成函数指针;一旦捕获了变量,它的类型就是编译器生成的独一无二的闭包类型,无法转成函数指针。模板可以解决一半问题:
// 适用于 C++17
template <class F>
void on_click(F cb);但模板会把F编码进on_click的类型,导致每个不同的 lambda 都实例化一份on_click,而且无法把回调存进同一个std::vector里——因为元素类型必须相同。
类型擦除的思路是:定义一个统一的抽象接口,把“按签名调用”变成虚函数;再用一个模板派生类把具体的可调用对象包起来,并通过基类指针持有它。这样外部只看到统一的包装类型,内部通过虚调用分派到真实对象。std::function的核心就是这个结构,业界常称为“概念/模型”(concept/model)模式或多态包装。
二、迷你实现的骨架
整个实现只有三个组成部分:
| 组件 | 作用 |
|---|
Concept | 抽象基类,声明invoke与clone两个纯虚函数 |
Model<F> | 模板派生类,持有具体可调用对象F,实现两个虚函数 |
Function | 外部包装类,用std::unique_ptr<Concept>持有模型,提供构造、拷贝、调用 |
clone()是为了支持拷贝语义:std::function可拷贝,但基类指针不能直接拷贝,否则两个包装对象会共享同一个模型并重复释放。让模型自己克隆一份,拷贝构造就变成了“调用clone得到新的模型”。
invoke的参数写成Args&&...,配合std::forward保留调用方的值类别。包装器内部不需要知道参数原来是什么,只要把收到的实参原样转发给目标对象即可。
三、完整示例代码
下面的代码实现了一个简化版Function,支持函数指针、lambda 和函数对象,支持拷贝、移动、判空与空调用异常。第一行标注了适用标准。
// 适用于 C++17
#include <cstddef>
#include <functional>
#include <iostream>
#include <memory>
#include <type_traits>
#include <utility>
template <class Signature>
class Function;
template <class R, class... Args>
class Function<R(Args...)> {
public:
Function() noexcept = default;
Function(std::nullptr_t) noexcept {}
template <class F,
class = std::enable_if_t<
!std::is_same<std::decay_t<F>, Function>::value>>
Function(F&& f)
: impl_(std::make_unique<Model<std::decay_t<F>>>(std::forward<F>(f))) {}
Function(const Function& other)
: impl_(other.impl_ ? other.impl_->clone() : nullptr) {}
Function(Function&& other) noexcept = default;
Function& operator=(const Function& other) {
Function tmp(other);
swap(tmp);
return *this;
}
Function& operator=(Function&& other) noexcept = default;
R operator()(Args... args) const {
if (!impl_) {
throw std::bad_function_call();
}
return impl_->invoke(std::forward<Args>(args)...);
}
explicit operator bool() const noexcept { return impl_ != nullptr; }
void swap(Function& other) noexcept { impl_.swap(other.impl_); }
private:
struct Concept {
virtual ~Concept() = default;
virtual R invoke(Args&&... args) = 0;
virtual std::unique_ptr<Concept> clone() const = 0;
};
template <class F>
struct Model final : Concept {
explicit Model(F f) : fn(std::move(f)) {}
R invoke(Args&&... args) override {
if constexpr (std::is_void<R>::value) {
fn(std::forward<Args>(args)...);
} else {
return fn(std::forward<Args>(args)...);
}
}
std::unique_ptr<Concept> clone() const override {
return std::make_unique<Model>(fn);
}
F fn;
};
std::unique_ptr<Concept> impl_;
};
int add(int a, int b) {
return a + b;
}
int main() {
Function<int(int, int)> f = add;
std::cout << f(2, 3) << '\n';
int base = 10;
f = [base](int a, int b) { return base + a * b; };
std::cout << f(2, 3) << '\n';
Function<int(int, int)> g = f; // 拷贝:调用 clone
std::cout << g(4, 5) << '\n';
Function<void(int)> log = [](int v) {
std::cout << "value=" << v << '\n';
};
log(7);
Function<int(int, int)> empty;
if (!empty) {
std::cout << "empty\n";
}
try {
empty(1, 2);
} catch (const std::bad_function_call&) {
std::cout << "caught bad_function_call\n";
}
return 0;
}按标准语义推演:f = add时,模板构造函数把函数指针类型int(*)(int,int)作为F,Model保存该指针;f(2, 3)通过虚函数invoke转发,返回 5。随后赋值一个捕获base的 lambda,包装器内部的旧模型被销毁,新模型持有闭包副本。g = f触发拷贝构造,clone()生成一个持有同一闭包副本的新模型,二者互不影响。空的Function调用operator()时抛出std::bad_function_call。
这段代码有两点刻意的简化。第一,Model::clone要求F可拷贝,因此捕获了unique_ptr的 lambda 无法存入;这与标准std::function的约束一致。第二,没有实现小对象优化,所有目标都放在堆上;标准库实现通常会把小到能塞进内部缓冲区的对象直接存进去。
四、真实实现中的几个关键细节
小对象优化(small buffer optimization, SBO)。标准并没有规定std::function必须如何存储目标,但主流实现会在包装器内部预留一小块缓冲区,目标对象足够小且满足对齐要求时就地构造,否则才堆分配。这解释了为什么std::function的大小通常远大于一个指针,也解释了为什么“存一个小 lambda 不分配内存”只是实现层面的常见行为,而不是标准保证。
拷贝与移动。std::function的拷贝构造会复制目标对象;移动构造之后,源对象处于有效但未指定的状态,标准只要求它仍可安全析构和重新赋值,不要求它一定为空。因此不要假设“移动后源对象必然为空”,需要判空时重新赋值或检查operator bool(但移动后的值本身未指定)。
空调用。默认构造或由nullptr构造的std::function不含目标,调用operator()会抛出std::bad_function_call(定义在<functional>)。它不会返回默认值,也不会静默失败。
类型查询。target_type()返回目标的std::type_info,target<T>()在目标类型正好是T时返回指针,否则返回nullptr。这两者适合调试和少量需要知道底层类型的场景,不应作为常规分派手段。
只可移动的可调用对象。C++23 增加了std::move_only_function(需要 C++23 及以上的标准库支持),它放宽了 CopyConstructible 要求,适合包装捕获unique_ptr的 lambda 或只能移动的函数对象。在 C++17 下如果确实需要,可以自己写一个只移动的包装器,或改用std::unique_ptr加自定义接口。
常见坑点
- ❌ 认为
std::function保存的是 lambda 的引用。
✅ 它保存的是目标的副本;lambda 捕获的变量在构造时被复制,之后修改原变量不会影响包装器内的副本。
- ❌ 把捕获了
std::unique_ptr的 lambda 赋给std::function。
✅ 标准要求目标可拷贝;改用std::shared_ptr捕获,或使用 C++23 的std::move_only_function。
- ❌ 调用空的
std::function并期望得到默认值。
✅ 调用前用if (f)判断,或捕获std::bad_function_call。
- ❌ 在热路径上反复构造
std::function,以为它和直接调用一样快。
✅ 类型擦除会引入间接调用;把包装器构造提到循环外,性能敏感处直接用模板或函数指针。
- ❌ 认为
target<T>()可以用来判断“能否按某签名调用”。
✅ 它只比较精确类型;签名兼容性应通过std::is_invocable等 trait 在编译期判断。
- ❌ 移动
std::function后继续读取源对象保存的目标。
✅ 移动后源对象处于有效但未指定状态,只能销毁或重新赋值。
- ❌ 在回调里保存
std::function的引用参数,回调返回后继续使用。
✅ 需要长期保存时按值捕获或复制一份;引用参数只在调用期间有效。
总结
| 要点 | 说明 |
|---|
| 核心机制 | 类型擦除:抽象基类 + 模板模型 + 智能指针持有 |
| 调用方式 | 通过虚函数间接调用,参数用完美转发保持值类别 |
| 拷贝语义 | 模型实现clone,避免多个包装器共享同一目标 |
| 存储策略 | 标准不规定;常见实现有小对象优化,也可能堆分配 |
| 约束 | 目标必须可拷贝;空调用抛std::bad_function_call |
| 现代替代 | C++23std::move_only_function支持只移动目标 |
理解std::function的实现,最重要的收获不是背下这段代码,而是认清它的成本边界:一次类型擦除换来接口统一,代价是一层间接调用和潜在的内存分配。凡是能接受模板的场合,模板往往更直接;只有确实需要“运行期统一类型”时,std::function才是合适的选择。