std::any 能装任意类型,std::function 能包任意可调用对象。这俩能力差得远,底层却是同一招:类型擦除(type erasure):用「非模板接口 + 模板派生的壳 + 虚函数跳板」把具体类型藏起来。这篇把两个都手写一遍,真跑,看完你就明白标准库这两个类在做什么、以及为什么它能省掉模板的代码膨胀。
1. 引子:一个矛盾的需求
想写一个「能装任何类型」的容器,或者「能包任何 lambda」的回调,会遇到同一个矛盾:
- 如果用模板(
template <typename T> class Box),类型在编译期确定,每个类型生成一份代码 —— 类型一多,二进制就膨胀,而且无法放进同一个vector里统一处理; - 如果用继承多态(
Base*指向Derived),能统一放进容器,但所有类型都必须提前继承自同一个Base—— 你没法让int去继承一个基类。
类型擦除是第三条路:对外只暴露一个非模板的「接口」类,内部用模板把任意类型包装成一个实现了该接口的「壳」,通过虚函数跳板访问真实数据。它既统一了接口(能放进 vector<Any>),又不要求类型有任何继承关系。
2. 手写 any:typeid 校验取值
std::any 的骨架就三步:一个抽象基类 Base(接口),一个模板派生类 Holder<T>(壳),一个 unique_ptr<Base> 成员(持有壳的指针)。取值时用 typeid 校验「你想要的类型」和「我存的类型」是不是同一个:
// any_demo.cpp — 编译: g++ -std=c++17 -Wall -O2 any_demo.cpp -o anydemo
#include <cstdio>
#include <memory>
#include <string>
#include <typeinfo>
#include <utility>
class Any {
struct Base { // 非模板接口:把所有类型统一成它
virtual ~Base() = default;
virtual const std::type_info& type() const = 0;
virtual std::unique_ptr<Base> clone() const = 0;
};
template <typename T>
struct Holder : Base { // 模板壳:真正装数据的地方
T value;
explicit Holder(T v) : value(std::move(v)) {}
const std::type_info& type() const override { return typeid(T); }
std::unique_ptr<Base> clone() const override {
return std::make_unique<Holder<T>>(value);
}
};
std::unique_ptr<Base> ptr_;
public:
Any() = default;
Any(const Any& other) : ptr_(other.ptr_ ? other.ptr_->clone() : nullptr) {}
Any(Any&&) noexcept = default;
Any& operator=(const Any& other) { ptr_ = other.ptr_ ? other.ptr_->clone() : nullptr; return *this; }
Any& operator=(Any&&) noexcept = default;
template <typename T>
Any(T v) : ptr_(std::make_unique<Holder<T>>(std::move(v))) {} // 隐式:任何类型都能塞进来
bool has_value() const { return ptr_ != nullptr; }
const std::type_info& type() const { return ptr_ ? ptr_->type() : typeid(void); }
template <typename T>
T& cast() { // 取值:先 typeid 校验,再 static_cast
if (!ptr_ || ptr_->type() != typeid(T)) throw std::bad_cast();
return static_cast<Holder<T>*>(ptr_.get())->value;
}
};
int main() {
Any a = 42;
std::printf("a 是 int = %d,值 = %d\n",
static_cast<int>(a.type() == typeid(int)), a.cast<int>());
Any b = std::string("hello");
std::printf("b 是 string = %d,值 = %s\n",
static_cast<int>(b.type() == typeid(std::string)), b.cast<std::string>().c_str());
bool threw = false;
try { a.cast<std::string>(); } // 把 int 当 string 取 → 抛异常
catch (const std::bad_cast&) { threw = true; }
std::printf("取错类型抛异常 = %d\n", static_cast<int>(threw));
}
a 是 int = 1,值 = 42
b 是 string = 1,值 = hello
取错类型抛异常 = 1
内存布局是这样的(概念图):
手写 Any 的内存布局:类型擦掉,藏进虚表
Any 对象(栈上,约 8 字节)
┌──────────────────┐
│ unique_ptr<Base> ┼──► 堆上的 Holder<T>
└──────────────────┘
┌──────────────────────────────┐
│ vptr ──► Base 的虚函数表 │ ← 具体类型 T 被擦掉,
│ type() 返回 typeid(T) │ 藏进虚表指针
│ clone() 复制一个新的 Holder<T> │
│ value : T(真正的数据) │
└──────────────────────────────┘
cast<T>():先比 type()==typeid(T),对上再 static_cast 到 Holder<T> 拿 value
cast<T>() 里那句 static_cast<Holder<T>*> 之所以安全,正是因为前面已经用 typeid(T) 校验过「存的确实是 T」—— 类型擦除不是「丢掉类型信息」,而是「把类型信息藏进虚表,取用时再核对」。这也解释了为什么 any 取错类型是运行时才报(抛 bad_cast),而模板是编译期就报。
官方文档:std::any · std::type_info(
typeid返回的就是它)
3. 手写 function:invoker 跳板
std::function 的套路一模一样,只是把「存数据」换成「存一个可调用对象」,虚函数从 type() 换成 call()。这个负责调用的虚函数就是所谓的 invoker(跳板):
// function_demo.cpp — 编译: g++ -std=c++17 -Wall -O2 function_demo.cpp -o fndemo
#include <cstdio>
#include <memory>
#include <type_traits>
#include <utility>
template <typename Signature> class Function; // 主模板:只声明,不定义
template <typename R, typename... Args>
class Function<R(Args...)> {
struct InvokerBase { // 非模板接口:统一的「可调用」抽象
virtual ~InvokerBase() = default;
virtual R call(Args... args) = 0;
virtual std::unique_ptr<InvokerBase> clone() const = 0;
};
template <typename F>
struct Invoker : InvokerBase { // 模板壳:包住任意可调用对象
F f;
explicit Invoker(F f) : f(std::move(f)) {}
R call(Args... args) override { // 跳板:把虚调用转发给 f
if constexpr (std::is_void_v<R>) {
f(args...);
} else {
return f(args...);
}
}
std::unique_ptr<InvokerBase> clone() const override {
return std::make_unique<Invoker<F>>(f);
}
};
std::unique_ptr<InvokerBase> inv_;
public:
Function() = default;
Function(const Function& other) : inv_(other.inv_ ? other.inv_->clone() : nullptr) {}
Function(Function&&) noexcept = default;
Function& operator=(const Function& other) { inv_ = other.inv_ ? other.inv_->clone() : nullptr; return *this; }
Function& operator=(Function&&) noexcept = default;
template <typename F>
Function(F f) : inv_(std::make_unique<Invoker<F>>(std::move(f))) {} // 任何可调用对象都能包
R operator()(Args... args) { return inv_->call(args...); } // 对外:像普通函数一样调
};
int add(int x, int y) { return x + y; }
int main() {
Function<int(int, int)> f1 = add; // 包函数指针
Function<int(int, int)> f2 = [](int x, int y) { return x * y; }; // 包 lambda
Function<void(int)> printer = [](int v) { std::printf("value = %d\n", v); }; // 包 void 返回
std::printf("add(3,4) = %d\n", f1(3, 4));
std::printf("mul(3,4) = %d\n", f2(3, 4));
printer(7);
}
add(3,4) = 7
mul(3,4) = 12
value = 7
调用链长这样:
手写 Function 的调用链:虚调用只发生在跳板这一层
Function<R(Args...)> 对象
┌───────────────────────────┐
│ unique_ptr<InvokerBase> ┼──► Invoker<F>
└───────────────────────────┘ │ f : F(lambda / 函数指针)
│ call(Args...) ← 唯一的虚调用(跳板)
└──────┬───────
fn(1,2) ─► operator() ─► inv_->call(1,2) ─► f(1,2)
(非模板代码) (虚函数间接调用) (模板实例,每种 F 一份,可内联)
这里的关键分工:Function 类和 InvokerBase 都是非模板的(编译成一份),只有 Invoker<F> 这个壳是模板(每个可调用对象类型一份,但很小,只做转发)。于是 vector<Function<int(int,int)>> 可以装下一堆签名相同、实现各异的 lambda,而不用为它们各自写一份完整的包装代码。
if constexpr (std::is_void_v<R>) 处理了「返回 void」的特例:void 函数不能 return f(...),只能「调用但不返回」。这也是 std::function 内部会用到的技巧。
官方文档:std::function · std::make_unique
4. 为什么这能省模板代码膨胀
把三种「统一不同类型」的写法放一起比:
| 方案 | 类型何时确定 | 代码份数 | 运行时开销 | 适合场景 |
|---|---|---|---|---|
模板(template<class T>) | 编译期 | 每种类型一份 | 无(可内联) | 类型已知、追求零开销 |
继承多态(Base*) | 运行时(基类接口) | 一份 | 一次虚调用 | 类型都继承自同一基类 |
| 类型擦除(any/function) | 运行时 | 一份接口 + 每类型一个小壳 | 一次虚调用 + 一次堆分配 | 类型未知、但要统一接口 |
模板的「代码膨胀」来自:每个实例化都会把整个函数体复制一份。类型擦除的聪明之处在于,它把「必须随类型变化」的那部分压进一个极小的模板壳(Holder<T> / Invoker<F>),而「不变的大头」(接口、管理逻辑)编译成一份非模板代码。代价是每次访问多一次虚调用、对象多一次堆分配。所以类型擦除不是「免费午餐」,它是在「灵活」和「零开销」之间取了个平衡。
5. 完整示例:异构容器 + 可调用对象表
把 any 和 function 串起来:一个 vector<Any> 装异构数据按类型分派,一个 vector<Function<int(int,int)>> 装同签名不同实现的算子:
// erasure_full.cpp — 编译: g++ -std=c++17 -Wall -O2 erasure_full.cpp -o efull
#include <cstdio>
#include <memory>
#include <string>
#include <type_traits>
#include <typeinfo>
#include <utility>
#include <vector>
class Any {
struct Base {
virtual ~Base() = default;
virtual const std::type_info& type() const = 0;
virtual std::unique_ptr<Base> clone() const = 0;
};
template <typename T>
struct Holder : Base {
T value;
explicit Holder(T v) : value(std::move(v)) {}
const std::type_info& type() const override { return typeid(T); }
std::unique_ptr<Base> clone() const override { return std::make_unique<Holder<T>>(value); }
};
std::unique_ptr<Base> ptr_;
public:
Any() = default;
Any(const Any& other) : ptr_(other.ptr_ ? other.ptr_->clone() : nullptr) {}
Any(Any&&) noexcept = default;
template <typename T>
Any(T v) : ptr_(std::make_unique<Holder<T>>(std::move(v))) {}
const std::type_info& type() const { return ptr_ ? ptr_->type() : typeid(void); }
template <typename T>
T& cast() {
if (!ptr_ || ptr_->type() != typeid(T)) throw std::bad_cast();
return static_cast<Holder<T>*>(ptr_.get())->value;
}
};
template <typename Signature> class Function;
template <typename R, typename... Args>
class Function<R(Args...)> {
struct InvokerBase {
virtual ~InvokerBase() = default;
virtual R call(Args... args) = 0;
virtual std::unique_ptr<InvokerBase> clone() const = 0;
};
template <typename F>
struct Invoker : InvokerBase {
F f;
explicit Invoker(F f) : f(std::move(f)) {}
R call(Args... args) override {
if constexpr (std::is_void_v<R>) { f(args...); }
else { return f(args...); }
}
std::unique_ptr<InvokerBase> clone() const override { return std::make_unique<Invoker<F>>(f); }
};
std::unique_ptr<InvokerBase> inv_;
public:
Function() = default;
Function(const Function& other) : inv_(other.inv_ ? other.inv_->clone() : nullptr) {}
Function(Function&&) noexcept = default;
template <typename F>
Function(F f) : inv_(std::make_unique<Invoker<F>>(std::move(f))) {}
R operator()(Args... args) { return inv_->call(args...); }
};
int main() {
std::vector<Any> items; // 异构数据:同一个容器,装三种类型
items.emplace_back(42);
items.emplace_back(std::string("hello"));
items.emplace_back(3.14);
for (auto& item : items) {
if (item.type() == typeid(int)) {
std::printf("int = %d\n", item.cast<int>());
} else if (item.type() == typeid(std::string)) {
std::printf("string = %s\n", item.cast<std::string>().c_str());
} else if (item.type() == typeid(double)) {
std::printf("double = %.2f\n", item.cast<double>());
}
}
std::vector<Function<int(int, int)>> ops; // 同签名不同实现:一个容器装三种算法
ops.emplace_back([](int a, int b) { return a + b; });
ops.emplace_back([](int a, int b) { return a - b; });
ops.emplace_back([](int a, int b) { return a * b; });
for (auto& op : ops) {
std::printf("op(6,3) = %d\n", op(6, 3));
}
}
int = 42
string = hello
double = 3.14
op(6,3) = 9
op(6,3) = 3
op(6,3) = 18
同一个 vector<Any> 装下了 int、std::string、double 三种毫无继承关系的类型,靠 typeid 分派;同一个 vector<Function<int(int,int)>> 装下了三个不同的 lambda,靠 invoker 跳板统一调用。这正是类型擦除的价值:把「类型未知」的灵活性,和「统一接口」的工程性,同时拿到了手。代价只是每次访问多一次虚调用、一次堆分配。
6. 延伸阅读
- std::any — cppreference —— 类型擦除容器的标准实现,这篇手写
Any的对照 - std::function — cppreference —— 可调用对象包装器,这篇手写
Function的对照 - Type erasure(类型擦除)— cppreference —— 这一机制的语言层面说明
- std::type_info — cppreference ——
typeid返回的类型,any取值校验靠它 - Compiler Explorer — godbolt.org —— 把手写
Function贴进去,看operator()这条链路上虚调用和模板实例各编译成了什么
本知识库内的相关篇目:
- 《类型列表与编译期算法:手写 typelist 的 map/filter》 —— 编译期想对「一组类型」做运算(取第 N 个、映射成指针、筛选整型)
- 《表达式模板:Eigen 式延迟求值,把 a+b+c 压成一次遍历》 —— 表达式模板(Expression Templates)让 a+b+c 不再生成中间临时对象
- 《tag dispatch 与重载偏序实战:按迭代器类别派发算法》 —— tag dispatch(标签派发)用「空类型」当标签
7. 一句话总结
类型擦除 = 非模板接口(Base / InvokerBase)+ 模板派生壳(Holder<T> / Invoker<F>)+ 虚函数跳板(type() / call()),把具体类型藏进虚表、取用时再核对 —— any 用 typeid 校验后 static_cast 取值、取错抛 bad_cast;function 用 invoker 虚调用把 operator() 转发给任意 lambda;这套机制把「必须随类型变化」的部分压进极小模板壳、不变的大头编译成一份,从而在「类型未知」和「代码不膨胀」之间取到平衡,代价是每次访问一次虚调用加一次堆分配。
256



被折叠的 条评论
为什么被折叠?



