std::any 不是 void*:浅谈类型擦除到底擦掉了什么
这个仓库已经开源!现代化 C++(C++11/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::any(C++17 引入)的核心能力是「存储任意类型的值,并在需要时安全地取回」。它靠一种叫类型擦除(type erasure)的技术实现——存储时隐藏具体类型信息,取出时通过类型检查恢复安全性。这一篇我们就来深入理解any的机制、适用场景,以及为什么大多数时候你其实应该用variant而不是any。
从 void* 的痛点说起:any 的设计动机
C++ 是一门静态类型语言,编译器在编译期就必须知道每个变量和表达式的类型。但有时候你确实需要「在运行时才知道存什么类型」的容器。经典场景包括:
- 插件系统的属性映射——不同插件可能注册不同类型的属性(整数、字符串、自定义结构体)。
- 脚本引擎的变量绑定——脚本里的变量在运行时可以是任何类型。
- 序列化 / 反序列化框架——解析 JSON 或 XML 时,某些字段的类型只有看到具体数据后才能确定。
在 C 语言里,这类需求通常用void*满足。但void*是完全类型不安全的——你把一个int*转成void*存起来,取出时转成double*来用,编译器不会有任何警告,运行时你会得到一堆垃圾数据。std::any的目标就是提供和void*一样的「存什么类型都行」的灵活性,同时保证取值时的类型安全。
any 的基本用法:构造、检查与取值
构造与赋值
std::any可以持有任何可拷贝构造的类型的值:
#include<any>#include<string>#include<iostream>#include<vector>intmain(){std::any a=42;// 持有 inta=3.14;// 现在持有 doublea=std::string("hello");// 现在持有 std::string// 空状态std::any empty;// 不持有任何值std::any also_empty=std::any{};// 同上// 就地构造std::anyv(std::in_place_type<std::vector<int>>,10,42);// 构造一个包含 10 个 42 的 vector<int>}和variant不同,any的备选类型列表是完全开放的——你可以存任何类型,不需要在声明时枚举出来。这正是它的灵活性所在,也是它性能不如variant的根源。
检查与取值
std::any a=42;// 检查是否有值if(a.has_value()){std::cout<<"has value\n";}// 获取类型信息std::cout<<"type: "<<a.type().name()<<"\n";// 实现相关(如 "i" 或 "int")// 取值:std::any_casttry{intval=std::any_cast<int>(a);// OK,返回 42std::cout<<"value: "<<val<<"\n";// double bad = std::any_cast<double>(a); // 抛出 std::bad_any_cast!}catch(conststd::bad_any_cast&e){std::cout<<"wrong type: "<<e.what()<<"\n";}// 指针版本:不抛异常,返回 nullptrint*ptr=std::any_cast<int>(&a);// OK,ptr 不为空double*bad=std::any_cast<double>(&a);// bad 为 nullptrstd::any_cast有两种重载:传引用的版本在类型不匹配时抛std::bad_any_cast异常;传指针的版本在类型不匹配时返回nullptr。如果你需要频繁检查类型,指针版本更高效(不涉及异常开销)。
踩坑预警:
std::any_cast<int>(a)返回的是值的拷贝,不是引用。如果你想修改any内部的值,得用std::any_cast<int&>(a)来获取引用:
std::any a=42;std::any_cast<int&>(a)=100;// 修改 any 内部的值为 100// int copy = std::any_cast<int>(a); copy = 200; // 只修改了拷贝,any 内部没变类型擦除与 SBO:any 内部到底长什么样
std::any的实现基于类型擦除技术。简单说,any内部维护一个「概念接口」——它知道如何销毁所持有的值、如何复制它、如何获取它的type_info——但不知道值的具体类型。这些操作通过函数指针或虚函数来分派。
当你执行std::any a = 42;时,any在内部创建一个「包装器」对象,这个包装器持有int类型的值,并提供了上述操作的实现。any本身只保存一个指向这个包装器的指针(或引用)。
为了优化小对象的性能,主流标准库实现都采用了Small Buffer Optimization(SBO)。当所持有的类型足够小(通常和std::string差不多大或更小)时,值直接存储在any对象内部的缓冲区中,不需要堆分配;只有当值超过 SBO 阈值时,才会在堆上分配内存。
std::cout<<"sizeof(std::any): "<<sizeof(std::any)<<"\n";// 典型输出:16 或 32(取决于实现)// 这包括了 SBO 缓冲区 + 类型信息指针 + 管理数据// 小对象:栈上存储(SBO 生效)std::any small=42;// 大对象:堆上分配std::any large=std::vector<int>(1000000,0);SBO 的存在意味着:对int、double、小型结构体这些常见类型,any的性能开销非常小——没有堆分配,只是一次额外的间接寻址。但对于大型对象(比如大vector、大string),每次拷贝any都会触发一次堆分配和一次深拷贝,这个开销不可忽略。
any / variant / void* / union:四个家伙怎么选
这四种机制都能实现「存不同类型的值」,但定位和适用场景截然不同。我们用一张表对比:
| 特性 | std::any | std::variant | void* | union |
|---|---|---|---|---|
| 类型安全 | 运行时检查 | 编译期检查 | 无检查 | 无检查 |
| 备选类型 | 任意 | 固定列表 | 任意 | 固定列表 |
| 生命周期管理 | 自动 | 自动 | 手动 | 手动 |
| 堆分配 | 可能(SBO 外) | 无 | 取决于使用 | 无 |
visit支持 | 无 | 有 | 无 | 无 |
| 内存开销 | 中等 | 最大备选类型 + 元数据 | 一个指针 | 最大成员 |
| 类型查询 | type()+any_cast | holds_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_ptr<T>——这样拷贝any只是增加引用计数,不会拷贝底层对象。
// 避免大对象拷贝:用 shared_ptr 包裹autobig_data=std::make_shared<std::vector<int>>(1000000,0);std::any a=big_data;// 拷贝 shared_ptr,不拷贝 vectorautoretrieved=std::any_cast<std::shared_ptr<std::vector<int>>>(a);// retrieved 指向同一个 vector,引用计数增加any 真正不可替代的场景
动态配置系统
当你需要一个键值映射,而值可以是各种不同类型时,any是个自然的选择:
#include<any>#include<string>#include<unordered_map>#include<iostream>classConfig{public:template<typenameT>voidset(conststd::string&key,T value){entries_[key]=std::move(value);}template<typenameT>std::optional<T>get(conststd::string&key)const{autoit=entries_.find(key);if(it==entries_.end())returnstd::nullopt;// 尝试获取正确类型的值constT*ptr=std::any_cast<T>(&it->second);if(!ptr)returnstd::nullopt;return*ptr;}boolhas(conststd::string&key)const{returnentries_.count(key)>0;}private:std::unordered_map<std::string,std::any>entries_;};// 使用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);autohost=cfg.get<std::string>("server_host");// optional<string> = "192.168.1.1"autoport=cfg.get<int>("server_port");// optional<int> = 8080autobad=cfg.get<double>("server_host");// optional<double> = nullopt(类型不匹配)automissing=cfg.get<int>("nonexistent");// optional<int> = nullopt(键不存在)这种「任意类型的属性字典」模式在游戏引擎、GUI 框架、插件系统中非常常见。any提供了足够的灵活性来存储不同类型的值,同时通过any_cast保证了取值时的类型安全。
属性字典 / 消息传递
在消息传递或组件系统中,实体(Entity)可能需要携带不同类型的属性。any可以用来实现一个通用的属性容器:
#include<any>#include<unordered_map>#include<string>#include<functional>#include<iostream>classEntity{public:template<typenameT>voidset_attribute(conststd::string&name,T value){attrs_[name]=std::move(value);}template<typenameT>std::optional<T>get_attribute(conststd::string&name)const{autoit=attrs_.find(name);if(it==attrs_.end())returnstd::nullopt;constT*ptr=std::any_cast<T>(&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_map<std::string,std::any>attrs_;};// 使用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));autohp=player.get_attribute<int>("health");// optional<int> = 100插件接口
设计插件系统时,宿主和插件之间的接口可能需要传递「宿主和插件各自定义的类型」的数据。由于双方的类型在编译期互不可见,any可以作为一个中性的传递容器:
// 宿主定义usingPluginData=std::any;classPluginHost{public:// 插件通过这个接口发送"任意类型"的数据给宿主virtualvoidon_plugin_data(conststd::string&key,constPluginData&data)=0;};// 插件端classMyPlugin{public:voidsend_custom_data(PluginHost&host){// 插件可以发送任何类型的数据structCustomResult{intcode;std::string message;};host.on_plugin_data("result",CustomResult{0,"success"});}};手写 MiniAny:三块骨头看穿类型擦除
为了更深入地理解类型擦除的机制,我们来手写一个极简版的any。这个实现远不如标准库完善,但它能帮你理解any内部到底怎么工作。
#include<memory>#include<stdexcept>#include<typeinfo>#include<utility>classMiniAny{public:MiniAny()=default;// 从任意类型构造template<typenameT>MiniAny(T value):holder_(newHolder<T>(std::move(value))){}// 拷贝构造MiniAny(constMiniAny&other):holder_(other.holder_?other.holder_->clone():nullptr){}// 移动构造MiniAny(MiniAny&&other)noexcept=default;// 赋值MiniAny&operator=(MiniAny other)noexcept{swap(holder_,other.holder_);return*this;}boolhas_value()constnoexcept{returnholder_!=nullptr;}conststd::type_info&type()constnoexcept{returnholder_?holder_->type():typeid(void);}// 内部概念接口structHolderBase{virtual~HolderBase()=default;virtualconststd::type_info&type()constnoexcept=0;virtualstd::unique_ptr<HolderBase>clone()const=0;};// 具体类型包装template<typenameT>structHolder:HolderBase{T value;explicitHolder(T v):value(std::move(v)){}conststd::type_info&type()constnoexceptoverride{returntypeid(T);}std::unique_ptr<HolderBase>clone()constoverride{returnstd::make_unique<Holder>(value);}};std::unique_ptr<HolderBase>holder_;};// 类型安全的取值函数template<typenameT>Tmini_any_cast(constMiniAny&a){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*holder=dynamic_cast<MiniAny::Holder<T>*>(a.holder_.get());returnholder->value;}这个简化实现揭示了any的三块核心骨头:
第一,HolderBase是类型擦除的接口——它定义了「任何被存储的类型都必须支持的操作」(获取类型信息、克隆自身),但不暴露具体类型。
第二,Holder<T>是具体的类型包装——它继承HolderBase,为每个具体类型提供实现。当你执行MiniAny a = 42;时,内部创建的是Holder<int>实例。
第三,mini_any_cast通过typeid比对来恢复类型安全——在取出值之前检查存储的类型是否和请求的类型一致。
标准库的std::any比这个实现复杂得多:有 SBO 优化避免小对象的堆分配,有移动语义优化,还有emplace等更灵活的构造方式。但核心思想是一模一样的。
什么时候不该用 any
虽然any很灵活,但大多数时候它并不是最好的选择。下面是几个「不要用any」的场景:
类型集合已知且有限:如果你知道值只可能是int、double、std::string中的某一个,直接用variant<int, double, std::string>。variant提供编译期类型检查和visit,性能也更好。
只需要表达「有值或无值」:用optional<T>而不是any。optional更轻量、语义更明确。
可以用模板解决:如果你的函数需要接受不同类型的参数,但不需要在运行时存储「不同类型的值」,模板通常是更好的选择。模板在编译期就完成了类型分派,没有任何运行时开销。
可以用多态解决:如果你有一组相关的类型,它们共享某个接口,虚函数可能比any更合适。虚函数提供了类型安全的接口,而any则完全放弃了接口约束。
笔者的一般原则是:能用variant就不用any,能用模板就不用运行时类型擦除。any是最后的手段——只有在所有静态方案都不适用的场景下才考虑。
嵌入式视角:资源受限环境下的 any
在嵌入式系统中,std::any通常不是首选工具。原因有三:第一,any的 SBO 缓冲区会占用额外的 RAM(通常 16–32 字节),这在 RAM 只有几十 KB 的 MCU 上是不可忽视的开销;第二,大对象会触发堆分配,而很多嵌入式系统要么没有堆,要么堆空间非常有限;第三,any_cast的类型检查涉及 RTTI(运行时类型信息),在某些嵌入式工具链中 RTTI 是被禁用的(为了节省代码空间)。
如果你确实需要在嵌入式项目里用类似的「动态类型」功能,更推荐的做法是用variant+enum标签来实现一个受限版本——所有可能的类型在编译期就确定,不需要 RTTI,也没有堆分配。
参考资源
- cppreference: std::any
- cppreference: std::any_cast
- cppreference: std::bad_any_cast
- Arthur O’Dwyer: Back to Basics - Type Erasure (CppCon 2019)