设计模式 09 · 适配器模式

发布时间:2026/8/7 10:42:34
设计模式 09 · 适配器模式 前两篇的代理和装饰器,包装对象是为了加东西——代理加控制、装饰器加功能。这一篇的适配器模式(Adapter)也是包装,但目的完全不同:它包装一个对象,不是为了增强它,而是为了改变它的接口长相,让原本对接不上的两个东西能协作起来。一句话——适配器是个转接头。这个比喻几乎不用解释。你的笔记本是 Type-C 口,酒店墙上是国标插座,中间插一个转接头,两边就通了。转接头没给你的电脑增加任何功能,它只干一件事:把一种接口形状,转换成另一种接口形状。适配器模式在代码里干的就是这个——当你手上有一个现成的类(功能正合适),但它的方法签名/接口和你的系统要求的对不上时,你不去改它(可能也改不了,比如它是第三方库),而是写一个适配器夹在中间做转换。这一篇我们用一个特别真实的场景:对接第三方物流 API。你的系统里定义好了一套统一的物流接口,但顺丰、京东的 SDK 各有各的方法名和参数,跟你的接口完全对不上。适配器就是那个让它们能插进你系统的转接头。我们会讲清适配器的两种实现——对象适配器(靠组合)和类适配器(靠继承),以及它们的取舍;最后把适配器、装饰器、代理这三个长得很像的包装模式彻底辨析清楚,给结构型模式的包装家族做个小结。这篇文章按这条线索展开:先摆出接口对不上的具体难题;再引出适配器这个转接头的思路;然后分别实现对象适配器和类适配器,比较两者;接着看标准库和 Spring 里的真实身影;最后把三个包装模式(适配器/装饰器/代理)放在一起辨析,并给出适配器的适用边界。贯穿例是物流对接。目录一个接口对不上的难题适配器模式:夹在中间的转接头对象适配器:靠组合做转换类适配器:靠继承,以及两者的取舍现实身影:标准库与 Spring 里的适配器三个包装模式辨析:适配器 vs 装饰器 vs 代理什么时候用适配器一、一个接口对不上的难题先看场景。我们的订单系统在发货时,需要调用物流下单。为了不和具体某家快递绑死,系统里定义了一个统一的物流接口,业务代码只面向它编程:// 我方系统定义的统一物流接口publicinterfaceLogisticsService{Stringship(StringorderNo,Stringaddress);// 统一的下单方法}现在要接入顺丰。但顺丰给的 SDK 长这样——它是第三方的,你没法修改,而且方法名、参数和你的接口完全不一样:// 顺丰 SDK(第三方,不可修改)publicclassSfExpressSdk{// 方法名、参数、返回值全都和 LogisticsService 对不上publicSfResultcreateOrder(SfRequestrequest){System.out.println(顺丰下单:request);returnnewSfResult(SFSystem.currentTimeMillis());}}问题就摆在这:你的业务代码想调logisticsService.ship(orderNo, address),但顺丰只有createOrder(SfRequest)。两者功能是匹配的(都是物流下单),但接口形状对不上——方法名不同、参数结构不同、返回值不同。有人会想:那我改业务代码,直接调顺丰的createOrder不就行了?这么做有两个大问题:其一,业务代码就和顺丰焊死了,以后换京东、加菜鸟,业务代码得跟着改一遍,违反了当初定义统一接口的初衷;其二,顺丰 SDK 你根本改不了,它是个 jar 包。我们真正想要的是:让顺丰 SDK 能伪装成一个LogisticsService,插进我们的系统,而业务代码和顺丰 SDK 都不用改。中间需要一个转换层——这就是适配器。二、适配器模式:夹在中间的转接头适配器的思路直白得很:写一个新类,让它实现我方要求的接口(LogisticsService),在实现方法的内部,把调用翻译、转发给被适配的对象(顺丰 SDK)。这个夹在中间做翻译的类,就是适配器。它的三个角色:目标接口(Target):客户端期望的接口,我方的LogisticsService;被适配者(Adaptee):已存在的、接口不兼容的类,顺丰SfExpressSdk;适配器(Adapter):实现 Target 接口,内部调用 Adaptee,完成两者之间的转换。用一张图看清这个转接头夹在中间的位置:图里最关键的是那条翻译路径:客户端 → 目标接口 → 适配器(翻译)→ 被适配者。客户端始终只跟目标接口打交道,压根不知道背后是顺丰还是京东;被适配者也保持原样,不知道自己被谁包了。适配器把接口不匹配这个脏活全揽在自己身上。那么这个适配器具体怎么写?根据适配器怎么持有被适配者的方式不同,有两种实现:用组合(对象适配器)和用继承(类适配器)。我们分别看。三、对象适配器:靠组合做转换对象适配器:适配器持有一个被适配者的实例(组合),在实现目标接口方法时,调用这个实例并做参数/返回值的转换。publicclassSfLogisticsAdapterimplementsLogisticsService{privatefinalSfExpressSdksfSdk;// 组合:持有被适配者publicSfLogisticsAdapter(SfExpressSdksfSdk){this.sfSdksfSdk;}OverridepublicStringship(StringorderNo,Stringaddress){// —— 转换:把我方参数,翻译成顺丰要的格式 ——SfRequestrequestnewSfRequest();request.setBizOrderNo(orderNo);request.setReceiverAddr(address);SfResultresultsfSdk.createOrder(request);// 调用被适配者// —— 转换:把顺丰的返回,翻译成我方要的格式 ——returnresult.getWaybillNo();}}用起来,业务代码完全无感——它拿到的就是一个标准LogisticsService:LogisticsServicelogisticsnewSfLogisticsAdapter(newSfExpressSdk());Stringwaybilllogistics.ship(NO123,北京市朝阳区);// 像调自家接口一样好处很清楚:业务代码只依赖LogisticsService,顺丰 SDK 原封不动,两者的差异被SfLogisticsAdapter这个转接头彻底吸收。以后要接京东,再写一个JdLogisticsAdapter implements LogisticsService就行,业务代码一个字不改——这又是开闭原则的体现。对象适配器是实战中的首选,因为它靠组合,灵活性高:一个适配器可以适配被适配者及其子类;甚至一个适配器可以同时持有多个被适配者,把它们的能力组合起来对外提供。这也再次呼应了组合优于继承。那还有一种类适配器是干嘛的?它换用继承来实现,有它的特点,也有明显的局限。四、类适配器:靠继承,以及两者的取舍类适配器:适配器继承被适配者、同时实现目标接口。这样它自己就是一个被适配者(继承来了它的方法),又对外表现为目标接口。// 类适配器:继承被适配者 实现目标接口publicclassSfLogisticsClassAdapterextendsSfExpressSdkimplementsLogisticsService{OverridepublicStringship(StringorderNo,Stringaddress){SfRequestrequestnewSfRequest();request.setBizOrderNo(orderNo);request.setReceiverAddr(address);SfResultresultthis.createOrder(request);// 直接调用继承来的方法returnresult.getWaybillNo();}}区别就在那个this.createOrder(...)——因为继承了SfExpressSdk,适配器可以直接调用父类方法,不用再持有一个实例。但类适配器有一个 Java 特有的硬伤:Java 是单继承。适配器一旦extends SfExpressSdk,就用掉了它唯一的一次继承机会,再也没法继承别的类了。这意味着:它只能适配SfExpressSdk这一个具体类,没法像对象适配器那样灵活地适配一批相关的类;如果被适配者是final类,它连继承都做不到,类适配器直接失效。把两者对比一下:对象适配器(组合)类适配器(继承)持有被适配者的方式组合(持有实例)继承(extends)灵活性高,可适配子类、可组合多个低,只能适配一个具体类受单继承限制否是,用掉唯一继承名额能否适配 final 类能不能推荐度首选少用结论很明确:优先用对象适配器。它靠组合,更灵活、限制更少,也符合组合优于继承。类适配器只在极少数场景(比如你确实需要重写被适配者的某些方法)才考虑,大多数时候用不上。你会发现,结构型模式一路走来,组合优于继承这条原则反复地在为我们做选择——代理、装饰器、适配器,能用组合的地方,组合几乎总是更好的那个答案。五、现实身影:标准库与 Spring 里的适配器适配器在标准库里非常常见,尤其在新旧接口衔接不同体系对接的地方:InputStreamReader:这是适配器的经典案例。InputStream是字节流(一次读一个字节),Reader是字符流(一次读一个字符),两者接口不兼容。InputStreamReader就是把InputStream适配成Reader的适配器——它持有一个InputStream(对象适配器),内部按指定字符集把字节转成字符。new InputStreamReader(inputStream, UTF-8)这行你写过无数次的代码,就是在插一个字节转字符的转接头。Arrays.asList():把数组这个体系适配成List这个体系,让数组能用 List 的接口来访问。java.io里的各种XxxAdapter、Swing/AWT 的事件适配器(如MouseAdapter,给你一个空实现好让你只重写关心的方法)。Spring MVC 的HandlerAdapter:这是框架级的精彩应用。Spring MVC 支持多种风格的 Controller(注解式RequestMapping、实现Controller接口的、HttpRequestHandler的……),它们的调用方式各不相同。DispatcherServlet不可能为每种都写一套 if-else,于是引入HandlerAdapter——为每种 Controller 配一个适配器,DispatcherServlet只面向HandlerAdapter这个统一接口调用,由适配器把它翻译成对具体 Controller 的调用。这让 Spring MVC 能优雅地兼容多种 Controller 风格,是适配器统一异构接口能力的绝佳示范。一个规律:凡是你看到让一个老的/异构的/第三方的东西,能用我这套标准接口来调用的地方,背后大概率就是适配器。六、三个包装模式辨析:适配器 vs 装饰器 vs 代理到这里,结构型模式里三个长得像的包装模式——代理、装饰器、适配器——都讲完了。它们的代码骨架确实相似(都持有一个对象、都对外提供方法、都在中间做点事),极易混淆。用一张表把它们的意图彻底分开,这是结构型包装家族最该记住的一张对照:模式一句话意图接口是否改变典型信号代理控制对对象的访问不变(代理和真实对象同接口)加日志/权限/事务,你无感装饰器动态增强对象的功能不变(装饰和被装饰同接口)层层叠加新能力,你主动包适配器转换对象的接口改变(把 A 接口转成 B 接口)接口对不上,做个转接头最关键、也最好用的一条区分判据是——看接口变没变:代理和装饰器:接口不变。它们包装前后,对外的接口是同一个(都实现Order),包装是透明的,调用方用起来和原来一样,区别只在多了控制或多了功能。适配器:接口改变。它的全部使命就是把接口 A 变成接口 B,输入一种接口,输出另一种接口。接口发生转换,是适配器区别于另外两个的根本标志。再叠加上一篇那条内部对象谁给的判据,三者就彻底清晰了:接口变了 → 适配器;接口没变、你主动层层包着加功能 → 装饰器;接口没变、它替你挡在前面做管控 → 代理。结构相似是表象,意图(尤其接口变不变)才是它们各自的身份证。七、什么时候用适配器老规矩,泼冷水。适配器虽然实用,但它本质上是一种补救——它的存在,往往意味着系统里有接口不兼容的历史包袱。所以对它要有个清醒的态度。适合用适配器的信号:你想复用一个现成的类,它的功能正合适,但接口和你的系统对不上;这个类你改不了(第三方库、遗留系统、别的团队的代码),或者不该改;你需要让多个异构的实现(不同快递、不同 Controller 风格)统一到一套接口下。要警惕的信号:别把适配器当成接口设计烂的遮羞布。如果两个接口本可以一开始就设计一致,却因为随意而对不上,那正确的做法是把接口设计好,而不是事后到处贴适配器。适配器是用来对接你无法控制的外部,而不是用来给你自己能控制的内部混乱打补丁。如果一个系统里适配器满天飞,那通常是个信号:接口抽象出了问题,该回头审视设计,而不是继续加转接头。一句话:适配器是接纳既成事实的不兼容的优雅方案,但不是纵容本可避免的不兼容的借口。用它来对接外部世界,而不是掩盖内部的设计债。小结。适配器模式是个转接头:当现成的类功能合适、但接口对不上时,它夹在中间做接口转换,让两者协作,而双方都不用改。它有对象适配器(靠组合,首选)和类适配器(靠继承,受单继承所限,少用)两种实现——又一次,“组合优于继承帮我们做了选择。InputStreamReader(字节流转字符流)、Arrays.asList、Spring MVC 的HandlerAdapter,都是它的经典身影。而它和代理、装饰器最本质的区别,就一条:适配器改变接口,另外两个不改。至此,结构型的三个包装模式齐了。下一篇我们转向另一类结构型——不再是包装一个对象”,而是组织一群对象:当你面对一个树形结构(比如订单里嵌套着套餐、套餐里又嵌着子商品)时,组合模式能让你用统一的方式处理单个和一组。