
1. 适配器模式到底解决什么问题先聊聊我为什么突然想写这个。前阵子接手一个老模块第三方库的接口改了版原本用的getData(int)变成了fetch(const std::string)参数类型、返回方式全变了。如果直接全局替换调用点牵扯十几个文件、上百处调用风险极高。我当时第一反应就是用适配器模式在不动老代码的前提下写一个中间层把新旧接口翻译过来。半天搞定线上零故障。不少刚接触C的朋友会觉得适配器模式是个“概念货”教科书上画几张UML图就完了但真到项目里才知道它的价值它解决的是接口不兼容的问题而不是功能问题。换句话说业务逻辑不需要改改的是“怎么把现有的接口翻译成你需要的样子”。适配器模式的核心角色只有三个目标接口Target你希望代码面向的形态通常是新接口或你自定义的抽象。被适配者Adaptee已有的、无法修改或不想修改的旧接口。适配器Adapter中间的翻译层把被适配者的调用方式转换成目标接口。用生活里的例子类比最直观。你去国外旅游手里的充电插头是国标两项酒店插座是欧标圆孔。你不能把酒店插座拆了重装也不能把充电器插头锯了于是买一个转换插座。转换插座就是适配器充电器就是被适配者酒店插座就是目标接口。C里有两种实现形态对象适配器通过组合持有被适配者的实例和类适配器通过多继承实现。我在实际项目中几乎都用对象适配器原因后面细说。先记住一个原则适配器模式最适合用在“接口已经稳定但调用方式变化”的场景或者“你想统一一堆风格各异的第三方接口”的场景。2. 两种实现形态各有什么优缺点2.1 对象适配器组合优先灵活度最高对象适配器的实现方式很直接适配器类内部持有一个被适配者的指针或引用然后在重写目标接口方法时调用被适配者的对应方法。// 目标接口 class IDataSource { public: virtual ~IDataSource() default; virtual std::string fetchData(int id) 0; }; // 旧接口无法修改 class LegacyDataProvider { public: std::string queryById(int id) { return data_ std::to_string(id); } }; // 对象适配器 class DataSourceAdapter : public IDataSource { public: explicit DataSourceAdapter(LegacyDataProvider* provider) : m_provider(provider) {} std::string fetchData(int id) override { return m_provider-queryById(id); } private: LegacyDataProvider* m_provider; };这种写法有几个显而易见的好处被适配者不需要修改哪怕它是不可继承的类也行比如被final修饰。适配器可以同时适配多个被适配者你可以在一个适配器里组合多个旧对象拼装出新的接口语义。耦合度低适配器只依赖接口/类的声明不依赖内部实现。我实测中最常用的场景是系统里同时存在两套仓储接口一套是老的LegacyRepository一套是新的ModernRepository业务方想统一都走新接口风格。我不改老代码只写两个适配器类分别把老接口翻译成新接口然后在依赖注入的地方换个类型就完事了。2.2 类适配器多继承副作用需要警惕类适配器通过多继承来实现适配器同时继承目标接口和被适配者。这种写法在C里是可行的因为C支持多继承但带来的复杂度往往大于收益。// 类适配器同时继承目标接口和被适配者 class ClassAdapter : public IDataSource, public LegacyDataProvider { public: std::string fetchData(int id) override { return queryById(id); } };优势是代码量少、不需要持有指针、也不用手动管理成员生命周期。但它有几个明显的坑无法适配不可继承的类。多继承可能引发名字冲突钻石继承问题在大型项目中很难排查。适配器与被适配者强绑定如果一个被适配者的修改导致适配器重建整个继承链条都要动。我的经验建议默认用对象适配器除非你明确需要重写被适配者的虚函数逻辑。因为对象适配器更容易测试——你可以在测试里传入一个mock的被适配者而类适配器就做不到。3. 实战把老接口翻译成新接口3.1 真实业务场景假设你维护一个支付网关模块原本调用的是LegacyPayClient它的方法是class LegacyPayClient { public: bool pay(const std::string orderId, double amount, const std::string currency); std::string queryStatus(const std::string orderId); };现在公司统一升级新网关ModernPayService的接口变成class ModernPayService { public: PaymentResult pay(const PaymentRequest request); PaymentStatus query(const std::string transactionId); };而且PaymentRequest是一个结构体里面包含订单号、金额、币种、回调地址。如果你直接全局替换所有pay调用工作量大还不说很多老模块还依赖bool返回值判断成功与否改起来很容易漏。3.2 写出适配器我的做法是写一个LegacyPayAdapter实现新接口内部调用老的LegacyPayClient。这样老模块的调用代码可以保持不变但底层实现已经翻译到新接口上了。#include string #include iostream struct PaymentRequest { std::string orderId; double amount; std::string currency; std::string callbackUrl; }; struct PaymentResult { bool success; std::string message; std::string transactionId; }; struct PaymentStatus { std::string status; }; // 新接口 class IPaymentService { public: virtual ~IPaymentService() default; virtual PaymentResult pay(const PaymentRequest request) 0; virtual PaymentStatus query(const std::string transactionId) 0; }; // 老实现 class LegacyPayClient { public: bool pay(const std::string orderId, double amount, const std::string currency) { std::cout Legacy pay: orderId amount currency std::endl; return true; } std::string queryStatus(const std::string orderId) { return PAID; } }; // 适配器把旧支付接口翻译成新接口 class LegacyPayAdapter : public IPaymentService { public: explicit LegacyPayAdapter(LegacyPayClient* client) : m_client(client) {} PaymentResult pay(const PaymentRequest request) override { bool ok m_client-pay(request.orderId, request.amount, request.currency); if (ok) { return PaymentResult{true, OK, request.orderId}; } return PaymentResult{false, PAY_FAILED, }; } PaymentStatus query(const std::string transactionId) override { std::string status m_client-queryStatus(transactionId); return PaymentStatus{status}; } private: LegacyPayClient* m_client; };3.3 为什么这样做是安全的我在实际重构中有一个执念接口迁移要能逐步进行而不是一次性把代码全部推翻。上面的适配器方案完美支持渐进式迁移——你可以让10%的调用先走新接口剩下的90%继续用老的反正业务层看到的都是新接口底层是翻译过的旧实现。测完一部分再慢慢替换最后再把老客户端代码删掉。注意适配器本身不应该包含业务逻辑。比如上面pay方法里如果老接口返回false适配器只负责把bool转成PaymentResult不应该在这里做重试、告警、记日志这种业务决策。否则适配器会越来越臃肿变成“垃圾场”类后续维护会让你欲哭无泪。4. 实战重构遗留代码中的接口不匹配4.1 典型场景描述还有一种常见的应用场景是老代码的接口风格太“异构”比如某个回调函数用了void (*)(int, const char*)而新的系统统一用std::functionvoid(const Event)。这种类型层面的不匹配光靠函数指针转换根本编译不过去。我以前参与过一个网关项目里面接了三家物流公司的接口每一家的查询方法签名都不同第一家int queryByCode(const char* code, char* result, int len)第二家std::string getTrackingInfo(const std::string id)第三家TrackingResult fetch(const TrackingQuery query)业务侧要统一调用最笨的办法是写三个分支每个分支里调不同的方法然后手动组装结果。但这样代码里全是if (company a)的判断很难看而且新增一家公司就要改动业务代码。4.2 适配器统一接口我的方案是定义统一的ITrackingService接口然后为每一家物流公司写一个适配器。#include string #include memory #include vector struct TrackingResult { std::string status; std::string description; std::string estimatedArrival; }; class ITrackingService { public: virtual ~ITrackingService() default; virtual TrackingResult track(const std::string trackingNo) 0; }; // 第一家物流公司 class CompanyOneClient { public: int queryByCode(const char* code, char* result, int len) { snprintf(result, len, statusDELIVERED;descPackage delivered;eta2024-05-01); return 0; } }; class CompanyOneAdapter : public ITrackingService { public: explicit CompanyOneAdapter(CompanyOneClient* client) : m_client(client) {} TrackingResult track(const std::string trackingNo) override { char buf[256] {0}; m_client-queryByCode(trackingNo.c_str(), buf, sizeof(buf)); // 实际项目中这里需要解析buf内容 return TrackingResult{DELIVERED, Package delivered, 2024-05-01}; } private: CompanyOneClient* m_client; }; // 第二家物流公司 class CompanyTwoClient { public: std::string getTrackingInfo(const std::string id) { return statusIN_TRANSIT,descArrived at hub; } }; class CompanyTwoAdapter : public ITrackingService { public: explicit CompanyTwoAdapter(CompanyTwoClient* client) : m_client(client) {} TrackingResult track(const std::string trackingNo) override { std::string info m_client-getTrackingInfo(trackingNo); // 解析并返回 return TrackingResult{IN_TRANSIT, Arrived at hub, 2024-05-03}; } private: CompanyTwoClient* m_client; };然后只需要维护一个工厂或配置表把公司标识映射到对应的适配器实例。业务层拿到ITrackingService后根本不知道背后是哪家物流新接入一家公司也只需要新写一个适配器类不用碰业务代码。4.3 适配器模式与策略模式的边界我见过不少人把适配器模式和策略模式搞混。简单区分一下策略模式是“同一个接口多个实现”选择哪个实现由业务决定比如排序算法可以切换冒泡、快排、归并。适配器模式是“接口不兼容通过翻译来适配”它不关心业务怎么选只关心“旧的怎么翻译成新的”。如果上面物流这个例子你是为每家公司写了一个类然后统一调用这更像是“策略模式工厂”的组合。但为什么我说它是适配器关键在于每个类的核心职责是把“特定公司的不统一签名”翻译成统一接口。如果你把所有公司的接口都封装成同样签名了后续再用策略模式切换就没问题。很多实际项目里这两个模式经常连用先用适配器把异构接口统一再用策略模式让调用方可选。5. 常见问题与避坑指南5.1 适配器把接口“翻译”了但异常语义不一致怎么办这是我在实战中踩得最深的一个坑。老接口用bool返回值表示成功失败新接口用异常或者std::optional表达错误。适配器不能只做“返回类型转换”还必须把错误语义也翻译过来。比如老接口bool pay(...)返回false只表示失败但具体失败原因是查不到内部日志的。适配器里最好把false转成带错误码的结果对象而不是默默返回PaymentResult{false}。否则上层拿到false后一脸迷茫调试半天才发现底层日志里有详细错误。我的经验法则是适配器遇到被适配者返回异常时不要吞掉异常要把它包装成新接口的错误类型再抛出。如果被适配者返回bool务必在适配器里补充上下文信息比如订单号放入错误对象方便排查。5.2 适配器类爆炸一个接口配一个类维护成本高假设你有5个被适配者每个都要实现3个目标接口方法适配器类数量会线性增长。这个问题的解法不是加入“超级适配器”而是优先使用组合和std::function让适配器更像一个配置对象而不是一堆类。如果几个被适配者的“翻译规则”相同只是参数映射不同可以考虑用模板适配器。比如templatetypename Client class GenericAdapter : public ITrackingService { public: explicit GenericAdapter(Client* client) : m_client(client) {} TrackingResult track(const std::string trackingNo) override { // 通过 traits 或函数参数绑定进行翻译 return m_client-query(trackingNo); } private: Client* m_client; };但注意模板适配器会提高编译时间和代码理解成本我建议只在确实有3个以上“相同签名模式”的被适配者时才用它。5.3 生命周期管理要清晰对象适配器内部持有被适配者的指针这里有一个常见问题谁负责释放被适配者对象我有一次在项目里写适配器时直接用new LegacyPayClient()传入适配器后来代码里又手动删除了客户端结果运行时直接崩了。正确做法是如果被适配者由外部传入适配器只持有不拥有不要负责释放。如果适配器自己创建被适配者优先用std::unique_ptr来管理避免裸指针悬空。适配器作为接口注入时最好用std::shared_ptr管理避免多线程环境下对象被提前销毁。我在实际项目里更推荐让适配器持有std::unique_ptrLegacyPayClient并在构造时传入通过移动语义。这样适配器析构时自动释放底层资源不会泄露也不会重复释放。5.4 适配器对接口的“翻译”不要过度有些朋友写适配器时会把原本不支持的功能也硬塞进去。比如老接口只能查单个订单你非要在适配器里循环100次去实现批量查询。这不叫适配这叫扬长避短包装。批量查询可以放在上层服务层组合不要污染适配器的职责。如果适配器的职责膨胀后续测试、排查问题都很难定位。我看到过一段代码适配器里面缓存了结果、加了日志、还做了重试结果一改接口就崩。适配器忠实翻译即可额外逻辑请放在外面。5.5 多线程场景下的线程安全问题老适配器直接持有裸指针在多线程环境中有隐忧。假设两个线程同时通过适配器调用同一个被适配者对象的方法如果被适配者内部有可变状态就可能出现数据竞争。规避方案有几种被适配者的线程安全由它自己保证适配器只做转发。适配器内部加入锁保证每次调用串行化。但注意不要锁粒度太大否则并发性能直线下降。如果只是只读调用可以直接const修饰。5.6 适配器代码的测试策略适配器模式最难测试的不是功能而是如何验证适配器没有多余逻辑。我一般会写三类测试翻译正确性测试传入一个mock的被适配者验证适配器输出的目标接口调用参数与被适配者收到的参数一致。异常传播测试被适配者抛异常时适配器是否按新接口形式抛出不吞掉异常。边界测试比如空字符串、整型边界、nullptr被适配者看是否有崩溃或未定义行为。我在代码里写过很多次类似的假客户端class MockLegacyClient : public LegacyPayClient { public: bool pay(const std::string orderId, double amount, const std::string currency) override { lastOrderId orderId; lastAmount amount; lastCurrency currency; return true; } std::string lastOrderId; double lastAmount 0; std::string lastCurrency; };测试LegacyPayAdapter时只要创建MockLegacyClient调用适配器接口检查mock内部的参数是否被正确传递即可。这样无需依赖真实网络环境测试稳定、快速。6. 适配器模式在项目中的定位与扩展思路适配器模式不只是一个入门设计模式它在大型系统里扮演着重要的“防腐”角色。当你面对以下情况时它就是最优解业务模块要对接多个供应商每个供应商接口风格不一样。老系统重构新接口已定但旧代码不能一次性改动。你引入一个新库它的接口和你现有业务抽象不匹配你又不想在业务代码里到处加分支判断。从扩展角度看适配器和很多模式都能联动配合工厂模式从配置文件读取适配器类型由工厂组装并返回适配器实例。配合门面模式多个适配器背后再加一层门面对外暴露更简洁的API。配合装饰器模式在适配器的外部再套一层缓存、日志、限流等装饰逻辑避免污染适配器本体。我在支付网关重构里就实践了这个思路底层是三家不同的支付渠道各自有适配器适配器外面再套一层统一的缓存和日志装饰最外面是一个门面类业务方只需调用一个方法完全不感知渠道差异。这套结构上线后新渠道接入只需要三小时而且不用改动任何调用方代码。如果项目里已经有类似interface风格的抽象适配器模式几乎不需要额外引入任何依赖直接用虚函数重写就能实现。这大概也是它成为最“朴素”的设计模式的原因——没有花哨的魔法没有复杂的继承层级就是把一个接口翻译成另一个接口。我个人在实际操作中的体会是设计模式好不好用关键看能不能在“不改旧代码”的前提下把新需求落下去。适配器模式是我用过最频繁的“无侵入重构”工具之一遇到接口不兼容问题先想适配器准没错。最后再分享一个小技巧适配器类的命名最好不要叫XxxAdapter就完了建议把被适配者和目标接口都带上比如LegacyPayToModernGatewayAdapter。虽然名字长一点但一看就知道翻译方向和目标别人接手时不用翻代码历史记录。这样的小习惯能帮你省掉不少沟通成本。