写C的年头一长你会发现真正天天打交道的设计模式不是那些花哨的工厂和观察者而是最朴素的适配器模式。最近我在做一个数据采集项目要把一个老日志库、一个C风格的第三方接口、还有一个时序数据库的写入接口统一到现有C代码里三个接口三种风格最后全部靠适配器模式搞定。这篇文章不是把GoF书上的概念抄一遍而是把我在实际开发中用到的各种适配器变体完整拆一遍——经典款怎么选C特有的模板/函数/RAII怎么玩以及哪些坑是用之前必须知道的。想搞懂适配器模式并能立刻上手的人可以仔细看看我尽量把每个思路都落到可复现的代码上。1. 适配器模式的核心问题接口永远在变代码不能总改1.1 一次真实的重构三个库的接口冲突先讲个实际场景。我之前维护一个服务端模块里面有一套用了三年的日志接口Logger有文件、控制台、远程三个实现。后来项目要接入一个第三方SDK它自带了一套日志系统接口签名和我们的完全不一样我们这边是info(string)、error(string)人家是log_message(int level, string text)。方案摆在面前只有三条路改SDK不可能改我们的接口三个实现类连带一大堆调用点全部要动伤筋动骨写一个适配器把第三方日志包装成我们的Logger。第三条路显然是最理性的。这就是适配器模式的典型诞生场景当已有接口无法满足新需求又不想改动被适配对象本身时在中间加一层转换层。这种接口冲突在C项目里太常见了。第三方库升级改了函数签名、两组开发团队各自定义了语义相同但形式不同的结构体、C库函数和C容器对数据类型的要求不一致……一旦出现这类问题第一反应不应该是去改历史代码而是先看能不能用适配器把兼容层垫出来。1.2 适配器模式的本质与角色划分适配器模式的核心思想用一个词概括就是“转接”。它和生活中的电源转换头完全是一个逻辑你从国外带回来的电器插头是欧标的国内插座是国标的你不能改插座、也不能改电器而是买一个转换头插在中间。适配器模式就是软件里的转换头。从GoF经典定义出发适配器模式涉及四个角色Target目标接口客户端期望调用的抽象接口比如上面说的Logger。Adaptee被适配者已经存在的、接口不匹配的实现比如第三方日志类ThirdPartyLogger。Adapter适配器把Adaptee的接口转换成Target接口是整个模式的核心。Client客户端只面向Target接口编程完全不知道Adaptee存在。很多新手写适配器容易犯一个错误在适配器里塞了一堆业务逻辑。记住适配器只做“接口翻译”不做“业务加工”。一个日志适配器就应该只负责把log_message(2, text)翻译成info(text)至于消息要不要加时间戳、要不要限流那是Target实现类该考虑的事塞进适配器会让它变成一个四不像后面维护起来非常痛苦。2. 两种基础实现类适配器和对象适配器的完整对比2.1 类适配器继承让接口映射最直接类适配器通过继承同时获得Target接口和Adaptee实现。在C里常见的写法是public继承Target接口private继承Adaptee类。为什么Adaptee要用private继承因为适配器只需要在内部调用Adaptee的成员不需要把Adaptee的接口暴露给外部private继承正好起到了“实现继承”的作用。下面我用日志场景写一个完整的类适配器// 目标接口 class Logger { public: virtual ~Logger() default; virtual void info(const std::string msg) 0; virtual void error(const std::string msg) 0; }; // 被适配者第三方日志类 class ThirdPartyLogger { public: void log_message(int level, const std::string text) { // 实际的日志写入逻辑比如 level 2 是 info4 是 error } }; // 类适配器 class ThirdPartyClassAdapter final : public Logger, private ThirdPartyLogger { public: void info(const std::string msg) override { log_message(2, msg); // 私有继承的成员在派生类内部可以直接调用 } void error(const std::string msg) override { log_message(4, msg); } };类的代码量确实少编译期就完成了接口绑定调用几乎没有间接跳转开销。但它的局限也明显private继承把ThirdPartyLogger的类型焊死在适配器里如果日志系统后面有多个子类要适配每个子类还得再写一个适配器而且ThirdPartyLogger如果有虚函数想被覆盖在私有继承下也需要额外处理。类适配器更像一个一次性胶带适合确确实实只需要适配这一个类的场景。2.2 对象适配器组合让扩展更灵活对象适配器不再继承Adaptee而是持有Adaptee的实例指针或引用通过成员调用来完成接口转换。这是实际项目中我更推荐的方式因为它遵循了面向对象里的组合优先原则适配器不依赖某个具体类而是依赖一个抽象类型或接口Adaptee的任何子类都能被适配。同样是日志场景对象适配器长这样class ThirdPartyObjectAdapter final : public Logger { public: explicit ThirdPartyObjectAdapter(std::shared_ptrThirdPartyLogger logger) : logger_(std::move(logger)) {} void info(const std::string msg) override { logger_-log_message(2, msg); } void error(const std::string msg) override { logger_-log_message(4, msg); } private: std::shared_ptrThirdPartyLogger logger_; };可以看到适配器把ThirdPartyLogger作为一个构造参数接收进来。这意味着同一个适配器可以适配ThirdPartyLogger及其任意派生类也可以在运行时动态切换被适配对象。如果你需要适配一个已经存在的对象实例而不是每次重新创建一个那对象适配器是唯一的选择。2.3 选型建议什么场景选类适配器什么场景选对象适配器很多人在两种实现之间纠结我根据实际踩坑经验整理了一个对照表维度类适配器对象适配器实现方式public继承Target private继承Adaptee继承Target 持有Adaptee对象依赖形式编译期静态绑定强耦合运行期组合弱耦合能否适配Adaptee的子类不能类型被固定可以传入任意子类对象能否复用已有实例不能每次新建Adapter都新建一个Adaptee可以直接包装传入的实例代码量更少更紧凑稍多一点但结构清晰灵活性低高我个人的选型经验是**默认用对象适配器。**只有在被适配者没有任何运行时多态需求、结构非常简单、且能确保不会出现第二种实现时才为了那点简洁性用类适配器。类适配器那个“继承”看起来快但很容易变成后期扩展的枷锁。还有一个细节无论用哪种适配器Target接口的析构函数一定要是虚的。前几年我接过一个项目Target接口忘了写virtual ~Logger() default;结果通过基类指针删除派生适配器时直接UB内存泄漏排查了大半天。这种低级错误防不胜防建议把虚析构养成习惯。3. C独有的适配器变体模板、函数、RAII与类型擦除C的适配器模式远不止GoF里那两种形态。因为C有模板、lambda、智能指针这些东西适配器的思路可以被玩出很多花样甚至一些你平时没往这上面想的机制本质上就是适配器变体。3.1 模板适配器与类型擦除没有虚函数也能统一接口先看一个模板适配器。假设你要让一个泛型函数能够处理多种图形对象每种图形都有自己的绘制方法矩形叫paint圆形叫drawCircle。经典的虚函数多态要求所有图形继承同一个基类并统一方法名但在模板世界里你可以用适配器把这个约束推迟到编译期。// 直接用模板接受任意类型只要它满足特定语法要求 templatetypename Shape void render(const Shape shape) { // 这里要求传入的Shape有paint()或drawCircle()方法 shape.paint(); }问题是CirclePainter没有paint()只有drawCircle()怎么办你可以写一个小模板适配器把它转换为符合要求的形态class RectPainter { public: void paint() const { /* 绘制矩形 */ } }; class CirclePainter { public: void drawCircle() const { /* 绘制圆形 */ } }; // 针对 CirclePainter 的模板适配器 templatetypename T class CircleAdapter { public: explicit CircleAdapter(const T shape) : shape_(shape) {} void paint() const { shape_.drawCircle(); } private: const T shape_; }; // 调用时统一走 paint() CirclePainter circle; CircleAdapterCirclePainter adapted(circle); render(adapted); // 通过这看起来有点绕但实际生产里很常用。比如STL的迭代器、标准库的各种适配器都是这种“通过模板把不同接口适配成统一语法”的思路。它的最大优势是零虚函数开销编译期就能完成类型检查。如果说模板适配器是在编译期“翻译接口”那么类型擦除就是把这种适配能力提升到运行期。最经典的例子是std::function和std::any但很多人不知道它们内部就是一个“外部多态”适配器。我用一段简化代码展示它的骨架class Drawable { public: // 构造函数是模板接受任意可绘制对象 templatetypename T Drawable(T obj) : model_(std::make_sharedModelstd::decay_tT(std::forwardT(obj))) {} void draw() const { model_-draw(); } private: // 抽象接口藏在内部 struct Concept { virtual ~Concept() default; virtual void draw() const 0; }; // 通过模板模型对外部类型做适配 templatetypename T struct Model final : Concept { explicit Model(T obj) : obj_(std::move(obj)) {} void draw() const override { obj_.draw(); } T obj_; }; std::shared_ptrConcept model_; }; class RectPainter { public: void draw() const { /* 绘制矩形 */ } }; int main() { RectPainter rect; Drawable d(rect); // 不需要RectPainter继承任何东西 d.draw(); }这种写法其实就是一个适配器ModelT把传入的对象适配成了统一的Concept接口外部通过Drawable这个包装类访问被适配对象完全不需要知道自己被适配了。以后你想让一个新类型也能放到Drawable里不用改它的代码只要它有draw()成员直接构造即可。这就是适配器模式“反向应用”的典范。3.2 函数适配器std::function与lambda做到轻量适配有时候你根本不想建一个完整的类来做适配只想把一个回调函数或一段逻辑转换成另一个接口要求的形态。C11之后有了std::function和lambda函数适配器就变得非常实用了。比如项目里原本的回调签名是using EventCallback void(const Event);但是新接的SDK要求注册这样的回调using SdkCallback void(int eventType, const char* payload);你不希望业务代码跟着SDK走于是写一个桥接函数作为适配器// 桥接函数充当适配器 void sdkCallbackBridge(int eventType, const char* payload, void* userData) { auto* callback static_castEventCallback*(userData); Event event; event.type eventType; event.data payload ? std::string(payload) : ; (*callback)(event); }然后用std::function把“业务关心的事件回调”包装成“SDK需要的回调上下文”// 业务侧定义自己的处理函数 void handleEvent(const Event event) { // ... } // 注册给SDK时适配 EventCallback myCallback handleEvent; SdkCallback adapted [](int type, const char* payload, void* ctx) { auto* cb static_castEventCallback*(ctx); Event event; event.type type; event.data payload ? payload : ; (*cb)(event); }; // 传入SDK sdk_register_callback(adapted, myCallback);这种做法不需要定义一个适配器类lambda就是最轻量的适配器。我经常在代码里看到有人为一个小接口差异专门建一个类其实没必要lambda一行就解决了。3.3 RAII适配器把C风格资源接入现代CC项目里大量遇到“C库接口”和“现代C习惯”的冲突。C库要求你手动init和free出错返回错误码现代C要求构造和析构自动管理出错抛异常。这中间天生需要一层适配而RAII就是这个适配器的天然形态。还是拿我最近用的TDengine举例。TDengine的C接口需要你用taos_stmt_init创建一个语句对象之后每次写入都要手动管理生命周期还要检查每个调用返回的错误码。直接裸用这套API业务代码会被错误处理塞满而且稍疏忽就容易泄漏。我写了一个StmtGuard来适配这个C接口#include stdexcept #include string // 假设这些是TDengine C接口的声明 struct TAOS; struct TAOS_STMT; TAOS_STMT* taos_stmt_init(TAOS* taos); int taos_stmt_prepare(TAOS_STMT* stmt, const char* sql, size_t len); int taos_stmt_close(TAOS_STMT* stmt); const char* taos_stmt_errstr(TAOS_STMT* stmt); class StmtGuard { public: StmtGuard(TAOS* taos, const char* sql) : stmt_(taos_stmt_init(taos)) { if (!stmt_) { throw std::runtime_error(taos_stmt_init failed); } if (taos_stmt_prepare(stmt_, sql, strlen(sql)) ! 0) { std::string err taos_stmt_errstr(stmt_); taos_stmt_close(stmt_); stmt_ nullptr; throw std::runtime_error(err); } } ~StmtGuard() { if (stmt_) { taos_stmt_close(stmt_); } } StmtGuard(const StmtGuard) delete; StmtGuard operator(const StmtGuard) delete; StmtGuard(StmtGuard other) noexcept : stmt_(other.stmt_) { other.stmt_ nullptr; } StmtGuard operator(StmtGuard other) noexcept { if (this ! other) { if (stmt_) taos_stmt_close(stmt_); stmt_ other.stmt_; other.stmt_ nullptr; } return *this; } TAOS_STMT* get() const { return stmt_; } private: TAOS_STMT* stmt_; };这个类做的工作核心就是适配把“调用方必须记得close”适配成“析构时自动close”把“返回错误码要逐行检查”适配成“构造失败直接抛异常”。使用方完全不必关心C接口里的资源管理细节。// 业务代码清爽多了 void insertSamples(TAOS* taos, const std::string sql) { StmtGuard stmt(taos, sql.c_str()); // 绑定参数... // taos_stmt_execute(stmt.get()); if (taos_stmt_execute(stmt.get()) ! 0) { throw std::runtime_error(taos_stmt_errstr(stmt.get())); } }有人可能会说“RAII不算设计模式吧”但从结果看它就是适配器C接口是Adaptee现代C的资源管理习惯是TargetStmtGuard就是Adapter。理解这点后你会发现自己其实一直在用适配器模式。3.4 接口适配器与双向适配器两种不常见但实用的变体接口适配器Default Adapter在Java里常见C里用得少但也值得一提。它定义了一个抽象的Adapter类把所有接口先给一个空实现子类只覆盖自己真正关心的那部分方法。在C里用lambda和std::function完全可以替代这个套路所以如果你不是写一个必须继承的抽象框架平时不必刻意去套这个变体。双向适配器则是让两个接口可以互相适配A接口的对象能被当成B接口用B接口的对象也能被当成A接口用。比如两个模块各自定义了User和Account你可以写一个适配器同时实现两个接口内部持有某一方的实例或引用让双方都能通过这个适配器访问对方。这个在系统集成场景里偶尔会遇到不过要注意双向适配器的职责确实比单向适配器重维护成本也高一些不要为了“对称”而硬做双向得看业务是否真的有双向转换需求。4. 三个实战场景适配器变体如何解决真实问题理论讲再多不如看几个能直接抄的思路。下面三个场景都是我在真实项目中处理过或者见过的问题我给出核心代码和设计思路。4.1 场景一让C风格链表直接支持范围for循环项目里有一段历史遗留代码数据结构是C风格的链表templatetypename T struct Node { T data; Node* next nullptr; };业务上想对这段链表做求和、筛选、统计不想写一堆while(node)循环而是希望能像C容器一样使用STL算法和范围for。直接改链表结构会影响所有调用点代价太大。于是我写了一个Range适配器把链表“变成”一个可迭代范围templatetypename T class LinkedRange { public: explicit LinkedRange(NodeT* head) : head_(head) {} class Iterator { public: Iterator(NodeT* node) : node_(node) {} T operator*() const { return node_-data; } Iterator operator() { node_ node_-next; return *this; } bool operator!(const Iterator other) const { return node_ ! other.node_; } private: NodeT* node_; }; Iterator begin() const { return Iterator(head_); } Iterator end() const { return Iterator(nullptr); } private: NodeT* head_; };用法变成Nodeint* head create_list(); LinkedRangeint range(head); for (int value : range) { std::cout value ; } int sum std::accumulate(range.begin(), range.end(), 0);这里的LinkedRange就是一个典型的适配器变体它把“从头到尾遍历节点”这个C风格行为适配成了C标准迭代器协议。链表本身一行没改但能力完全不同了。你可能会问直接用std::vector存一份数据不就行了不行如果链表是外部传给我们的指针复制一份会有性能和一致性问题Range适配器只是提供“视图”不产生额外数据拷贝。4.2 场景二用回调桥接适配兼容SDK签名差异很多C项目要接入C语言写的SDK注册回调函数时必须先满足C的函数指针签名。但业务代码希望用的是C的成员函数、lambda或者std::function。前面我提到过桥接函数这里给一个更完整的封装思路。假设SDK需要using RawCallback void (*)(int code, const char* message, void* userData);业务侧想要注册一个std::functionvoid(std::string, Level)class Notifier { public: // 注册一个业务回调 using BizCallback std::functionvoid(const std::string, int); void Register(BizCallback callback) { callback_ std::move(callback); // userData 直接传 this通过成员函数完成最终适配 SDK_SetCallback(Adapter, this); } private: static void Adapter(int code, const char* message, void* userData) { auto* self static_castNotifier*(userData); if (self-callback_) { self-callback_(message ? message : , code); } } BizCallback callback_; };这个Adapter是静态成员函数满足C函数指针的要求同时它通过userData拿到了类实例又把C风格的回调用参数转换成了C风格的std::function调用。实际项目中要注意userData的生命周期如果SDK回调还可能发生在对象析构之后这会变成悬垂指针。一个稳妥的办法是让Notifier继承std::enable_shared_from_this在静态适配器里先用weak_ptr取锁再转换static void Adapter(int code, const char* message, void* userData) { auto* weak static_caststd::weak_ptrNotifier*(userData); if (auto self weak-lock()) { self-handleMessage(code, message); } }测下来这种适配器几乎都能解决问题代价是注册回调的地方多一次智能指针的拷贝但对业务场景来说微不足道。4.3 场景三用对象适配器RAII封装时序数据库写入这篇文章开头提到的数据采集项目里最核心的一块是写入TDengine。业务层根本不想知道TAOS_STMT怎么prepare、怎么bind、怎么批量执行它们只想调用class DbWriter { public: virtual ~DbWriter() default; virtual void writeBatch(const std::vectorSample samples) 0; };我第一版方案是先做RAII的StmtGuard把语句对象管起来再写一个TaosWriter实现DbWriter接口把TDengine的C接口适配成业务友好的批量写入接口class TaosWriter final : public DbWriter { public: TaosWriter(TAOS* taos, std::string insertSql) : guard_(taos, insertSql.c_str()) {} void writeBatch(const std::vectorSample samples) override { for (size_t i 0; i samples.size(); i) { bindSample(guard_.get(), i, samples[i]); } if (taos_stmt_add_batch(guard_.get()) ! 0) { throw std::runtime_error(taos_stmt_errstr(guard_.get())); } if (taos_stmt_execute(guard_.get()) ! 0) { throw std::runtime_error(taos_stmt_errstr(guard_.get())); } } private: void bindSample(TAOS_STMT* stmt, size_t index, const Sample sample) { // 逐字段调用 taos_stmt_bind_param 完成绑定 // 注意每个字段类型要按TDengine要求设置 } StmtGuard guard_; };这块代码是对象适配器实现DbWriter和RAII适配器StmtGuard的组合应用。业务侧通过std::unique_ptrDbWriter持有它完全不用关心数据库写的是TDengine还是别的库后面如果想替换成其他数据库只需要再写一个DbWriter适配器就行。当时实测下来的效果批量写入从原来每次几十条、需要手写C接口调用的方式变成了调用writeBatch一次传入几百条采样数据代码可读性提升了一大截而且因为StmtGuard关了移动拷贝语句对象泄漏的问题也彻底消失了。5. 适配器模式的常见陷阱与排查经验5.1 适配器膨胀什么时候该停手适配器虽好但用多了你会在代码里看到一层套一层、名字又臭又长的Adapter类。最常见的原因是有的人不看接口本身是否合理遇到不匹配就无脑套一层适配器结果接口上全是“接头”的痕迹。我遇到过最夸张的一个项目一个底层类被三个不同的服务各适配了一遍每个适配器之间还有很多重复逻辑。这种时候适配器已经从“转换器”变成了“垃圾堆”不仅没降低耦合反而让调用链变得极难追踪。我的经验是如果发现某个Target接口需要适配的第三方类超过三个或者适配器里开始出现分支判断来区分不同的被适配者那大概率是该重新设计Target接口本身了而不是继续堆适配器。另外注意适配器和门面模式的区别。适配器解决的是“接口形状不兼容”门面模式解决的是“子系统太复杂需要简化入口”。如果你已经有了一套干净的子接口只是想给外部提供更友好的汇总视图那个不叫适配器那是门面别把两种模式混在一起写不然代码会特别拧巴。5.2 生命周期与所有权引用、值还是智能指针对象适配器最常见的问题就是持有方式选错。持有裸引用被适配对象如果先被销毁适配器调用时就悬垂了持有值会让被适配对象发生拷贝有时候这个拷贝代价很高甚至切掉多态信息持有shared_ptr则意味着你要保证被适配对象本身就是用shared_ptr管理的。我建议遵循一条原则适配器不拥有被适配者除非你明确设计了所有权转移。默认情况下对象适配器应该使用裸指针或引用并约定被适配者的生命周期必须长于适配器。如果无法保证再用shared_ptr。类适配器因为是继承方式生命周期跟随Target本身所以问题不大但如果Adaptee在外部也被引用还是要确认同一份对象的生命周期是不是能对齐。5.3 性能开销虚函数、模板与热路径适配器模式一旦用多性能问题是绕不开的。类适配器和对象适配器在Target接口是纯虚函数的情况下每次调用都会有一次虚函数跳转虽然这个开销通常很小但如果处于每秒百万次调用的热路径上一次间接跳转叠加可能就会影响到吞吐。模板适配器和类型擦除有各自的取舍模板适配器把适配放到编译期零运行时开销但会让代码体积膨胀类型擦除不仅有一次虚函数跳转内部还经常伴随智能指针、甚至堆内存分配如果是高频小对象创建成本明显。所以遇到性能敏感点别本能地套适配器。先分析这条路径是不是热点是热点能改接口就直接改接口不要隔山打牛不是热点优先选代码清晰、容易维护的适配器写法。性能优化要在可靠的测量之后进行不要为了省一个间接跳转把代码搞复杂。5.4 异常安全从错误码到异常的转换细节C接口返回错误码是常态而C项目里更倾向于抛异常。做这层适配的时候资源清理顺序是个大坑。我看到过很多新手在构造适配器时匆匆检查错误就直接throw结果之前分配的资源没释放造成泄漏。正确顺序我在前面的StmtGuard里已经演示过了先检查错误如果失败立刻手动执行清理动作再throw。一定要保证清理动作在throw之前完成否则析构不会被调用资源就卡在那里了。还有一个容易踩的细节析构函数里永远不要抛异常。适配器如果负责释放资源释放失败一般记日志或者静默处理就好在析构里抛异常会直接导致std::terminate整个进程可能就没了。另外把错误码转换成异常时尽量保留原始信息。TDengine有taos_stmt_errstr能拿到错误描述字符串其他库也都有类似接口。不要简单地抛一个std::runtime_error(bind failed)调试的时候你会后悔的。要把错误码、错误描述、甚至SQL语句前几十个字符一起拼进去排查问题效率会高很多。6. 写在最后适配器变体用多了之后的一些体会这几年写C我最大的体会是适配器模式看起来最容易理解但真正用好的关键不在于会不会写Adapter类而在于能不能准确判断“这一层接口不该改那一层接口应该自己重构”。我的习惯是每接一个外部库先安静下来画一张接口对应关系图哪些接口跟业务心智模型一致哪些完全是历史的遗留包袱。对确实不一致的才引入适配器对本身设计就混乱的遗留接口再叠加适配器只会让问题更隐蔽。还有一个小技巧适配器的命名一定要清晰别全叫XxxAdapter。我一般会在适配器名字里带出被适配对象和Target接口的关键信息比如ThirdPartyLoggerToLoggerAdapter长是长了点但三个月后再看代码不用点进文件就知道这层转换是干嘛的。我个人认为深入研究适配器的各种变体后真正的收获并不是多会写几种模式而是学会了一种思维任何两层模块之间都需要一个清晰、可替换的边界适配器就是这个边界最直接的表态。希望我这篇文章里拆解的几种实现思路能给你下次遇到接口不匹配时提供一个马上能落地的参考。