在vscode里写C++写到一定阶段,总会撞上一个很微妙的问题:你写了一个模板函数,它能接收任意类型的可调用对象,但你想把它们统一存进一个std::vector,却被编译器无情地拒绝了——因为模板函数每实例化一次就是一个新类型,它们之间没有任何公共基类。这时候你就碰到了C++里最值钱也最容易绕晕的概念之一:类型擦除(type erasure)。
今天这篇东西,我打算把type erasure从“听上去很玄”讲到“源码级别能看懂”,再把工程里最常见的使用场景、性能代价、以及我踩过的一些坑全部铺开。适合正在学C++的初学者看得懂大概,也适合写了两三年C++但一直没搞透std::function和std::any底层原理的人静下心读一遍。
1. 类型擦除到底解决了什么问题——从一次“存不住”的编译错误说起
1.1 模板和虚函数:两种多态各自的天生缺陷
C++里有多态,而且不止一种。模板是编译期多态,编译器根据调用时的具体类型生成对应代码。虚函数是运行期多态,通过隐藏的vptr(虚表指针)在运行时找到真正的函数入口。
先说模板。模板写起来非常爽,std::sort可以对任何迭代器排序,std::thread可以接收任何可调用对象。但模板有个致命问题:它没法把一个类型不确定的东西存起来。
cpp复制template <typename F>
void run_with_log(F f) {
std::cout << "start\n";
f();
}
// 假设有两个不同的 lambda
auto a = []() { std::cout << "A\n"; };
auto b = []() { std::cout << "B\n"; };
// 不行!a和b是两种完全不同的类型,vector需要元素类型一致
// std::vector<???> functions = {a, b};
a的类型可能是main::$_0,b的类型是main::$_1,编译器根本不会帮你找到它们的“公共祖类”,因为lambda之间没有任何继承关系。
虚函数是运行期多态,它把“类型不同但行为相似”的类统一到一个基类接口下。但它要求所有参与多态的类必须显式继承同一个基类,而且基类里得有虚函数。这逼着使用者去修改自己的类,对于第三方库、内置类型、lambda表达式,这个方案直接不成立。
那能不能既不用继承、又能在运行时统一处理不同类型呢?这就是type erasure的出发点。
1.2 类型擦除最直白的理解:对外一个壳,对内一张表
我自己的理解方式很简单:类型擦除就是把“具体类型”藏起来,只暴露“接口签名”。
想象你去饭店点餐,菜单上写的是“清蒸鲈鱼”,你不需要知道后厨用的是哪条鱼、哪个师傅、哪口锅,你只需要知道点这个菜最后会上来一份清蒸鲈鱼。这里“清蒸鲈鱼”就是被擦除之后的统一接口,具体是海鲈鱼还是淡水鲈鱼、师傅姓张还是姓李,都是被藏在背后的具体类型。
在C++代码里,类型擦除最经典的三件套是std::function、std::any和std::variant——当然严格说std::variant不算擦除,它保留了类型集合。std::function擦除掉“可调用对象的具体类型”,只保留operator()的签名;std::any擦除掉一切类型,只保留“能存、能取”这个核心能力。
1.3 为什么lambda调用要经过“二次跳转”才能到真正的函数
很多人在刚接触std::function时有个疑惑:我直接把lambda赋给auto,调用的时候不是直接内联吗?为什么要包一层function反而更慢?
因为你一旦把一个lambda放进std::function,lambda的具体类型就被藏起来了。调用时编译器看到的是一个统一的函数签名void(),它只能通过std::function内部的函数指针或虚函数跳到真正的lambda实现上。这就是多了一次间接调用的来源。后面第3节我会详细展开代价。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从零实现一个迷你std::function——继承和函数指针两条路都走一遍
2.1 用抽象基类做类型擦除:最快能跑起来的思路
先看教科书里最常见的一种实现方式:用一个抽象基类定义统一接口,然后用模板派生类承接具体类型,最后通过基类指针调用。
cpp复制class CallableBase {
public:
virtual ~CallableBase() = default;
virtual void call() const = 0;
};
template <typename F>
class CallableHolder final : public CallableBase {
public:
explicit CallableHolder(F f) : f_(std::move(f)) {}
void call() const override { f_(); }
private:
F f_;
};
class Function {
public:
Function() = default;
template <typename F>
Function(F f) : base_(std::make_unique<CallableHolder<F>>(std::move(f))) {}
void operator()() const { base_->call(); }
private:
std::unique_ptr<CallableBase> base_;
};
思路非常清晰:CallableHolder<F>把任何可调用对象F装进一个统一的CallableBase接口下,外部只知道CallableBase有这个call(),具体的F被藏在了里面。这就是一种完完全全的type erasure:对外暴露的是void()签名,对内具体类型是什么,外部完全不知道。
用起来也很顺手:
cpp复制Function f1 = []() { std::cout << "hello\n"; };
Functio
