C++ 里有这么一类需求:你写了一套框架,希望调用方把“任意一种类型”的数据交给你,你再原样还回去,中间不关心它是什么。存倒是好存,关键是取的时候还能知道它原来是什么,而且生命周期不需要调用方操心。这就是类型擦除的典型场景,而 C++17 进了标准库的 std::any,正是为这种场景量身定做的工具。这篇笔记我会从实现原理往上拆,再拿几个真实业务场景把 std::any 的完整玩法串一遍,最后把 any_cast 的坑、性能开销和一些排查经验都聊透。适合两类读者:一类是把 std::any 当普通工具用、想避开雷区的人;另一类是想理解类型擦除本质、甚至打算自己实现类似容器的高级玩家。
1. 类型擦除到底在解决什么问题
1.1 类型擦除不是“把类型丢掉”
很多人第一次听说“类型擦除”这个说法,第一反应是:把类型信息丢掉,那和 void* 有什么区别?其实恰恰相反,类型擦除是在“接口层面”把类型隐藏起来,在“实现层面”类型信息一直都在。你可以把它理解成快递柜:到柜口的接口只有一个“存取”动作,柜子内部其实记得每个格子里放的是什么,只是你从外面看不到。std::any 内部同样保存着类型的身份信息,取回时还要做严格校验,而不是像 void* 那样套个强转就完事。
我见过不少同事第一次用 std::any 时很惊讶:“哎,它居然能知道里面存的是 int 还是 string?”能,这是类型擦除的基本盘——值本身和值的类型信息是打包在一起的,任你存入、拷贝、移动、析构,这套信息始终跟着走。搞懂这一点,后面很多问题其实都不用问。
1.2 为什么 void* 不是答案
在 C++17 之前,想要塞任意类型,最粗暴的方案就是 void*。但它有几个硬伤,随便一个在真实项目里都能惹事:
- 类型信息被彻底丢掉,取出时只能靠调用方自己记住“我当时塞的是什么”,一旦记错,强转就是未定义行为;
- 对象生命周期没人管,塞进去的是堆对象,谁来 delete?是调用方还是框架?约定一乱就是泄漏或悬垂;
- 值语义支持极差,想塞个 int、塞个 struct 都得先 new 一个堆对象出来,临时对象根本没法直接存。
我举个例子,一个简化版的事件发送器:
struct Event { int id; void* payload; }; // 发布端:把数据强转成 void* 塞进去 int code = 42; send(Event{1, &code}); // 订阅端:如果忘掉类型,只能靠注释和约定 void* p = receive().payload; // 约定破坏一次,就是一次 UB这套代码在小型 Demo 里能跑,但一旦项目规模上来,约定靠人记,迟早翻车。std::any 把“类型校验”和“生命周期管理”这两件事替你做了,这才是它存在的最直接理由。
1.3 三兄弟的分工:variant、function、any
类型擦除并不只有 std::any 一个解。C++17 同时给了你 std::variant 和 std::function,三者解决的问题有明确边界,我经常看到有人用错,顺手做个对比:
| 工具 | 类型集合 | 类型确定时机 | 典型场景 |
|---|---|---|---|
| std::variant | 编译期确定的封闭集合 | 编译期 | 有限状态、错误类型、性能敏感分支 |
| std::function | 函数签名固定,具体可调用对象被擦除 | 编译期擦除签名 | 回调、策略注入、事件处理 |
| std::any | 任意值类型(满足可拷贝即可) | 运行期 | 异构数据表、插件或脚本边界、动态载荷 |
三者关系可以这么记:std::variant 是“编译期就知道可能有哪些类型”的封闭世界;std::function 是“只保留调用能力、丢掉其他一切”的弱化版擦除;而 std::any 是“什么值都能装、用的时候再验明正身”的最彻底擦除。选型时如果类型集合是封闭的,优先 variant,性能和可读性都更好;只有当类型集合完全开放、编译期根本无法穷举时,才轮到 any 上场。
2. std::any 的实现原理:一个手工打造的动态分发表
2.1 一个 any 对象内部到底装了什么
std::any 表面上是一个“能装任意类型”的对象,内部结构其实非常克制。看主流的实现思路:一个 std::any 对象由两部分组成——一块内嵌的小缓冲区,加一个函数指针表(常被称为 manager)。
所谓 manager,本质上就是你自己手写的一张 vtable。每个被存入 any 的具体类型 T,都会触发一个专门针对 T 的模板实例化,这个实例提供一组静态操作:
- 返回类型信息(typeid(T));
- 拷贝(从源对象构造一份新的 T);
- 移动(把源对象的 T 挪走);
- 析构(销毁缓冲区或堆内存里的 T)。
写个原理级的示意(不是标准库源码,只是帮你理解思路):
template <typename T> struct AnyOps { static const std::type_info& type() noexcept { return typeid(T); } static void clone(void* dst, const void* src) { new (dst) T(*static_cast<const T*>(src)); } static void move_construct(void* dst, void* src) { new (dst) T(std::move(*static_cast<T*>(src))); } static void destroy(void* p) { static_cast<T*>(p)->~T(); } };当你写std::any a = 42;时,编译器实际上做的是:把AnyOps<int>的函数指针和 int 对象本体放到 a 内部。取出的时候,any_cast 先比对typeid(int)是否一致,一致才把指针重新解释回int*。整个过程没有虚函数,也没有继承体系,机制和你自己写函数指针表是一回事,只是标准库替你把各种边界情况处理完了。
2.2 小对象优化:能内嵌就内嵌
和 std::string 的短字符串优化一个思路,std::any 的实现通常也会做“小对象优化”。如果 T 的尺寸和内存对齐不超过内部缓冲区(常见实现里也就是一两个字宽的量级),对象就直接构造在这块内嵌缓冲区上,不触发堆分配;只有塞不进的对象才走 new。
这句“能内嵌就内嵌”看着轻描淡写,实际是 std::any 性能的关键。塞一个 int、一个指针、一个 double,大多数情况都是零堆分配;而堆分配这个动作,才是 std::any 在热路径上最大的敌人。选型之前先掂量一下:你大多数时候存的是“小对象”还是“大对象”?如果业务里清一色的大字符串、大 vector,那无论实现怎么优化,都躲不开分配。
2.3 拷贝和移动的语义设计
std::any 的拷贝构造要求容器里的类型可拷贝,这也是标准规定的硬门槛:非 CopyConstructible 的类型,比如 unique_ptr 这种移动型资源,就进不了 std::any。原因很好理解——any 本身是值语义容器,拷贝一个本就不可拷贝的东西,语义上说不通。
移动则有一个容易被忽略的细节:std::any 的移动构造是 noexcept 的。这意味着实现要保证“把一个 any 挪到另一个 any”这个动作,不能靠可能抛异常的 T 移动构造去完成。常见的做法是:对移动构造不抛异常的内嵌类型,走真正的 move;对无法满足条件的类型,实现会退化处理,比如拷贝,或者干脆放到堆上,移动时只搬指针。所以一个经验是:如果你自定义的类型既需要塞进 any、又在意性能,尽量把移动构造写成 noexcept,这样各实现都能给你最优路径。
2.4 emplace 与类型衰减
std::any 提供emplace<T>(args...),可以在容器内部原位构造对象,少一次临时对象的拷贝或移动。这个接口配合小对象优化很舒服:构造直接发生在内嵌缓冲区上,没有额外搬运。
另外一个容易踩的坑是类型衰减。std::any a = "hello";存进去的不是const char[6],而是const char*。同理,int x = 0; std::any a = x;存的是 int 的副本,不是引用。如果你真想存引用语义,得显式包std::reference_wrapper。搞清楚“any 存的是 value decay 之后的类型”,排查问题能省一大半力气。
3. 实战:用 std::any 搭一个运行时多态系统
3.1 场景一:异构配置表 / 属性面板
一个非常常见的需求:配置表的每一项可以是 int、double、string、bool,甚至是一个小结构体。用模板做死类型做不到,因为配置项来源是配置文件或脚本,类型在运行期才知道。
class PropertyBag { public: template <typename T> void set(const std::string& key, T&& value) { store_[key] = std::forward<T>(value); } template <typename T> const T* get(const std::string& key) const { auto it = store_.find(key); if (it == store_.end()) { return nullptr; } return std::any_cast<T>(&it->second); } bool contains(const std::string& key) const { return store_.count(key) != 0; } private: std::unordered_map<std::string, std::any> store_; };使用:
PropertyBag cfg; cfg.set("volume", 0.8); cfg.set("name", std::string("lobby")); cfg.set("players", std::vector<int>{1, 2, 3}); if (const double* v = cfg.get<double>("volume")) { // 拿到 0.8 }几个值得注意的点:get 返回的是指针形态的 any_cast,类型不匹配时返回 nullptr,不抛异常——配置读取的失败通常是“缺配置”而非“类型错误”,用可空指针判断比 try/catch 清晰得多。另外注意 set 会做一次拷贝或移动,大对象能 move 就 move,别传 const 左值进来白白多一次深拷贝。
3.2 场景二:事件总线里传递任意载荷
事件系统是 std::any 最典型的舞台。订阅者订阅某种事件类型,发布者发布事件时带着任意类型的载荷,中间人只负责转发,不解读内容。
一个极简版本:
using Handler = std::function<void(const std::any&)>; class EventBus { public: template <typename E> void subscribe(std::function<void(const E&)> h) { listeners_[std::type_index(typeid(E))].emplace_back( [h = std::move(h)](const std::any& a) { h(std::any_cast<const E&>(a)); }); } template <typename E> void publish(const E& e) { auto it = listeners_.find(std::type_index(typeid(E))); if (it == listeners_.end()) { return; } std::any payload(e); // 统一走 any 通道 for (auto& h : it->second) { h(payload); // 订阅者自行拿到期望类型 } } private: std::unordered_map<std::type_index, std::vector<Handler>> listeners_; };发布端根本不关心订阅者怎么解读载荷,订阅端拿到 any 后用std::any_cast<const E&>取引用,不匹配直接抛 bad_any_cast。这种“只识别类型、不解读内容”的松耦合,是事件系统最舒服的形态。
这里有一个性能提醒:publish 里std::any payload(e)是一次拷贝。对高频、大体积事件,这个拷贝可能成为瓶颈。两个思路:一个是改用std::reference_wrapper<const E>存引用,一个是发布端主动 move 一个独占事件对象。别等到压测才发现这里多了一次深拷贝。
3.3 场景三:把 std::any 塞进回调,实现返回值擦除
如果每个任务函数返回的类型都不一样,而调度器想统一管理“任务对象”,std::any 可以配合 std::function 实现返回值层面的擦除:
using Task = std::function<std::any()>; template <typename F> Task make_task(F f) { return [f = std::move(f)]() -> std::any { return f(); }; }然后在任务队列里存的全是 Task,跑完拿到一个 std::any,再按任务类型 any_cast 出结果。这个模式在异步任务框架、脚本回调里非常常见:框架层只认识“任务”和“结果”两个抽象,具体类型由使用者自己关掉。编译期做不到这种抽象,因为任务类型是脚本或插件在运行期才注册进来的。
3.4 场景四:手写一个 visit_any,绕开链式 if-else
any 的缺点是“取具体类型必须 cast”,类型一多就会出现一串 if-else。可以写一个小工具收敛一下:
template <class F, class... Ts> bool visit_any(std::any& a, F&& f) { bool handled = false; (void)std::initializer_list<int>{ (a.type() == typeid(Ts) ? (f(std::any_cast<Ts&>(a)), handled = true, 0) : 0)... }; return handled; }用法:
std::any v = 42; visit_any<int, double, std::string>(v, [](auto& value) { std::cout << value << '\n'; });这里有一个重要取舍:visit_any 做的是运行期的 typeid 比较,本质上就是一连串 if,只是把 if 藏到了包展开里。它不会有传统访问者模式那种编译期分派的性能,但换来了“类型集合可扩展”的自由。如果你的类型集合是封闭的,老老实实用 std::variant 的 visit,那个才是真正的编译期分派,别拿 any 硬碰硬。
4. any_cast 的原理与正确用法
4.1 一种 cast、多副面孔
std::any_cast 的复杂性在于它有好几个重载,语义各不相同。我做了个速查表:
| 调用形式 | 含义 | 类型不符/空 any 时 |
|---|---|---|
any_cast<T>(a) | 返回 T 值(拷贝一份) | 抛 bad_any_cast |
any_cast<T&>(a) | 返回内部对象引用,可原地修改 | 抛 bad_any_cast |
any_cast<const T&>(a) | 只读引用 | 抛 bad_any_cast |
any_cast<T>(std::move(a)) | 把内部对象移动出来 | 抛 bad_any_cast |
any_cast<T>(&a) | 返回指向内部对象的 T* | 返回 nullptr |
any_cast<T>(const any*) | 返回指向内部对象的 const T* | 返回 nullptr |
先区分“值语义”和“引用语义”两个阵营。值语义多数情况下没必要用,因为 std::any 内部往往已经有一层拷贝,再拷贝一层更亏;引用语义才是主流用法——取出引用,原地读、原地改。
any_cast<T&>和any_cast<const T&>的选择,取决于 any 本身是 const 还是非 const。把引用形式记成“取内部对象的一个窗口”就没错了。
还有一个容易让人绕晕的指针形态:std::any_cast<T>(&a)返回的是 T*,不是 T 本身。如果 any 里存的是std::string,用any_cast<std::string*>(&a)得到的是std::string**,注意这层指针嵌套。
4.2 匹配是绝对匹配:没有转换、没有继承
这是 any_cast 最容易被误解的一点:它做的是精确的类型匹配,不参与隐式转换,也不认继承关系。
- 存的是 int,取
any_cast<long>一定是 bad_any_cast,即使 int 到 long 可以隐式转换; - 存的是派生类对象,取基类引用同样失败,任何虚继承的规矩在这里都不适用;
- 存的是
std::string,取any_cast<const char*>失败,string 到 const char* 的转换不会被触发。
我见过一个真实场景:代码里存了个枚举,取的时候想当然用了any_cast<int>,结果每次运行都抛异常,半天找不到原因。原因就是枚举的 typeid 和 int 的 typeid 是两个完全不同的类型。记住一句话:存的时候是什么类型,取的时候就得原封不动要什么类型。
4.3 空 any 和 bad_any_cast
空 any(默认构造,或 reset 之后的)调用any_cast<T>会抛 bad_any_cast;但指针形态不会抛,它会返回 nullptr。所以“可能为空”的业务场景,优先用指针形态,避免异常开销:
std::any a; // 抛异常 // auto x = std::any_cast<int>(a); // 不抛,返回 nullptr int* p = std::any_cast<int>(&a); if (p == nullptr) { // 空 any 或类型不符 }还有一个小知识:std::any_cast<void>(&a)专门用来检查“是否为空”,不空返回一个有效指针,空返回 nullptr。这比a.has_value()多了一种底层视角,不过日常用不上,知道有它即可。
5. 性能开销与选型建议
5.1 开销从哪里来
std::any 不是零成本抽象。一次存储加一次取出的完整路径上,开销大致来自三块:
- 构造时的类型分派:模板实例化和函数指针表的建立,编译期搞定,运行期不花钱;
- 存储:小对象走内嵌缓冲区,零堆分配,大对象走 new/delete,这一块是最大变量;
- 取回:任何 any_cast 都要做一次类型比对,现实中这个比较通常是两个指针的比对,非常快,但错误的类型猜测会触发异常和栈展开,那就贵了。
把 std::variant 拿来对比会更直观:variant 的类型集合编译期就知道,visit 生成的是跳转表式的分派,一层间接跳转搞定;any 则要把类型识别拖到运行期,而且每次 cast 都带着异常路径的成本预期。所以凡是可以写死类型集合的场景,别上 any。
5.2 什么时候该用、什么时候该绕道
| 场景 | 建议 |
|---|---|
| 类型集合编译期可穷举 | 用 std::variant,别用 any |
| 类型集合完全开放(插件、脚本、配置) | any 是合理选择 |
| 超高频率、性能敏感路径 | 绕开 any,改用模板或访问者模式 |
| 只是需要把任意值存起来,不会反复 cast | any 很合适 |
| 需要存储可调用对象 | 用 std::function,不是 any |
一句话:std::any 的价值在于“边界”。它适合框架层收留来自各种来源的数据;一旦进入高频处理的内层,就应该尽早把类型还原出来,而不是让 any 陪着你跑完全程。
5.3 四个立竿见影的降本技巧
- 优先用引用形态取出:
any_cast<T&>而不是any_cast<T>,减少一次拷贝; - 大对象进场就 move:
store[k] = std::move(obj),配合 emplace 可以做到一次构造; - 别反复 cast:连续代码里如果要多次读同一个字段,先把引用取出来放到局部变量;
- 使用
std::reference_wrapper避免深拷贝:对超大、只读的对象,直接用std::ref(obj)包一层存进去,取出时 cast 成std::reference_wrapper<const T>,代价瞬间降下来。
这些都是我在实际项目里试过、验证过有效的做法,尤其是 reference_wrapper,处理“事件载荷是只读大对象”的场景非常管用。
6. 常见问题与排查技巧实录
6.1 bad_any_cast 一直抛,到底哪里不对
排查顺序固定三条,亲测有效:
- 看类型是否衰减。字符串字面量被存成 const char*,数组会衰减成指针,先把存的类型和取的 typeid 打印出来对一遍;
- 看引用和值。会不会存的是
std::reference_wrapper<T>,但你用any_cast<T>去取? - 看继承。基类引用是拿不到的,存进去的必须是精确类型。
我踩过一次最隐蔽的坑:用结构化绑定遍历一个unordered_map<string, any>,里面存的其实是reference_wrapper,结果所有 any_cast 全炸。从那以后我养成了一个习惯——调试期先把a.type().name()打出来看看。
6.2 赋值不等于修改内部对象
std::any a = 10; std::any_cast<int&>(a) = 20; // 改内部值,a 里是 20 a = 30; // 替换整个对象:销毁旧的,构造新的有人会把a = 30理解成“给里面的 int 赋 30”,错了,any 的 operator= 语义是“整体替换”。接口层面结果一致,但代价和风险完全不同:如果你事先拿到了int&引用,a = 30之后那个引用立刻悬垂,因为旧对象已经被销毁重建。要原地修改,务必通过 any_cast 出来的引用去赋值。
6.3 大对象疯狂分配,内存原地爆炸
高频往 any 里塞大字符串、大容器的场景,任何一次存储就是一次深拷贝。这不是 bug,是 std::any 的设计使然。解决思路两个:一是 move 进去,让源对象把内存转交;二是存引用包装。如果项目里到处是这种大体积拷贝,那基本可以断定是选型错了——往容器里塞一堆 string 的 any,不如老老实实定义结构体。
6.4 多线程共享同一个 any
std::any 和标准库其他容器一样,不提供并发安全。多个线程同时调 any_cast 读、另一个线程在写,属于数据竞争,是未定义行为,不是“可能读不到新值”这么温和。解决方式没有捷径,外部加锁或原子化访问,别指望 any 内部有什么魔法。另外,把 any 作为全局配置静态变量存起来,初始化顺序也是常见事故源,尽量封装成 RAII 对象再暴露。
6.5 常见问题速查表
| 现象 | 可能原因 | 解决参考 |
|---|---|---|
| bad_any_cast | 类型衰减、引用包装、继承 | 打印 type().name() 比对 |
| 修改不生效 | 用了值语义 cast | 改用 any_cast<T&> |
| 赋值后旧引用悬垂 | operator= 整体替换 | 原地修改走引用 |
| 内存涨得快 | 大对象反复深拷贝 | move 或 reference_wrapper |
| 崩溃在析构 | 存了裸指针 | 别用裸指针,用智能指针 |
| type() 变化 | 拷贝或赋值了另一个 any | 确认初始化/赋值语义 |
这里再给一条独家建议:如果你被迫在大量业务代码里写 any_cast,说明边界层的类型已经扩散到内核了。合理的做法是尽力把 any 限制在“入口/出口”两层,内部一旦确认类型就立刻转成强类型对象,千万别让 any 渗进你所有的数据结构里——那只会让整个代码库的强类型优势全部退化。
我个人在实际项目里的体会是,std::any 是一件“用对了很优雅、用错了很难受”的工具。它的优雅在于:类型擦除这套机制能让你把“任意值”和“类型身份”作为一个整体继续流动,边界接口不再被模板和 void* 撕裂;它的难受在于:一旦类型集合能被编译期穷举、一旦出现性能敏感路径、一旦你忍不住把 any 塞进所有角落,你就会付出两倍代价去偿还。
所以我最后再分享一个个人习惯:写任何使用 std::any 的接口前,先问自己三个问题——类型集合是不是确定的?取用频率高不高?载荷会不会很大?如果三个答案不能让我满意,我会回头看看 std::variant 或者模板方法能不能替掉它。能替掉的,坚决替;只有真正开放、类型无法在编译期穷举的运行期世界,才值得让 std::any 上场。