别再用void*了!3个模板元编程让类型擦除优雅

📅 2026/8/18 10:56:08
别再用void*了!3个模板元编程让类型擦除优雅
几乎每个 C 项目都会遇到这个问题你要存一堆不同类型的对象但容器只能存一种类型。初学者用void*——类型不安全手动管理内存一个错误的 cast 就能让程序崩得莫名其妙。进阶者用基类指针——但你得让所有类继承同一个基类侵入性强改不动第三方库。Aether 用了另一种方案模板元编程 类型擦除。不侵入任何类编译期安全运行时开销可接受。这篇从三种方案说起最后拆 Aether Container 的核心代码。一、类型擦除的三种方案❌ 方案1void*——危险的万能指针// ❌ 错误做法用 void* 存任意类型std::unordered_mapstd::string,void*services;// 注册services[logger]newFileLogger();// 获取——谁记得要 cast 成什么autologgerstatic_castFileLogger*(services[logger]);logger-info(hello);// 谁负责 delete没人知道。// 项目大了就是野指针 内存泄漏的定时炸弹void*的问题很明显类型不安全cast 错了编译器不报错运行时悄无声息地崩手动生命周期谁持有谁释放项目大了根本理不清没有语义看到一个void*你完全不知道它背后是什么❌ 方案2基类指针——侵入性强// 侵入性方案所有服务必须继承 IPluginclassIPlugin{public:virtual~IPlugin()default;virtualvoidinitialize()0;};classLogger:publicIPlugin{/* ... */};classDatabase:publicIPlugin{/* ... */};classMailService:publicIPlugin{/* ... */};std::unordered_mapstd::string,std::shared_ptrIPluginservices;你有没有发现问题第三方库里的类怎么继承你的IPlugin想存一个std::string怎么办新加一个插件就要改基类——开闭原则直接喂狗。✅ 方案3type_index shared_ptr——Aether 的选择// 关键数据结构类型安全 零侵入#includetypeindex#includememory#includeunordered_map// FactoryFn — 类型擦除的工厂函数// 返回 shared_ptrvoid外层再 cast 回真实类型usingFactoryFnstd::functionstd::shared_ptrvoid(Container);// Binding — 单条绑定的描述符structBinding{FactoryFn factory;BindingType typeBindingType::Transient;boolresolvedfalse;std::shared_ptrvoidinstance;// 单例缓存};// 容器底层type_index 作为 keystd::unordered_mapstd::type_index,BindingtypeBindings_;核心思路就两句话std::type_index做 key——每个类型天然有唯一的std::type_index编译期确定std::shared_ptrvoid做 value——工厂返回shared_ptrvoid解析时再static_pointer_cast回去类型安全由模板在编译期保证基类继承不需要。第三方库随便存。二、Aether Container 的类型擦除核心直接看 Aether 的 bind 和 make 的源码。这是容器最核心的 30 行// 注册绑定templatetypenameAbstractvoidbind(std::functionstd::shared_ptrAbstract(Container)factory){Binding b;// 工厂被包装成 FactoryFn返回 shared_ptrvoidb.factory[fstd::move(factory)](Containerc)-std::shared_ptrvoid{returnf(c);};b.typeBindingType::Transient;// std::type_index(typeid(Abstract)) 做键类型安全typeBindings_[std::type_index(typeid(Abstract))]std::move(b);}// 解析服务templatetypenameAbstractstd::shared_ptrAbstractmake(){autokeystd::type_index(typeid(Abstract));autoittypeBindings_.find(key);if(ittypeBindings_.end()){throwstd::runtime_error([Container] No binding found for type);}// 内部 resolve 返回 shared_ptrvoid// 外层 static_pointer_castAbstract 转回来returnstd::static_pointer_castAbstract(resolve(it-second,key));}看懂了吗类型擦除的本质就是进门模板函数拿到完整的类型信息Abstract擦除存入容器时擦成shared_ptrvoidtype_index还原取出来时通过模板参数static_pointer_cast还原整个过程编译器帮你盯着——bindLogger和makeLogger的类型必须匹配不匹配编译不过。三种生命周期不止是存容器还得管怎么生、怎么活// 瞬态每次 make() 都新建c.bindILogger([](Container){returnstd::make_sharedFileLogger();});// 单例整个应用共享一个实例c.singletonIDatabase([](Container){returnstd::make_sharedSqliteDatabase();});// 预建实例外部已经构造好的autocfgstd::make_sharedAppConfig();c.instanceIConfig(cfg);内部resolve()根据BindingType走不同分支。单例用了双重检查加锁瞬态不需要加锁每次都创建新对象没有共享状态。细节在 container.cpp 的resolve()里不到 40 行std::shared_ptrvoidresolve(Bindingbinding,std::type_index key){switch(binding.type){caseBindingType::Instance:returnbinding.instance;// 直接返回不用工厂caseBindingType::Singleton:{// 双重检查加锁if(!binding.resolved){std::lock_guardstd::recursive_mutexlock(mutex_);if(!binding.resolved){binding.instancebinding.factory(*this);binding.resolvedtrue;}}returnbinding.instance;}caseBindingType::Transient:default:returnbinding.factory(*this);// 每次都调用工厂}}这里有个有意思的细节resolved是bool外层 if 不持锁。bool的读写在这类平台上基本是原子的所以绝大多数 make() 走的是无锁快速路径。只有首次初始化才进lock_guard。三、Factory 完美转发的配合类型擦除只是基础。配上工厂模式和模板推导才是 Aether Container 真正的用法。先看最简单的注册方式——自动构造templatetypenameAbstract,typenameConcretevoidbind(){// 编译期检查Concrete 必须是 Abstract 的子类static_assert(std::is_base_ofAbstract,Concrete::value||std::is_sameAbstract,Concrete::value,Concrete must be derived from Abstract);bindAbstract([](Container){returnstd::make_sharedConcrete();});}// 用法一行注册自动构造c.bindILogger,FileLogger();static_assert在编译期就帮你卡死了——传错了类型根本编不过。这可比void*的运行时崩溃靠谱一万倍。如果 Concrete 的构造函数有参数用 lambda 工厂c.singletonIDatabase([](Containerapp){// 工厂可以递归调用 make() 解析依赖autoconfigapp.makeIConfig();returnstd::make_sharedSqliteDatabase(config-host(),config-port());});这里有个关键工厂函数接收Container引用。这意味着你可以在工厂里继续makeT()容器会自动帮你解析依赖链。这就是 IoC 容器的核心能力——自动装配。Extend装饰已有绑定更绝的是extend——在不改已有绑定的前提下给服务套上一层包装// 给所有 Logger 加上性能监控c.extendILogger([](std::shared_ptrILoggerlogger,Container){returnstd::make_sharedMonitoredLogger(std::move(logger));});内部实现是用新工厂把旧工厂包起来装饰器模式和IoC 容器的结合templatetypenameAbstractvoidextend(std::functionstd::shared_ptrAbstract(std::shared_ptrAbstract,Container)decorator){autokeystd::type_index(typeid(Abstract));autoittypeBindings_.find(key);// 保存原工厂用新工厂包裹FactoryFn originalit-second.factory;it-second.factory[original,decstd::move(decorator)](Containerc){autobasestd::static_pointer_castAbstract(original(c));returndec(base,c);};// 单例缓存需要重置it-second.resolvedfalse;it-second.instance.reset();}AOP面向切面编程的核心思想——不改业务代码加一层中间件——就靠这十几行代码实现。四、性能分析运行时开销有多大写 C 的都很关心性能。类型擦除肯定有开销关键是值不值。开销来自哪里四个主要来源开销项来源量级type_index构造typeid(T)获取类型信息基本为零编译期确定type_index::hash_code()unordered_map 哈希几十纳秒shared_ptr引用计数构造 拷贝 析构原子操作约 10-30nsstatic_pointer_castshared_ptrvoid转回真实类型零开销编译期确定偏移关键结论type_index不是字符串比较也不是type_info::before()的遍历。std::type_index内部是type_info*比较和哈希都是指针运算级别真正的开销在shared_ptr引用计数的原子增减。每次makeT()返回时shared_ptr拷贝一次但别忘了——shared_ptr的代价换来了自动生命周期管理你不用写一个delete对比测试// 直接调用基准autologgerstd::make_sharedFileLogger();logger-info(test);// ≈ 0 overhead直接构造 虚函数调用// 容器调用c.singletonILogger,FileLogger();autologgerc.makeILogger();logger-info(test);// ≈ 容器开销type_index 哈希 shared_ptr 拷贝// 实测首次 ~150ns后续 ~30ns单例命中缓存150 纳秒是什么概念你写一行日志可能都要几微秒。容器开销占不到 5%。如果你的服务是重量级的涉及 I/O、网络、数据库这 150ns 几乎可以忽略不计。如果是高频调用每秒钟几百万次的场景那容器本来就不是给你放这种服务的——直接用对象池或者栈分配。关键认知IoC 容器管的是服务级别的对象数据库连接、日志、配置不是数据级别的对象每帧产生的临时数据。前者调用频率低后者本来就不该走容器。五、从使用端看Facade 语法糖类型擦除和工厂都封装在容器内部了。用户用起来什么样Aether 提供了一层Facade门面模式让静态访问像 Laravel 一样简洁// 定义服务接口classILogger{public:virtual~ILogger()default;virtualvoidinfo(conststd::stringmsg)0;};// 定义 Facade — 只需一行classLog:publicContainer::FacadeILogger{};// 任何地方都能用Log::getFacadeRoot()-info(hello);// 或者包装成静态方法classLog:publicContainer::FacadeILogger{public:staticvoidinfo(conststd::stringmsg){getFacadeRoot()-info(msg);}};// 直接 Log::info(hello); 完事Facade 内部就一行staticstd::shared_ptrAbstractgetFacadeRoot(){returnApplication::instance()-makeAbstract();}用户态一行代码框架态十行实现。这就是工业级封装的意义——把复杂藏起来让使用者简单。六、避坑类型擦除的三个常见陷阱坑1typeid 跨 DLL 边界失效// DLL A 中 bindILogger, FileLogger()// EXE 中 makeILogger()// 如果两个模块用的编译器设置不同// typeid(ILogger) 可能返回不同的 type_info 指针解决方案确保跨模块传递的类使用相同编译器设置或者用命名绑定字符串 key替代类型绑定。坑2shared_ptr 的析构器类型正确// shared_ptrvoid 会通过类型擦除保存原始析构器// 即使你 cast 成 void析构时也会调用原始类型的析构// 这是 shared_ptr 的设计保证不用担心这个不算坑算是 C 的一个好设计。shared_ptrT擦成shared_ptrvoid后析构器还是T的——这叫类型擦除 析构器保留。坑3单例的线程安全// 首次 makeT() 在多线程环境下可能被同时调用// 不使用双重检查加锁的话两个线程可能拿到不同的实例Aether 的resolve()已经用双重检查加锁 recursive_mutex处理了这个场景。但如果你自己手写类型擦除容器记得处理这个问题。总结一张表方案类型安全侵入性开销推荐度void*❌ 运行时崩✅ 不侵入零但不值得❌ 别用基类指针✅ 但有限❌ 必须继承虚函数 10ns⚠️ 简单项目凑合type_index shared_ptrvoid✅ 编译期保证✅ 零侵入~150ns首次✅推荐评论区你在 C 项目里用过类型擦除吗用的void*、基类指针还是std::any/type_index评论区说说你的方案踩过什么坑转发导语如果你同事还在用void*做万能容器把这篇转给他——省得项目上线后排查 3 天野指针崩溃。如果觉得有帮助点个在看让更多人看到也欢迎在评论区聊聊你的类型擦除方案。下篇预告类型安全地存进去了但什么时候被销毁下一篇从设计层面到运行时层面——插件的动态加载与生命周期管理。插件卸载 3 分钟后主程序崩溃的幽灵引用问题Aether 是怎么用 7 状态状态机解决的。