
做C开发这些年适配器模式是我用得最频繁的几个设计模式之一。不管是接手老项目、接入第三方SDK还是重构代码时统一接口几乎都会碰到“接口长得不一样但干的事差不多”的情况。适配器模式就是专门干这个事的把不兼容的接口包装成调用方期望的样子让原本八竿子打不着的模块能正常协作。这篇文章我结合几个真实项目里遇到的场景把适配器模式在C里的两种实现方式、典型实战案例、容易踩的坑一次性说透。不管你是刚学C的新手还是已经带项目的老人只要跟接口对接、SDK接入这类活儿打过交道这篇都值得花几分钟读一遍。我会用完整的可编译代码来演示配合详细注解说明让你看完就能直接在自己项目里用起来。1. 适配器模式的核心思路这到底在解决什么问题1.1 接口不匹配的日常一个真实场景的引入先看一个我前段时间处理的例子。项目里有一套老的数据读取组件提供一个字符流接口每次调用ReadLine()返回一行字符串用IsEOF()判断是否读完。这个组件内部封装了不少底层的文件解析逻辑线上也跑了好几年很稳定没人敢动它。后来新系统要接入这个组件但新系统的上层模块定义的统一接口是ILineReader用nextLine()取下一行用hasNext()判断是否还有数据。麻烦就来了老组件里没有hasNext()新接口里没有ReadLine()名字对不上语义也差一点。硬改老组件风险大硬改新接口等于让所有上层调用方迁就旧时代的设计更不合理。这时候适配器模式就是最优解。它做的事情用一句话概括在目标接口Target和已有实现Adaptee之间加一个适配器Adapter把已有实现“翻译”成目标接口。翻译的意思是适配器实现目标接口的所有方法方法内部调用已有实现的方法完成语义和参数的转换。新系统只认识ILineReader适配器一边实现ILineReader一边持有老组件对象两头都不得罪。1.2 适配器模式的三个角色与生活化类比为了便于记忆把适配器模式涉及的角色拆开看就三个角色对应代码中的位置职责Target目标接口新系统依赖的抽象接口定义调用方期望的方法签名Adaptee被适配者已经存在的旧实现提供实际功能但接口不匹配Adapter适配器中间层实现Target内部调用Adaptee生活化类比最常用的是电源转换头。你去国外出差带的笔记本电脑插头是国标的酒店插座是欧标的直接插不进去。转换头的作用是什么它左边提供国标插孔让你电脑插上去右边是欧标插脚插进墙上插座。两边都不需要改转换头完成“物理接口”的转换。适配器模式就是这个转换头目标接口是转换头提供给业务方的插孔被适配者是墙上的插座适配器自己负责中间那层转接。1.3 为什么要用适配器可测试性、隔离性与低成本很多人问接口不匹配就不能直接改旧接口吗能但代价不同。老系统往往承载着历史逻辑可能被多个模块调用改动一个函数签名就可能导致线上事故。而适配器模式把改动限制在一个新类中旧代码完全不动新代码只依赖抽象接口。这种隔离性带来的最大好处是可测试性测试时我可以传一个模拟的ILineReader进去不需要依赖真实的文件、网络或硬件资源。设计模式解决的不是代码能不能跑的问题而是代码能不能低成本地维护和演进。2. 两种经典实现类适配器与对象适配器的取舍在C里适配器模式有两种主流实现方式一种基于继承叫类适配器一种基于组合叫对象适配器。初学的人容易纠结选哪个实际工作中也经常有人用错。先看它们的写法差异和适用场景。2.1 类适配器用多重继承做接口转换类适配器通过继承同时获得目标接口和已有实现C支持多重继承所以这种写法在C里天然可行。直接把Adapter同时继承自ILineReader和LegacyStringStream在重写的目标接口方法里调用从老实现继承来的方法。#include iostream #include string // Target新系统期望的接口 class ILineReader { public: virtual ~ILineReader() default; virtual std::string nextLine() 0; virtual bool hasNext() const 0; }; // Adaptee老组件接口不匹配 class LegacyStringStream { public: explicit LegacyStringStream(const std::string text) : data_(text) {} std::string ReadLine() { if (IsEOF()) return ; size_t pos data_.find(\n, cursor_); if (pos std::string::npos) { pos data_.size(); } std::string line data_.substr(cursor_, pos - cursor_); cursor_ pos 1; return line; } bool IsEOF() const { return cursor_ data_.size(); } private: std::string data_; size_t cursor_{0}; }; // Adapter类适配器同时继承Target和Adaptee class LegacyStreamAdapter : public ILineReader, private LegacyStringStream { public: explicit LegacyStreamAdapter(const std::string text) : LegacyStringStream(text) {} std::string nextLine() override { return ReadLine(); } bool hasNext() const override { return !IsEOF(); } }; int main() { std::string input hello\nworld\nc; ILineReader* reader new LegacyStreamAdapter(input); while (reader-hasNext()) { std::cout reader-nextLine() std::endl; } delete reader; return 0; }这段代码麻雀虽小五脏俱全。注意我继承了ILineReader用的是公有继承因为调用方要通过基类指针操作继承LegacyStringStream用的是私有继承这点很关键——私有继承意味着外部世界看不到适配器拥有字符串流的能力适配器只在内部复用其实现这符合接口隔离的设计原则。初学C多重继承的朋友容易忽略继承方式统统一律public结果适配器被当成本科生闷葫芦不暴露内部也算歪打正着但职责边界就不那么清晰了。类适配器最大的限制是老实现必须能被继承并且老类的构造行为要可控。如果老组件是final类C11后支持final关键字或者它不是继承体系的一部分类适配器就施展不开。另外一旦同时继承的基类里有同名方法还要处理二义性问题这些都是实战中要留神的。2.2 对象适配器组合优先于继承的现代C实践对象适配器的思路更简单适配器作为目标接口的一个实现类内部持有一个被适配者对象通常用指针或引用所有目标接口的方法都转发给内部那个对象。#include iostream #include memory #include string class ILineReader { public: virtual ~ILineReader() default; virtual std::string nextLine() 0; virtual bool hasNext() const 0; }; class LegacyStringStream { public: explicit LegacyStringStream(const std::string text) : data_(text) {} std::string ReadLine() { /* 同上 */ return ; } bool IsEOF() const { return true; } private: std::string data_; size_t cursor_{0}; }; class LegacyStreamAdapter : public ILineReader { public: // 通过构造函数注入被适配者组合关系 explicit LegacyStreamAdapter(std::shared_ptrLegacyStringStream stream) : stream_(std::move(stream)) {} std::string nextLine() override { return stream_-ReadLine(); } bool hasNext() const override { return !stream_-IsEOF(); } private: std::shared_ptrLegacyStringStream stream_; }; int main() { auto stream std::make_sharedLegacyStringStream(hello\nworld); ILineReader* reader new LegacyStreamAdapter(stream); delete reader; return 0; }对象适配器为什么在多数场景下更推荐因为它脱胎于“组合优于继承”这条面向对象设计的经验法则。对象适配器只依赖被适配者的公开接口不要求它可继承被适配者也可以替换成任何符合接口的子类实现。这样适配器与老实现之间的耦合更松散。C里配合std::shared_ptr或std::unique_ptr管理所有权在构造时注入依赖后续还可以替换不同实现灵活性明显占优。2.3 两种方式对比实战选型建议维度类适配器对象适配器实现基础多重继承成员对象 转发能否适配final类不能可以耦合程度继承是强耦合组合是弱耦合灵活性较差较好代码量略少稍多适用场景适配逻辑简单、老实现可继承大多数接口对接场景我自己的选型习惯是默认优先考虑对象适配器除非适配逻辑非常直接且我确实能控制老类的继承关系才用类适配器。C里多重继承的坑比网上教程写出来的更多尤其是几个基类都有同名虚函数时的覆盖规则排查起来很耗时间。3. 实战案例把老式随机数接口适配成统一随机数接口3.1 场景设定游戏项目中为什么需要这个适配器搜索热词里“c随机数”“c小游戏”都很火我恰好在一个小游戏项目里处理过一个很典型的适配问题。项目里原先用C库的rand()生成随机数外围包了一层OldRandomGenerator直接返回0到RAND_MAX的整数。新方案为了测试可控设计了一个统一随机数接口IRandomSource上层不关心随机数从哪来只关心能拿到[min, max]区间的整数或者[0, 1]的浮点数。这时候适配器模式的价值就完全体现出来了不用改老随机数组件游戏上层也不用感知底层接口的龃龉。只需要写一个新的适配器让IRandomSource的语义在老组件基础上计算出来。3.2 完整实现对象适配器 现代C写法#include iostream #include memory #include chrono #include functional #include random // Target统一随机数接口 class IRandomSource { public: virtual ~IRandomSource() default; // 返回 [min, max] 闭区间内的整数 virtual int randomInRange(int min, int max) 0; // 返回 [0.0, 1.0) 范围内的浮点数 virtual double random01() 0; }; // Adaptee老式随机数生成器 class OldRandomGenerator { public: virtual ~OldRandomGenerator() default; virtual int Next() 0; // 返回 [0, RAND_MAX] virtual void Seed(unsigned int seed) 0; }; // 老实现A使用C库 rand() class CRandomGenerator : public OldRandomGenerator { public: int Next() override { return rand(); } void Seed(unsigned int seed) override { srand(seed); } }; // Adapter对象适配器 class RandomAdapter : public IRandomSource { public: explicit RandomAdapter(std::unique_ptrOldRandomGenerator generator) : generator_(std::move(generator)) { // 使用系统时钟种子初始化 auto now std::chrono::system_clock::now(); auto ns std::chrono::duration_caststd::chrono::nanoseconds( now.time_since_epoch()).count(); generator_-Seed(static_castunsigned int(ns)); } int randomInRange(int min, int max) override { if (min max) { std::swap(min, max); } int interval max - min 1; int raw generator_-Next(); // 这里按比例映射而非取模尽量避免模运算偏差 double scale static_castdouble(raw) / (RAND_MAX 1.0); return min static_castint(scale * interval); } double random01() override { int raw generator_-Next(); return static_castdouble(raw) / (RAND_MAX 1.0); } private: std::unique_ptrOldRandomGenerator generator_; }; int main() { auto generator std::make_uniqueCRandomGenerator(); auto randomSource std::make_uniqueRandomAdapter(std::move(generator)); // 上层只和 IRandomSource 打交道 for (int i 0; i 10; i) { std::cout randomSource-randomInRange(1, 6) ; } std::cout std::endl; return 0; }3.3 关键细节说明为什么这样写有几个细节值得展开聊。第一个是构造函数的依赖注入方式。适配器不自己去new一个生成器而是要求调用方把生成器传进来这是依赖反转思想的体现。上层代码可以传入真实的老组件测试代码可以传入一个固定序列的假生成器适配器本身不关心底层到底是谁。第二个是randomInRange的映射算法。很多入门代码喜欢用取模运算raw % (max - min 1)但rand()的范围通常不是interval的整数倍取模会产生分布偏差。我用浮点乘法做映射同时确保结果不会越界。这只是随机数生成中的一个小优化但适配器是“翻译”层翻译时把旧接口的“糙”顺手修正一下也是适配器常见的职责扩展。第三个是std::unique_ptrOldRandomGenerator的所有权管理。C里适配器内部持有被适配者时必须明确谁释放、何时释放。用智能指针可以避免手工delete导致的内存泄漏和重复释放。如果你用裸指针一定要在适配器析构函数里释放并且注意拷贝构造和赋值运算的问题否则容易踩双删除的坑。3.4 现代C进阶用 std::function 做轻量适配器有时候适配逻辑很轻不值得专门写一个类。比如只需把任意一个函数适配成IRandomSource接口可以直接借助std::function和 lambda 做一个通用适配器。#include functional #include iostream #include memory class IRandomSource { public: virtual ~IRandomSource() default; virtual int randomInRange(int min, int max) 0; virtual double random01() 0; }; class FunctionRandomAdapter : public IRandomSource { public: using RangeFunc std::functionint(int, int); using UnitFunc std::functiondouble(); FunctionRandomAdapter(RangeFunc range, UnitFunc unit) : range_(std::move(range)), unit_(std::move(unit)) {} int randomInRange(int min, int max) override { return range_(min, max); } double random01() override { return unit_(); } private: RangeFunc range_; UnitFunc unit_; }; int main() { // 用现代C标准库组件实现随机数 auto adapter std::make_uniqueFunctionRandomAdapter( [](int min, int max) { std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distributionint dist(min, max); return dist(gen); }, []() { std::random_device rd; std::mt19937 gen(rd()); std::uniform_real_distributiondouble dist(0.0, 1.0); return dist(gen); } ); for (int i 0; i 5; i) { std::cout adapter-randomInRange(1, 100) ; } std::cout std::endl; return 0; }这个写法的好处是适配器的构造直接被std::function参数化老代码的兼容层只需要一行lambda就能完成特别适合临时性的、一次性的适配任务。代价是比显式类适配器更难调试lambda内部出错时调用栈不那么直观所以我会在适配逻辑有复用可能的场景优先用正式类在一次性胶水代码里才用lambda方案。4. 实战案例统一字符串读取接口与类适配器的注意点4.1 场景设定老代码接口的兼容需求前面的随机数适配器用了对象适配器这一节我演示一下类适配器在真实项目里的完整用法。接一个老旧的日志解析模块它暴露的接口是std::vectorstd::string GetAllLines()一次性返回所有行。新模块需要一个逐行遍历的接口以便在数据量很大时边读边处理避免一次性加载过多内容。新旧接口差异不大旧的是一次性全量新的是增量流式读取。显然可以把旧的全量接口包装成新的流式接口先全量取出存入内部队列每次nextLine()从队列弹出一个元素。这个需求用类适配器写起来很自然。4.2 完整实现类适配器代码#include iostream #include string #include vector #include deque // Target逐行读取接口 class ILineReader { public: virtual ~ILineReader() default; virtual std::string nextLine() 0; virtual bool hasNext() const 0; }; // Adaptee老接口一次性返回全部行 class LegacyLogProvider { public: virtual ~LegacyLogProvider() default; virtual std::vectorstd::string GetAllLines() 0; }; // 一个具体实现 class FileLogProvider : public LegacyLogProvider { public: explicit FileLogProvider(std::vectorstd::string lines) : lines_(std::move(lines)) {} std::vectorstd::string GetAllLines() override { return lines_; } private: std::vectorstd::string lines_; }; // Adapter类适配器同时继承目标接口和老接口 class LogLineReaderAdapter : public ILineReader, private LegacyLogProvider { public: explicit LogLineReaderAdapter(std::vectorstd::string lines) : LegacyLogProvider() { // 不能在初始化列表里调用虚函数所以放到构造函数体里 for (const auto line : lines) { lines_.push_back(line); } } std::string nextLine() override { if (lines_.empty()) return ; std::string line lines_.front(); lines_.pop_front(); return line; } bool hasNext() const override { return !lines_.empty(); } private: std::dequestd::string lines_; }; int main() { std::vectorstd::string raw {line1, line2, line3}; ILineReader* reader new LogLineReaderAdapter(raw); while (reader-hasNext()) { std::cout reader-nextLine() std::endl; } delete reader; return 0; }4.3 类适配器的坑构造函数里别调用虚函数上面代码里有一个容易犯的错误我特意在注释里标出来了如果LogLineReaderAdapter希望在构造函数里调用从老接口继承的GetAllLines()来填充数据就必须想清楚一个问题——构造函数执行期间对象还不是完整派生类对象虚函数调用不会分发到派生类重写版本。也就是说若我在适配器构造函数里直接调GetAllLines()而GetAllLines()是一个虚函数且当前对象在构造阶段C会调用基类版本如果基类没有合理默认实现行为就不符合预期。所以我在实现里选择在构造函数参数中接收已有的行数据然后再复制到内部队列避免在构造函数期间触发虚函数调用。这是类适配器实战中非常隐蔽但影响巨大的一个坑。4.4 什么时候别用类适配器类适配器还有一个天生的限制如果老接口是final类或者老接口的方法依赖严格的状态机而继承后动不了就完全不能用继承方案。另外如果适配器不止适配一个老实现比如要同时适配LogProviderA和LogProviderB类适配器很难同时继承两个实现再各自转发这时候对象适配器通过构造函数注入任意LegacyLogProvider子类就轻松很多。所以我的经验是类适配器适合适配逻辑简单、被适配者本身就在一个可扩展的继承体系中的场景。但凡被适配者的类型不确定或者将来可能要扩展为适配多个实现都倾向对象适配器。5. 适配器模式与外观模式、代理模式的界限区分设计模式学到最后最头疼的不是记不住某个模式的代码而是两个模式看起来很像不知道用哪个。适配器模式经常和外观模式、代理模式放在一起比较我在这里用一个简单的方式帮大家分清。5.1 三者对比意图决定归属适配器模式解决的痛点是“接口不兼容”它改变接口不改变功能。外观模式解决的痛点是“子系统太复杂、太分散”它提供一个简化后的统一入口让调用方不用关心内部多个类之间的协作关系。代理模式解决的痛点是“访问控制、延迟加载、日志记录等横切需求”它保持接口不变在原有调用前后加一层处理。举一个生活例子适配器是电源转换头接口变了电流还是那个电流外观是酒店前台你想吃饭、订票、叫车只需要对前台说一句话前台帮你协调各个部门代理是公司助理你打给老板的电话先被助理接听助理判断该不该转接转接前后还可能录音、登记。5.2 一张表看清三个模式模式接口是否变化功能是否增强典型适用场景适配器模式变化基本不变新旧系统接口对接外观模式简化是接入更简单复杂子系统统一门面代理模式不变是增加控制/缓冲远程调用、权限控制、懒加载很多 C 面试题喜欢专门考这个区别因为工作多年的人也容易答混。核心记忆点就一句话看接口变不变、为什么变。接口变了、为了对齐两边——适配器接口变了、为了简化使用——外观接口没变、为了管控过程——代理。6. 常见问题与排查技巧实录6.1 “抽象类不能实例化”的编译错误初写适配器最常见的一个编译错误明明继承了一个接口却还是提示“cannot instantiate abstract class”。原因通常是漏掉了某个纯虚函数的重写。C不像Java那样会明确告诉你“你的Adapter缺少randomInRange方法”它只会含糊地报“抽象类无法实例化”。排查方法是在适配器类里把继承来的纯虚函数全部列出逐一对照。更高效的做法是用override关键字编译器看到override会校验确实重写了基类的虚函数一旦写错方法名直接报错。我建议所有适配器代码里重写目标接口的方法一律加override这是低成本高收益的习惯。6.2 多重继承的二义性同名方法的冲突类适配器里同时继承目标类和被适配者如果两个基类都有一个同名方法比如都有init()调用init()时编译器就不知道你指的是哪一个产生二义性。解决办法要么在适配器里显式声明using Base::init;要么在适配器内部再定义一个私有方法屏蔽基类方法的直接可见性。这里特别提醒C多重继承很容易出问题两个基类都带状态的虚函数也可能造成内存布局上的复杂度。遇到这种行为最省心的方法就是切换到对象适配器。别跟编译器较劲成年人的设计世界里“打不过就换方案”。6.3 调试与排查技巧适配器层加上日志适配器里最容易出现的运行时问题集中在数据转换逻辑上边界不对、空指针、数值越界。我习惯在适配器构造函数入口和关键的转换方法里打印日志确认被适配者的输入和输出是否符合预期。int randomInRange(int min, int max) override { int raw generator_-Next(); double scale static_castdouble(raw) / (RAND_MAX 1.0); int result min static_castint(scale * (max - min 1)); // 调试日志确认转换前后 std::cerr [RandomAdapter] raw raw scale scale result result std::endl; return result; }线上环境不需要时可以把日志摘掉但开发阶段这层日志能省很多时间。适配器夹在两个系统之间出错时两边背锅有日志就能快速判断是上游没给对还是下游理解错了。6.4 常见问题速查表问题现象根本原因解决对策抽象类无法实例化漏重写一个纯虚函数用 override 让编译器帮忙检查多重继承同名方法冲突基类间有同名成员用 using 声明或改对象适配器运行时崩溃析构时双重释放裸指针在多处释放统一用 unique_ptr 管理所有权边界值不对如 max 取不到映射算法错误用浮点缩放替代取模仔细检查 -1构造函数里调虚函数行为怪异构造期虚调用不发散到派生类构造函数外再初始化或传入数据我项目里遇到过最哭笑不得的一次问题适配器转发数据时把“取反”逻辑写反了导致新老模块数据都对不上号排查了两小时才发现是适配器方法里一个符号写成了。这类问题靠日志梳理流转值很快就能定位。个人经验适配器模式要克制地使用适配器模式很好用但跟所有设计模式一样不能滥用。我见过一些项目把所有地方都加适配器结果整个系统里到处都是XxxAdapter层层包裹调用链深不见底业务逻辑淹没在无意义的接口转换里。适配器模式应该在“确实存在不兼容且确实有下列任一条需求”的时候才使用两方都不方便改、代码需要可测试的抽象隔离、你希望把替换底层实现的影响限制在一个局部。另一个体会是能用对象适配器尽量别用类适配器尤其在团队协作的项目里。类适配器靠多重继承实现C多重继承的设计语义对很多同事并不友好代码可读性相对差。对象适配器的代码更像“员工转发”人人看得懂维护成本低。如果你在准备C面试被问到设计模式时适配器模式是一个特别容易展开的话题。可以先讲本质接口转换再讲两种实现的区别再随手举一个适配rand()到IRandomSource的小例子面试官很难不认可这种有代码支撑的理解深度。实际写代码的时候记住一句话就够了别让调用方去迁就老接口也别让老代码去迁就新世界中间加一层翻译官所有的体面就都有了。