std::function 的“类型擦除”,我们需要拆开它的黑盒子,看看编译器在底层到底生成了什么样的代码结构。
所谓的“类型擦除”(Type Erasure),本质上就是:在编译期我不关心你到底是谁(结构体?函数指针?Lambda?),但我依然要把你存起来,并且在运行期能正确调用你。
为了实现这一点,std::function 在内部使用了 “桥接模式 (Bridge Pattern)” + “虚函数多态” 的组合拳。
我们可以把 std::function 的内部结构拆解为三个核心层级。为了方便理解,我会写一个简化版的 MyFunction 来模拟标准库的实现。
第一层:统一的外壳 (The Wrapper)
这是我们在代码里直接使用的 std::function<void(int)>。它本身不是模板类(指它的类名不包含具体的 Callable 类型,只包含返回值和参数),但它的构造函数是模板。
它的内部主要持有一个基类指针。
// 假设我们要模拟 std::function<void(int)>
class MyFunction {
public:
// 构造函数是模板:这里是“类型擦除”发生的入口!
// 无论传进来的是函数指针,还是巨大的 Struct,这里都用 F 接收
template <typename F>
MyFunction(F f) {
// new 出具体的实现类,但赋值给基类指针
// 这一步之后,ptr 就不知道 F 具体是谁了,只知道它是一个 CallableBase
ptr = new CallableImpl<F>(f);
}
~MyFunction() { delete ptr; }
// 调用操作符
void operator()(int arg) {
// 通过虚函数机制,调用实际的代码
ptr->call(arg);
}
private:
// 内部定义的基类指针
struct CallableBase {
virtual void call(int) = 0;
virtual ~CallableBase() = default;
};
CallableBase* ptr; // <--- 关键:只持有基类指针
};
第二层:隐藏的具体实现 (The Eraser)
要让上面的 ptr->call() 工作,我们需要一个中间层。这个中间层利用 C++ 的模板类继承非模板基类的特性。
这就是为什么 std::function 内部会有虚表(V-Table)。
// 这是一个模板类,继承自非模板的基类
// 每一个不同的 F(不同的 Lambda,不同的函数指针)都会实例化出一个新的 CallableImpl 类
template <typename F>
struct CallableImpl : public CallableBase {
F data; // 这里真正存储了你的对象(函数指针、Lambda 等)
CallableImpl(F f) : data(f) {}
// 重写虚函数
void call(int arg) override {
data(arg); // 调用实际存储的对象
}
};
发生了什么?
-
当你把一个 Lambda 传给
MyFunction时,编译器实例化了CallableImpl<YourLambdaType>。 -
这个
CallableImpl知道具体类型,也知道大小。 -
但是,它被转型(Cast)成了
CallableBase*存到了ptr里。 -
从此以后,外面的
MyFunction只知道ptr指向一个“能调用的东西”,类型信息被“擦除”了,只留下了“行为(虚函数)”。
第三层:内存管理的魔法 (SBO – Small Buffer Optimization)
你提到的“装下 4 字节还是大对象”,涉及到 SBO (小对象优化)。
如果每次创建一个 std::function 都要 new 一次(分配堆内存),那性能太差了。特别是对于只有 8 字节的函数指针。
标准库的实现通常会在类内部放一个小缓冲区(union)。
内存布局示意图
class std_function_implementation {
private:
// 这是一个联合体
union Storage {
// 方案 A:直接存指针(用于堆分配的大对象)
CallableBase* heap_ptr;
// 方案 B:预留的一块栈内存(比如 16 或 32 字节)
char stack_buffer[32];
} storage;
// 一个函数指针,用于管理 storage 中的对象(如何调用,如何销毁,如何复制)
// 这其实是另一种形式的手动虚表(V-Table)
void (*manager_func)(Storage&, Action, ...);
};
它是如何工作的?
-
当你存入一个函数指针(8字节)时:
-
sizeof(FuncPtr) <= sizeof(stack_buffer)。 -
不需要
new!直接用placement new把函数指针拷贝到stack_buffer这块内存里。 -
性能极快,就像拷贝一个
long long。
-
-
当你存入一个捕获了 1KB 数据的 Lambda 时:
-
sizeof(Lambda) > sizeof(stack_buffer)。 -
触发堆分配:
storage.heap_ptr = new CallableImpl<BigLambda>(...)。 -
性能较慢,涉及
malloc。
-
总结:性能开销到底在哪?
理解了原理,就能精准分析性能损耗:
-
虚函数开销(间接寻址):
-
普通函数调用:
Call 0x12345678(直接跳转,CPU 分支预测极准)。 -
std::function调用:-
读取
storage中的指针。 -
读取对象的虚表指针 vptr。
-
查找虚表中的
call函数地址。 -
跳转。
-
-
后果: 这会导致指令流水线中断,且难以被编译器内联(Inline)优化。
-
-
缓存命中率 (Cache Locality):
-
如果触发了堆分配,代码和数据(Lambda 捕获的变量)分散在堆的各处,可能导致 CPU 缓存未命中(Cache Miss)。
-
-