1. 什么场景下你需要适配器模式
如果你做过一段时间C++工程,大概率遇到过这种尴尬:手里有一个封装得很好的旧模块,接口设计和命名都按老一套风格来,结果新项目里定了一套全新的接口规范,两边根本对不上。你要么改旧模块的代码,把老接口全部推翻重来,要么在新调用方那里写一堆中转的胶水代码。改老代码风险大、影响面广;写胶水代码一次两次还行,多了就是灾难。适配器模式解决的就是这个“接口不匹配”的问题。
适配器(Adapter)在生活里最常见的就是电源转换头。你从国外带回来一个插座标准不同的设备,你不会把墙里的电线拆了重接,而是花几块钱买一个转换头,把设备的插头转成墙插能接受的形状。这个转换头不改变电本身,只负责接口形态的匹配。软件里的适配器干的是同一件事:不改动原有的类,也不改动调用方的期待接口,加一个中间层把两者的接口对齐。
但要说明白一件事:适配器不是万能的胶水。它适用于“接口签名不匹配”,而不是“业务逻辑完全不兼容”。如果旧模块的内部行为和新的接口语义根本对不上,比如旧模块是同步接口、新接口要求异步回调,那适配器只能帮你做接口转换,不能替你解决并发模型差异。所以接到需求时,先判断这是“接口层问题”还是“架构层问题”。只有前者才适合用适配器。
这篇内容适合写接口、改遗留代码、接第三方SDK的前后端C++开发者。我会用一个模拟项目X的真实场景——老日志模块接入新上报框架——把类适配器、对象适配器、泛型适配器、函数适配这几种做法全部走一遍,顺便聊聊适配器在生命周期管理和异常安全上的坑。
2. 适配器模式的两种经典形态:类适配器与对象适配器
2.1 类适配器:用多继承快速包装接口
类适配器是教科书里最直接的写法。它的思路是让适配器同时继承旧接口和新接口,在重写新接口方法时,内部调用从旧接口继承来的实现。C++允许多继承,所以这种写法在C++里特别自然。
// 旧接口:老项目的日志模块 class LegacyLogger { public: void write(int level, const std::string& msg) { // 把日志写到本地文件 std::cerr << "[Legacy] level=" << level << " msg=" << msg << '\n'; } }; // 新接口:新系统统一的日志上报规范 class ILogSink { public: virtual ~ILogSink() = default; virtual void flush(const LogEvent& evt) = 0; }; // 类适配器:同时继承新旧两个接口 class LegacyLoggerAdapter : public LegacyLogger, public ILogSink { public: void flush(const LogEvent& evt) override { int level = toLegacyLevel(evt.level); write(level, evt.message); } };这个写法的优点是直接、简单,适配器的代码量最少。但它有一个明显的隐患:C++多继承会带来二义性和对象布局复杂度。如果新旧两个接口都有同名的成员函数,或者都有基类,编译期就可能爆出让人摸不着头脑的错误。再看实际工程,旧接口往往不是你能轻易改的,但它内部可能有非虚析构、私有成员、甚至操作全局状态,直接继承一个来路不明的旧类,风险不小。
所以我个人很少在生产代码里用类适配器,除非旧接口非常简单、没有资源管理负担,而且新接口恰好就是纯虚接口。更多时候我倾向用组合方式。
2.2 对象适配器:组合优先,兼容性更好
对象适配器不继承旧接口,而是持有一个旧接口的实例指针或引用,在新接口方法里调用旧实例的接口完成实际工作。上面的日志适配器改用组合方式是这样:
class LegacyLoggerAdapter : public ILogSink { public: explicit LegacyLoggerAdapter(std::shared_ptr<LegacyLogger> legacy) : legacy_(std::move(legacy)) {} void flush(const LogEvent& evt) override { int level = toLegacyLevel(evt.level); legacy_->write(level, evt.message); } private: std::shared_ptr<LegacyLogger> legacy_; };对象适配器最大的好处是把“新接口”和“旧实现”的耦合降到了最低。LegacyLogger的继承关系、成员布局、生命周期全部由它自己管,适配器只是一个翻译层。C++核心指南也提倡优先用组合而不是继承,理由就是组合关系的使用意图清晰、测试容易,适配器发生了什么一目了然。
当然对象适配器也不是没有代价。你要多写一个成员变量和一个构造函数,代码量比类适配器略多。另外,如果旧实例是一个栈对象,你要特别注意生命周期,适配器不能持有悬垂引用。工程上常见的做法是传入shared_ptr,或者严格约定旧实例的生命周期长于适配器。无脑裸指针传参,大概率会在某个夜深人静的版本里挂掉。
2.3 两种形态怎么选:一张决策表
我给团队内部整理过一个选型表,直接照着判断就行。
| 判断条件 | 类适配器 | 对象适配器 |
|---|---|---|
| 旧接口是否简单且无资源管理 | 可以 | 可以 |
| 旧接口有非虚析构或涉及继承体系 | 不建议 | 推荐 |
| 是否需要适配多个旧类到一个新接口 | 基本做不到 | 完美支持,逐个实例注入 |
| 是否想让适配器可复用、可测试 | 受限 | 推荐 |
| 追求最小代码量、接口极简 | 可选 | 稍多几行但可控 |
如果让我给一句话结论:在C++11之后的项目里,默认选对象适配器。类适配器更像教学示例,实际工程里组合方式能避开多继承带来的太多坑。
3. C++特有的适配器玩法:模板、函数与引用包装
3.1 模板适配器:同一个适配过程,适配任意旧类型
刚才提到的日志例子,一个适配器只对应一个旧类。但如果有一批旧类,它们的接口形态相同——都有一个write方法、参数还都一样——你每适配一个旧类就写一个Adapter类,代码量就上来了。这时候可以用模板把整个适配过程泛化。
template <typename LegacyType> class GenericLogSinkAdapter : public ILogSink { public: explicit GenericLogSinkAdapter(std::shared_ptr<LegacyType> legacy) : legacy_(std::move(legacy)) {} void flush(const LogEvent& evt) override { int level = toLegacyLevel(evt.level); legacy_->write(level, evt.message); } private: std::shared_ptr<LegacyType> legacy_; }; // 使用示例 auto adapter1 = std::make_shared<GenericLogSinkAdapter<LegacyFileLogger>>(fileLogger); auto adapter2 = std::make_shared<GenericLogSinkAdapter<LegacyConsoleLogger>>(consoleLogger);只要LegacyFileLogger和LegacyConsoleLogger都有write(int, const std::string&)成员函数,这个模板就能直接复用。如果个别类的write签名稍微不同,比如一个是writeln一个是logMessage,模板实例化会直接编译失败。应对这种差异有两个办法:一是给这些类统一加一个薄薄的原生函数适配层,再把模板套在外面;二是用C++20的concept约束接口形态,编译报错时信息会很清晰。
模板适配器的价值在于:当适配的目标接口一致时,用“模板参数”替代“重复编写适配器”。它把变化点压缩到最小,但要求适配者对逻辑高度抽象。核心原则是:如果两个适配器的内部逻辑只有类型不同,就值得模板化;如果逻辑本身有分支差异,那就不适合强行模板化,老老实实写两个类反而更清晰。
3.2 函数适配器:用 std::function 把一个自由函数包装成接口对象
前面说的适配器都是“类适配接口”,但实际项目里经常还有另一种情况:旧代码是C风格的自由函数,而新系统要求注册回调对象。比如新框架需要注入一个ImageProcessor接口,实际处理逻辑却在一个全局函数cv_process_image()里。与其造一个完整的Adapter类,不如直接用std::function做轻量适配。
// 旧实现:C风格全局函数 bool cv_process_image(const Image& in, Image* out) { // 一些图像处理逻辑 return true; } // 新框架期望的处理器接口 using ImageProcessorFunc = std::function<bool(const Image&, Image*)>; ImageProcessorFunc adapt() { return [](const Image& in, Image* out) -> bool { return cv_process_image(in, out); }; }这种适配方式几乎零成本,一个lambda就完成翻译。它的适用范围是:新接口本身就是函数指针或std::function类型的回调接口。如果新接口是带多态行为的纯虚类,那std::function只适配了“调用”,适配不了对象生命周期和资源管理,这时还是得回到类适配器。
std::function适配还有一个隐藏价值:它可以绑定带状态的lambda,把一个状态机对象和一个旧函数“粘”在一起。比如旧函数需要一块缓存区,你可以在lambda捕获列表里放一个unique_ptr缓存,每次调用时取出使用。这在纯函数适配器里做不到,而用std::function反而顺手。
3.3 引用包装与void*适配:老C接口的通用桥接方案
还有更麻烦的老接口,它们长这样:
void register_legacy_handler(void* user_data, void (*callback)(void*));新代码里希望注册一个std::function回调。这时你不能直接把泛型lambda转成函数指针,因为无捕获lambda才可转换。破解方法是把user_data指向一个堆上的包装对象,回调函数里恢复出这个对象再调用std::function。
struct CallbackBridge { std::function<void()> fn; }; void legacy_callback_forward(void* user_data) { auto* bridge = static_cast<CallbackBridge*>(user_data); bridge->fn(); } void register_modern_callback(std::function<void()> fn) { auto* bridge = new CallbackBridge{std::move(fn)}; register_legacy_handler(bridge, &legacy_callback_forward); }这就是典型的C/C++混编适配。坑其实不少:你new出去的bridge什么时候delete?如果框架在某个回调里调用完不再使用,你可以在回调里delete;如果回调可能被持有很长时间,则delete时机就得单独商定。我见过不少项目在这里漏了delete,内存泄漏每次不大但反复触发,valgrind一跑满屏都是。最好的做法是让旧框架提供释放回调的接口,把bridge的destroy挂到那里。实在没有办法时,退而求其次用一个全局单例容器持有bridge,程序退出时统一清理,丑是丑了一点,但起码不会在引用后释放。
4. 实操复盘:将旧日志系统适配到统一上报框架
4.1 场景设定与接口冲突分析
模拟项目X里有一坨历史悠久的消息日志模块,它暴露的是类似上面的LegacyLogger,方法签名是write(int level, const std::string& text)。新上线的系统做了统一日志平台,所有模块都必须实现ILogSink接口,统一走flush(const LogEvent&)。LogEvent结构里除了level和message,还有timestamp、module、requestId这些字段。
旧日志模块不能动,因为还有老业务在调用它;新框架的接口也不能改,因为所有其他模块都按这个实现。这就是适配器模式的标准出场时机。但真实项目里比教科书多一层:日志的level枚举新旧语义不一致,旧库用0~5的数字表示Debug到Fatal,新框架用枚举类LogLevel::Debug、LogLevel::Info这种。适配器必须做一次枚举映射。
4.2 适配器实现:从接口到落地的完整代码
我最后用的是对象适配器加模板形态的结合体。先定义适配器本身,这里把枚举映射放到适配器内部,保证外部调用方只感知LogEvent。
enum class LogLevel { Debug, Info, Warn, Error, Fatal }; struct LogEvent { LogLevel level; std::string message; int64_t timestamp; std::string module; std::string request_id; }; class LegacyLoggerAdapter : public ILogSink { public: explicit LegacyLoggerAdapter(std::shared_ptr<LegacyLogger> legacy) : legacy_(std::move(legacy)) {} void flush(const LogEvent& evt) override { int legacyLevel = fromNewLevel(evt.level); std::string enriched = evt.message + " [module=" + evt.module + ", req=" + evt.request_id + "]"; legacy_->write(legacyLevel, enriched); } private: static int fromNewLevel(LogLevel level) { switch (level) { case LogLevel::Debug: return 0; case LogLevel::Info: return 1; case LogLevel::Warn: return 2; case LogLevel::Error: return 3; case LogLevel::Fatal: return 4; } return 1; } std::shared_ptr<LegacyLogger> legacy_; };这里的关键决策有两个。第一,为什么要用shared_ptr而不是unique_ptr或裸指针?因为适配器可能被注册到框架的某个容器里,容器不负责所有权,而旧Logger又可能在多处被引用。用shared_ptr把一个复杂的生命周期问题交给智能指针处理,是最稳妥的默认选择。第二,为什么要在适配器内部拼装module和request_id?因为旧接口只有一个message字段,新格式里额外数据不能丢,拼装虽然粗糙,但能保证信息不丢失。更优雅的做法是让旧接口做一个格式化hook,但那又要侵入老模块,违背了适配器“不改旧代码”的原则。
4.3 注册与使用方式
适配器写好后,接入点通常是一个全局注册函数。框架侧只需要知道ILogSink,完全感知不到LegacyLogger的存在。
void register_log_sink(std::shared_ptr<ILogSink> sink); // ... auto legacyLogger = std::make_shared<LegacyLogger>(); auto adapter = std::make_shared<LegacyLoggerAdapter>(legacyLogger); register_log_sink(adapter);这一步就是一个标准的依赖注入。以后如果日志模块升级,你只需要把LegacyLoggerAdapter换成新模块自己的ILogSink实现,注册代码一行不用改。这就是适配器带来的架构收益:替换成本被摊薄到“新增一个类”,而不是改整条调用链。
4.4 用模板消除重复适配器
模拟项目X里旧方法不止一个。除LegacyLogger外,还有个RemoteLogger也提供write(int, const std::string&),但走网络上报。给RemoteLogger再写一个RemoteLoggerAdapter就是纯重复劳动。所以我直接用模板把上面适配器重写了一遍,唯一保留的可变点是旧实例的类型。
template <typename LegacyType> class GenericLogSinkAdapter : public ILogSink { public: explicit GenericLogSinkAdapter(std::shared_ptr<LegacyType> legacy) : legacy_(std::move(legacy)) {} void flush(const LogEvent& evt) override { int legacyLevel = fromNewLevel(evt.level); std::string enriched = evt.message + " [module=" + evt.module + ", req=" + evt.request_id + "]"; legacy_->write(legacyLevel, enriched); } private: static int fromNewLevel(LogLevel level) { /* 同上 */ } std::shared_ptr<LegacyType> legacy_; };这个模板能编译的前提是LegacyType有write(int, const std::string&)成员。万一后加入的某个旧类用的方法名是log(int, std::string_view),模板直接编译失败。遇到这种情况我会用“概念约束 + 特化兜底”的两步走:先在模板里用static_assert检查类型特征,给出明确的报错;再为个别类写一个非模板适配器专门处理。不要为了强行复用模板去改旧类的公共API,那样适配器就失去意义了。
4.5 单元测试:适配器是否真的“适配”了
写完适配器不是结束,我习惯配上单元测试。测试重点是:枚举映射正确性、消息字段拼装正确性、旧类是否确实被调用了一次。
TEST(LegacyAdapterTest, EnumMapping) { auto stub = std::make_shared<LegacyLoggerStub>(); GenericLogSinkAdapter<LegacyLoggerStub> adapter(stub); LogEvent evt{LogLevel::Error, "boom", 123, "auth", "req42"}; adapter.flush(evt); ASSERT_EQ(stub->lastLevel(), 3); ASSERT_TRUE(stub->lastMessage().find("auth") != std::string::npos); }LegacyLoggerStub是一个测试替身,记录最近一次的调用参数。通过它你能验证适配器只做“翻译”而不引入额外副作用。很多适配器写完后容易忘记测试枚举映射,上线后日志等级错乱,会被运维揪出来。这个测试代码一点不复杂,但能挡住后续老接口升级时的回归问题。
5. 适配器实战中的常见坑与排查技巧
5.1 接口变了,适配器悄悄“失配”
最常见的坑是:旧接口的方法签名改了,或者新接口增加了一个纯虚方法,但适配器没有同步更新。类适配器此时可能编译不报错(因为新加的纯虚方法影响的是实例化,某些编译器要到链接才报),对象适配器如果调用的旧方法不存在,直接编译失败还算好的。最难受的是接口语义静默变化,比如旧write方法从线程安全变成非线程安全,适配器不会察觉,但线上开始偶发日志错乱。
排查办法是:给适配器加编译期检查。用static_assert校验接口特征:
template <typename LegacyType> concept WritableLogger = requires(LegacyType* t, int level, std::string msg) { t->write(level, msg); };把它挂到模板适配器上,接口不匹配时编译直接报错,而不是等到运行时炸。适配器的价值在于屏蔽差异,但屏蔽差异的前提是接口真的还对得上。
5.2 生命周期管理失误:适配器持有悬垂引用
对象适配器持有旧实例引用时,最常见的崩溃是悬垂引用。旧实例是栈对象,适配器却先被释放或被提前delete,后续flush就是访问已释放内存。别问我是怎么知道的,线上凌晨三点崩溃永远是最清醒的时候。
我的排查铁律:适配器持有的旧实例,一律使用shared_ptr管理所有权;如果做不到,必须保证旧实例的创建和销毁都在适配器之前完成,并在适配器构造函数里加一条断言,明确写出这个不变量。用裸指针不是不行,但你得为整个团队立一条规矩:谁创建谁负责销毁,销毁前保证没有适配器还在引用它。
5.3 过度使用适配器:什么时候该停手
适配器模式的确好用,但它也会上瘾。有些开发者看到一个接口对不上就写适配器,最后整个工程里塞满了各种Adapter,代码量没少,反而多了一层让人看不懂的中转逻辑。
判断标准很简单:如果你需要适配的旧接口超过三四个,而且各自签名差异很大,说明问题不在“适配”,而在“边界设计”。这时候应该考虑是不是把旧模块包一层门面(Facade),重新定义一套中间接口,再让每个旧类适配到中间接口上。适配器解决的是两点之间的差异性,门面解决的是多个点的统一入口,两者可以配合使用,但别让适配器变成到处乱贴的创可贴。
5.4 适配器里的异常安全
旧接口可能抛出异常,新框架可能对异常零容忍。如果旧write方法在磁盘满时抛std::runtime_error,适配器直接透传的话,新框架的回调链可能直接中断,连带影响主流程。
我在适配器里通常加一层兜底:
void flush(const LogEvent& evt) noexcept override { try { legacy_->write(fromNewLevel(evt.level), evt.message); } catch (const std::exception& e) { // 降级输出到系统日志,绝不能把异常抛到框架层 std::fprintf(stderr, "logger adapter caught: %s\n", e.what()); } catch (...) { std::fprintf(stderr, "logger adapter caught unknown exception\n"); } }注意这里把flush声明成noexcept,是为了和新接口的契约保持一致。捕获所有异常后进行降级处理,避免适配器成为整条链上的单点故障源。很多框架设计的新接口本身就要求实现不得抛异常,你如果不在适配器里处理,下个排查问题的同事会问候你全家。
6. 写在最后的几点个人体会
我个人在实际项目里用适配器最多的场景,其实不是新项目,而是给一堆历史包袱收尾。旧系统里积累了各种形态怪异的接口,新架构不可能全部推倒重写,适配器就成了平滑迁移的最好工具。每当我新增一个适配器,都会顺手在配置文件里加一个开关,让迁移过程是渐进的——先让少量流量走新接口,验证稳定后再逐步放开。这个思路比一次性切换要稳妥太多。
还有一个经验是:适配器类别写太厚。适配器的职责是“接口转换”,不是“业务翻译”。如果适配器里开始出现业务逻辑判断、特殊分支处理、统计打点,那它就不再是适配器,而是一个藏在暗处的业务模块。业务逻辑应该放在真正的业务层,适配器只做字段搬运和格式转换。否则将来业务迭代时,改适配器的人以为是改接口兼容,结果连带影响了一堆业务行为,这种事故我至少见过三次。
最后分享一个调试小技巧:为适配器加上一种特殊的日志标识,例如统一在消息里加前缀“[ADAPTER]”。这样线上排查问题时,能在日志链路里快速分辨哪些是直接输出、哪些经过了适配器转换。排查接口不匹配、日志缺失、字段错位时,这个前缀节省的时间非常可观。