news 2026/10/15 6:37:43

C++类型擦除实践:std::any原理、any_cast用法与性能深度剖析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++类型擦除实践:std::any原理、any_cast用法与性能深度剖析

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,改用模板或访问者模式
只是需要把任意值存起来,不会反复 castany 很合适
需要存储可调用对象用 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 一直抛,到底哪里不对

排查顺序固定三条,亲测有效:

  1. 看类型是否衰减。字符串字面量被存成 const char*,数组会衰减成指针,先把存的类型和取的 typeid 打印出来对一遍;
  2. 看引用和值。会不会存的是std::reference_wrapper<T>,但你用any_cast<T>去取?
  3. 看继承。基类引用是拿不到的,存进去的必须是精确类型。

我踩过一次最隐蔽的坑:用结构化绑定遍历一个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 上场。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 6:37:30

PS5远程游玩实战:从局域网到外网的串流搭建与调优

每次出差或躺在卧室不想动的时候&#xff0c;我都会盯着客厅那台PS5发呆。游戏机本身没问题&#xff0c;问题在于它被固定在一个位置&#xff0c;而我想在不同房间、不同设备上随时接着玩。这个需求听起来不算刚需&#xff0c;可真当你打了一半流程被迫存档关机的次数多了&…

作者头像 李华
网站建设 2026/10/15 6:37:30

MTK SoC平台开发实战:启动链路、编译环境与刷机避坑指南

简介&#xff1a;这是一份源自2024年国际固态电路会议&#xff08;ISSCC&#xff09;的官方论文&#xff0c;聚焦联发科技基于4纳米工艺打造的3.4GHz Tri-Gear全乱序ARMv9.2 CPU子系统&#xff0c;并说明其在5G移动SoC中的集成方案。内容适合芯片设计、移动处理器架构方向的工程…

作者头像 李华
网站建设 2026/10/15 6:37:03

基于大数据的重庆旅游推荐与可视化平台开题报告核心要点解析

一个"基于大数据的重庆旅游推荐与可视化平台"开题报告&#xff0c;最容易犯的错就是把"推荐系统"和"可视化"当成两个独立模块来写。实际上&#xff0c;推荐算法需要数据支撑&#xff0c;可视化要把推荐理由说清楚&#xff0c;数据采集则决定了这…

作者头像 李华
网站建设 2026/10/15 6:36:42

Context-Mode实战:从多轮对话一致性到上下文管理的全链路设计

我们在接手一个跨部门协作的智能问答项目时&#xff0c;遇到过一类非常诡异的问题&#xff1a;同一个问题&#xff0c;上午问还答得头头是道&#xff0c;下午问就开始胡言乱语&#xff1b;换个人问&#xff0c;结果又不一样。一开始我们以为是模型版本更新导致的&#xff0c;后…

作者头像 李华
网站建设 2026/10/15 6:36:39

airllm:用4-bit量化与分页缓存,在低显存设备上跑大模型推理

airllm 这个项目名&#xff0c;我盯了很久。几个信息源交叉看下来&#xff0c;核心思路基本清晰&#xff1a;在无 GPU 或只有普通显卡的环境里&#xff0c;把 4-bit 量化的大语言模型跑起来&#xff0c;实现本地对话和推理。它不靠昂贵的显存堆叠&#xff0c;而是把目光放在 CP…

作者头像 李华
网站建设 2026/10/15 6:35:52

AirLLM实战:消费级显卡跑70B大模型的单层加载推理优化

AirLLM这个名字最近在开源社区刷得不少。它是一个专注大语言模型推理的轻量级框架&#xff0c;和主流动辄几十万行代码、依赖重型依赖的推理引擎不同&#xff0c;AirLLM走的是极简路线——核心思路一句话就能说清楚&#xff1a;让大模型一层一层轮流加载到显存里计算&#xff0…

作者头像 李华