std::any 不是 void*:浅谈类型擦除到底擦掉了什么
std::any 不是 void*浅谈类型擦除到底擦掉了什么这个仓库已经开源现代化 CC11/14/17/20从基础到进阶的系统教程都在这里力争做一条完备的现代 C 学习路径欢迎各位大佬前来参观喜欢的话点个⭐Github 一键直达: git clone https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP看看超酷的新网站https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeModernCPP/引言这不就是 void* 套层皮吗我相信不少人第一次接触std::any的时候反应都一样这不就是void*套了层皮吗有什么用我当初第一反应也是这么想的还吐槽标准库怎么正事不做。后来在做一个插件系统的配置模块时我被现实打脸了——void*把类型信息全丢了你从里面取值的时候完全靠猜而「靠猜」在工程里就是个定时炸弹一旦你猜错了类型、或者外部多塞了一点「我以为你知道」的额外信息状态就对不上账了。而std::any同样能装任何类型但它记得自己装的是什么。你用错误的类型去取值它直接抛bad_any_cast而不是默默塞给你一段内存垃圾。std::anyC17 引入的核心能力是「存储任意类型的值并在需要时安全地取回」。它靠一种叫类型擦除type erasure的技术实现——存储时隐藏具体类型信息取出时通过类型检查恢复安全性。这一篇我们就来深入理解any的机制、适用场景以及为什么大多数时候你其实应该用variant而不是any。从 void* 的痛点说起any 的设计动机C 是一门静态类型语言编译器在编译期就必须知道每个变量和表达式的类型。但有时候你确实需要「在运行时才知道存什么类型」的容器。经典场景包括插件系统的属性映射——不同插件可能注册不同类型的属性整数、字符串、自定义结构体。脚本引擎的变量绑定——脚本里的变量在运行时可以是任何类型。序列化 / 反序列化框架——解析 JSON 或 XML 时某些字段的类型只有看到具体数据后才能确定。在 C 语言里这类需求通常用void*满足。但void*是完全类型不安全的——你把一个int*转成void*存起来取出时转成double*来用编译器不会有任何警告运行时你会得到一堆垃圾数据。std::any的目标就是提供和void*一样的「存什么类型都行」的灵活性同时保证取值时的类型安全。any 的基本用法构造、检查与取值构造与赋值std::any可以持有任何可拷贝构造的类型的值#includeany#includestring#includeiostream#includevectorintmain(){std::any a42;// 持有 inta3.14;// 现在持有 doubleastd::string(hello);// 现在持有 std::string// 空状态std::any empty;// 不持有任何值std::any also_emptystd::any{};// 同上// 就地构造std::anyv(std::in_place_typestd::vectorint,10,42);// 构造一个包含 10 个 42 的 vectorint}和variant不同any的备选类型列表是完全开放的——你可以存任何类型不需要在声明时枚举出来。这正是它的灵活性所在也是它性能不如variant的根源。检查与取值std::any a42;// 检查是否有值if(a.has_value()){std::couthas value\n;}// 获取类型信息std::couttype: a.type().name()\n;// 实现相关如 i 或 int// 取值std::any_casttry{intvalstd::any_castint(a);// OK返回 42std::coutvalue: val\n;// double bad std::any_castdouble(a); // 抛出 std::bad_any_cast}catch(conststd::bad_any_caste){std::coutwrong type: e.what()\n;}// 指针版本不抛异常返回 nullptrint*ptrstd::any_castint(a);// OKptr 不为空double*badstd::any_castdouble(a);// bad 为 nullptrstd::any_cast有两种重载传引用的版本在类型不匹配时抛std::bad_any_cast异常传指针的版本在类型不匹配时返回nullptr。如果你需要频繁检查类型指针版本更高效不涉及异常开销。踩坑预警std::any_castint(a)返回的是值的拷贝不是引用。如果你想修改any内部的值得用std::any_castint(a)来获取引用std::any a42;std::any_castint(a)100;// 修改 any 内部的值为 100// int copy std::any_castint(a); copy 200; // 只修改了拷贝any 内部没变类型擦除与 SBOany 内部到底长什么样std::any的实现基于类型擦除技术。简单说any内部维护一个「概念接口」——它知道如何销毁所持有的值、如何复制它、如何获取它的type_info——但不知道值的具体类型。这些操作通过函数指针或虚函数来分派。当你执行std::any a 42;时any在内部创建一个「包装器」对象这个包装器持有int类型的值并提供了上述操作的实现。any本身只保存一个指向这个包装器的指针或引用。为了优化小对象的性能主流标准库实现都采用了Small Buffer OptimizationSBO。当所持有的类型足够小通常和std::string差不多大或更小时值直接存储在any对象内部的缓冲区中不需要堆分配只有当值超过 SBO 阈值时才会在堆上分配内存。std::coutsizeof(std::any): sizeof(std::any)\n;// 典型输出16 或 32取决于实现// 这包括了 SBO 缓冲区 类型信息指针 管理数据// 小对象栈上存储SBO 生效std::any small42;// 大对象堆上分配std::any largestd::vectorint(1000000,0);SBO 的存在意味着对int、double、小型结构体这些常见类型any的性能开销非常小——没有堆分配只是一次额外的间接寻址。但对于大型对象比如大vector、大string每次拷贝any都会触发一次堆分配和一次深拷贝这个开销不可忽略。any / variant / void* / union四个家伙怎么选这四种机制都能实现「存不同类型的值」但定位和适用场景截然不同。我们用一张表对比特性std::anystd::variantvoid*union类型安全运行时检查编译期检查无检查无检查备选类型任意固定列表任意固定列表生命周期管理自动自动手动手动堆分配可能SBO 外无取决于使用无visit支持无有无无内存开销中等最大备选类型 元数据一个指针最大成员类型查询type()any_castholds_alternative无法查询无法查询从这张表能看出如果你能在编译期枚举出所有可能的类型variant几乎总是比any更好的选择。variant提供编译期类型检查、没有堆分配、支持visit。只有在类型列表无法在编译期确定的情况下插件系统、脚本引擎等any才有不可替代的价值。void*和union在现代 C 中基本没有正当的使用理由了——any和variant分别覆盖了它们的适用场景而且更安全。性能特征什么时候 any 会变慢理解any的性能开销对正确使用它至关重要。构造 / 赋值开销对 SBO 范围内的类型通常不超过 32 字节左右构造和赋值涉及一次值拷贝和少量元数据设置基本和拷贝原始类型一样快对超过 SBO 阈值的类型会触发一次new和一次delete在替换值时。取值开销std::any_cast需要进行一次typeid比较检查存储的类型是否和请求的类型匹配然后是一次static_cast。这个开销非常小——就是一次指针比较加上一次类型信息查找。拷贝开销拷贝any会深拷贝它持有的值。对大对象这是一次完整的深拷贝。如果你需要避免这个开销可以考虑用std::any包裹std::shared_ptrT——这样拷贝any只是增加引用计数不会拷贝底层对象。// 避免大对象拷贝用 shared_ptr 包裹autobig_datastd::make_sharedstd::vectorint(1000000,0);std::any abig_data;// 拷贝 shared_ptr不拷贝 vectorautoretrievedstd::any_caststd::shared_ptrstd::vectorint(a);// retrieved 指向同一个 vector引用计数增加any 真正不可替代的场景动态配置系统当你需要一个键值映射而值可以是各种不同类型时any是个自然的选择#includeany#includestring#includeunordered_map#includeiostreamclassConfig{public:templatetypenameTvoidset(conststd::stringkey,T value){entries_[key]std::move(value);}templatetypenameTstd::optionalTget(conststd::stringkey)const{autoitentries_.find(key);if(itentries_.end())returnstd::nullopt;// 尝试获取正确类型的值constT*ptrstd::any_castT(it-second);if(!ptr)returnstd::nullopt;return*ptr;}boolhas(conststd::stringkey)const{returnentries_.count(key)0;}private:std::unordered_mapstd::string,std::anyentries_;};// 使用Config cfg;cfg.set(server_host,std::string(192.168.1.1));cfg.set(server_port,8080);cfg.set(verbose,true);cfg.set(max_retries,3);autohostcfg.getstd::string(server_host);// optionalstring 192.168.1.1autoportcfg.getint(server_port);// optionalint 8080autobadcfg.getdouble(server_host);// optionaldouble nullopt类型不匹配automissingcfg.getint(nonexistent);// optionalint nullopt键不存在这种「任意类型的属性字典」模式在游戏引擎、GUI 框架、插件系统中非常常见。any提供了足够的灵活性来存储不同类型的值同时通过any_cast保证了取值时的类型安全。属性字典 / 消息传递在消息传递或组件系统中实体Entity可能需要携带不同类型的属性。any可以用来实现一个通用的属性容器#includeany#includeunordered_map#includestring#includefunctional#includeiostreamclassEntity{public:templatetypenameTvoidset_attribute(conststd::stringname,T value){attrs_[name]std::move(value);}templatetypenameTstd::optionalTget_attribute(conststd::stringname)const{autoitattrs_.find(name);if(itattrs_.end())returnstd::nullopt;constT*ptrstd::any_castT(it-second);if(!ptr)returnstd::nullopt;return*ptr;}voidlist_attributes()const{for(constauto[name,value]:attrs_){std::cout name (type: value.type().name())\n;}}private:std::unordered_mapstd::string,std::anyattrs_;};// 使用Entity player;player.set_attribute(health,100);player.set_attribute(name,std::string(Alice));player.set_attribute(position,std::make_pair(3.0f,7.5f));autohpplayer.get_attributeint(health);// optionalint 100插件接口设计插件系统时宿主和插件之间的接口可能需要传递「宿主和插件各自定义的类型」的数据。由于双方的类型在编译期互不可见any可以作为一个中性的传递容器// 宿主定义usingPluginDatastd::any;classPluginHost{public:// 插件通过这个接口发送任意类型的数据给宿主virtualvoidon_plugin_data(conststd::stringkey,constPluginDatadata)0;};// 插件端classMyPlugin{public:voidsend_custom_data(PluginHosthost){// 插件可以发送任何类型的数据structCustomResult{intcode;std::string message;};host.on_plugin_data(result,CustomResult{0,success});}};手写 MiniAny三块骨头看穿类型擦除为了更深入地理解类型擦除的机制我们来手写一个极简版的any。这个实现远不如标准库完善但它能帮你理解any内部到底怎么工作。#includememory#includestdexcept#includetypeinfo#includeutilityclassMiniAny{public:MiniAny()default;// 从任意类型构造templatetypenameTMiniAny(T value):holder_(newHolderT(std::move(value))){}// 拷贝构造MiniAny(constMiniAnyother):holder_(other.holder_?other.holder_-clone():nullptr){}// 移动构造MiniAny(MiniAnyother)noexceptdefault;// 赋值MiniAnyoperator(MiniAny other)noexcept{swap(holder_,other.holder_);return*this;}boolhas_value()constnoexcept{returnholder_!nullptr;}conststd::type_infotype()constnoexcept{returnholder_?holder_-type():typeid(void);}// 内部概念接口structHolderBase{virtual~HolderBase()default;virtualconststd::type_infotype()constnoexcept0;virtualstd::unique_ptrHolderBaseclone()const0;};// 具体类型包装templatetypenameTstructHolder:HolderBase{T value;explicitHolder(T v):value(std::move(v)){}conststd::type_infotype()constnoexceptoverride{returntypeid(T);}std::unique_ptrHolderBaseclone()constoverride{returnstd::make_uniqueHolder(value);}};std::unique_ptrHolderBaseholder_;};// 类型安全的取值函数templatetypenameTTmini_any_cast(constMiniAnya){if(!a.has_value()){throwstd::runtime_error(bad any cast: empty);}if(a.type()!typeid(T)){throwstd::runtime_error(bad any cast: type mismatch);}// 向下转型安全因为已经验证了类型auto*holderdynamic_castMiniAny::HolderT*(a.holder_.get());returnholder-value;}这个简化实现揭示了any的三块核心骨头第一HolderBase是类型擦除的接口——它定义了「任何被存储的类型都必须支持的操作」获取类型信息、克隆自身但不暴露具体类型。第二HolderT是具体的类型包装——它继承HolderBase为每个具体类型提供实现。当你执行MiniAny a 42;时内部创建的是Holderint实例。第三mini_any_cast通过typeid比对来恢复类型安全——在取出值之前检查存储的类型是否和请求的类型一致。标准库的std::any比这个实现复杂得多有 SBO 优化避免小对象的堆分配有移动语义优化还有emplace等更灵活的构造方式。但核心思想是一模一样的。什么时候不该用 any虽然any很灵活但大多数时候它并不是最好的选择。下面是几个「不要用any」的场景类型集合已知且有限如果你知道值只可能是int、double、std::string中的某一个直接用variantint, double, std::string。variant提供编译期类型检查和visit性能也更好。只需要表达「有值或无值」用optionalT而不是any。optional更轻量、语义更明确。可以用模板解决如果你的函数需要接受不同类型的参数但不需要在运行时存储「不同类型的值」模板通常是更好的选择。模板在编译期就完成了类型分派没有任何运行时开销。可以用多态解决如果你有一组相关的类型它们共享某个接口虚函数可能比any更合适。虚函数提供了类型安全的接口而any则完全放弃了接口约束。笔者的一般原则是能用variant就不用any能用模板就不用运行时类型擦除。any是最后的手段——只有在所有静态方案都不适用的场景下才考虑。嵌入式视角资源受限环境下的 any在嵌入式系统中std::any通常不是首选工具。原因有三第一any的 SBO 缓冲区会占用额外的 RAM通常 16–32 字节这在 RAM 只有几十 KB 的 MCU 上是不可忽视的开销第二大对象会触发堆分配而很多嵌入式系统要么没有堆要么堆空间非常有限第三any_cast的类型检查涉及 RTTI运行时类型信息在某些嵌入式工具链中 RTTI 是被禁用的为了节省代码空间。如果你确实需要在嵌入式项目里用类似的「动态类型」功能更推荐的做法是用variantenum标签来实现一个受限版本——所有可能的类型在编译期就确定不需要 RTTI也没有堆分配。参考资源cppreference: std::anycppreference: std::any_castcppreference: std::bad_any_castArthur O’Dwyer: Back to Basics - Type Erasure (CppCon 2019)