function的类型擦除

要真正理解 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); // 调用实际存储的对象
    }
};

发生了什么?

  1. 当你把一个 Lambda 传给 MyFunction 时,编译器实例化了 CallableImpl<YourLambdaType>

  2. 这个 CallableImpl 知道具体类型,也知道大小。

  3. 但是,它被转型(Cast)成了 CallableBase* 存到了 ptr 里。

  4. 从此以后,外面的 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, ...); 
};

它是如何工作的?

  1. 当你存入一个函数指针(8字节)时:

    • sizeof(FuncPtr) <= sizeof(stack_buffer)

    • 不需要 new!直接用 placement new 把函数指针拷贝到 stack_buffer 这块内存里。

    • 性能极快,就像拷贝一个 long long

  2. 当你存入一个捕获了 1KB 数据的 Lambda 时:

    • sizeof(Lambda) > sizeof(stack_buffer)

    • 触发堆分配:storage.heap_ptr = new CallableImpl<BigLambda>(...)

    • 性能较慢,涉及 malloc


总结:性能开销到底在哪?

理解了原理,就能精准分析性能损耗:

  1. 虚函数开销(间接寻址):

    • 普通函数调用:Call 0x12345678(直接跳转,CPU 分支预测极准)。

    • std::function 调用:

      1. 读取 storage 中的指针。

      2. 读取对象的虚表指针 vptr。

      3. 查找虚表中的 call 函数地址。

      4. 跳转。

    • 后果: 这会导致指令流水线中断,且难以被编译器内联(Inline)优化。

  2. 缓存命中率 (Cache Locality):

    • 如果触发了堆分配,代码和数据(Lambda 捕获的变量)分散在堆的各处,可能导致 CPU 缓存未命中(Cache Miss)。

    • 如果是 SBO(栈上分配),数据就在当前栈帧,缓存极其友好。

发表评论