设计模式修炼之路 ⑥ 适配器模式——让不兼容的接口握手
类适配器 vs 对象适配器、在 Spring MVC 里找它
目录
上一篇《设计模式修炼之路-原型模式》收官了创建型模式:单例、工厂、建造者、原型,四种"造对象"的姿势全部到齐。从这篇起进入结构型模式——关注的不再是"怎么造对象",而是"类和对象之间怎么组装搭配",一共八位选手。开篇登场的,就是生活里最常见的那个小玩意:适配器模式(Adapter Pattern)。手里攥着三相插头、墙上却是两孔插座时,你第一个想到的就是它。
一、什么是适配器模式?
先看定义:
将一个类的接口转换成客户期望的另一个接口,使原本接口不兼容而不能一起工作的类可以一起工作。
翻译成人话:接口对不上时,谁也不改,中间加一层转换器。
生活类比不需要想象力:家里墙上插座输出 220V 交流电,手机电池只吃 5V 直流电。你不会去砸墙重新布线(改造供电方),也不会把手机拆开改电路(改造用电方),而是买一个充电头(适配器)夹在中间,一头插墙上、一头接手机,各说各的语言,中间有人翻译。GoF 给它起的别名就很直白——包装器(Wrapper)。
适配器模式有三个角色:
- Target(目标接口):客户端想要的接口形态,上例里就是"输出 5V";
- Adaptee(被适配者):已经存在、但接口对不上的类,上例里就是"输出 220V"的插座;
- Adapter(适配器):中间那层转换器,把 Adaptee 的能力翻译成 Target 的样子。
按实现方式又分三种形态:类适配器(靠继承)、对象适配器(靠组合)、接口适配器(靠空实现兜底)。前两种是主角,第三种是衍生玩法,第四节全部实现。
二、适配器模式的优缺点
优点
- 两头都不改。Target 不动、Adaptee 不动,中间加一层就能协同工作,完美符合开闭原则——对扩展开放,对修改关闭。
- 复用存量代码。老系统的类、第三方的 SDK,包一层就能接入新体系,是"接盘"遗留代码改造成本最低的方式。
- 对客户端透明。客户端只认 Target 接口,Adaptee 被彻底藏在适配器背后,换实现客户端毫无感知。
- 对象适配器还支持运行时换被适配者,构造函数传谁就适配谁。
缺点
- 类适配器受单继承限制。Java 里 extends 名额只有一个,被 Adaptee 占了就不能再继承别的类;而且类适配器一次只能适配一个特定的 Adaptee。
- 对象适配器难以覆写被适配者的行为。毕竟是组合持有而非继承,想改造 Adaptee 内部逻辑有心无力。
- 中间层多了容易套娃。适配器用上瘾之后,系统里到处是"包一层再包一层",调用链变长,排查问题得多扒好几层皮。
- 滥用警告:如果接口不兼容是自家代码造成的,优先重构统一接口,而不是拿适配器当遮羞布——它是给"改不了、改不起"的代码准备的急救包,不是日常开发的主力武器。
三、适配器模式的应用场景
适配器模式在 JDK 和主流框架里无处不在:
- JDK 里:
InputStreamReader把 InputStream(字节流)适配成 Reader(字符流)——你写过的new BufferedReader(new InputStreamReader(System.in))就是两个适配器叠着用;Arrays.asList()把数组适配成 List 视图。 - Spring MVC 里:
HandlerAdapter——名字直接写在脸上。DispatcherServlet 要面对多种形态的处理器(@Controller 方法、HttpRequestHandler、Servlet……),它不想为每种类型写一坨 if-else,于是给每种处理器配一个适配器,统一调adapter.handle(...)。想支持新处理器类型?加个新 HandlerAdapter 就行,DispatcherServlet 一行不改。 - 日志门面里:slf4j 的一堆桥接包(如 log4j-over-slf4j),把老日志框架的 API 调用"翻译"到新体系,老代码一行不改。
- 日常业务里:对接第三方支付、短信、天气 SDK,对方的参数风格、错误码体系和你内部接口对不上——写个 Adapter 把它包成自家接口的样子,而不是把内部代码改成对方的风格。
什么时候该用:想复用一个现成类,但它的接口和你需要的不一致,而且这个类改不了(第三方)或改不起(老系统在跑、牵一发动全身)。
四、适配器模式的实现方式
示例代码在 design-pattern 项目的 adapter 包里:
design-pattern/src/main/java
└── adapter // 适配器模式
├── Voltage220V.java // 被适配者:家用电
├── Voltage5V.java // 目标接口:手机要的 5V
├── ClassAdapter.java // 类适配器(继承)
├── ObjectAdapter.java // 对象适配器(组合)
├── Phone.java // 手机:只认 5V
├── AdapterTest.java // 测试入口
└── interfaceadapter // 接口适配器
├── Animal.java // 方法太多的接口
├── AnimalAdapter.java // 全部空实现的抽象类
└── Duck.java // 只覆写关心的方法
场景:手机只认 5V,墙上插座输出 220V——别砸墙,也别拆手机,加个充电头。
1. 类适配器:继承 + 实现
先看两位"接口对不上"的当事人。被适配者,墙上的电:
public class Voltage220V {
public int output220V() {
int src = 220;
System.out.println("被适配者输出电压:" + src + "V");
return src;
}
}
目标接口,手机要的电:
public interface Voltage5V {
int output5V();
}
类适配器的写法:继承被适配者,同时实现目标接口,在接口方法里调用继承来的能力做转换:
// 类适配器:继承被适配者,实现目标接口
public class ClassAdapter extends Voltage220V implements Voltage5V {
@Override
public int output5V() {
int srcV = output220V();
int dstV = srcV / 44; // 220V 转 5V,模拟变压器
System.out.println("类适配器转换后输出电压:" + dstV + "V");
return dstV;
}
}
用电方手机,它眼里只有 Voltage5V:
public class Phone {
public void charging(Voltage5V voltage5V) {
if (voltage5V.output5V() == 5) {
System.out.println("电压正常,手机开始充电");
} else {
System.out.println("电压异常,拒绝充电");
}
}
}
测试代码:
private static void classAdapter() {
Phone phone = new Phone();
phone.charging(new ClassAdapter());
}
运行结果:
===== 类适配器 =====
被适配者输出电压:220V
类适配器转换后输出电压:5V
电压正常,手机开始充电
通了。ClassAdapter 既是 220V(继承来的),又是 5V(实现来的),output5V() 内部调 output220V() 完成降压——手机从头到尾没见过 220V。
但痛点马上浮出来:Java 单继承。这个 extends 名额被 Voltage220V 占死后,适配器就不能再继承别的类了;而且继承是"类级绑定",Adaptee 被写死在 extends 后面——哪天要适配美标 110V,只能另写一个适配器类。僵。
2. 对象适配器:组合 + 实现
于是教科书和《Effective Java》口径一致:用组合代替继承。不再 extends 被适配者,而是把它当字段持有:
// 对象适配器:组合被适配者,实现目标接口
public class ObjectAdapter implements Voltage5V {
private final Voltage220V voltage220V;
public ObjectAdapter(Voltage220V voltage220V) {
this.voltage220V = voltage220V;
}
@Override
public int output5V() {
int srcV = voltage220V.output220V();
int dstV = srcV / 44; // 220V 转 5V,模拟变压器
System.out.println("对象适配器转换后输出电压:" + dstV + "V");
return dstV;
}
}
测试代码:
private static void objectAdapter() {
Phone phone = new Phone();
phone.charging(new ObjectAdapter(new Voltage220V()));
}
运行结果:
===== 对象适配器 =====
被适配者输出电压:220V
对象适配器转换后输出电压:5V
电压正常,手机开始充电
效果一模一样,内部结构完全不同。变化全在组合带来的自由度上:
- 适配器不再占用继承名额,该 extends 谁 extends 谁;
- 被适配者通过构造函数注入,传 Voltage220V 还是它的任何子类都行——不再绑定一个类,而是适配"一族"对象;
- 运行时想换被适配者实例,传新的就是;
- 甚至可以在一个适配器里组合多个被适配者一起干活。
这是"组合优于继承"在结构型模式里的第一次大显身手,后面桥接、装饰器、代理还会反复请它出场。另外注意 Phone.charging(Voltage5V) 的参数是接口——手机依赖的是抽象而不是具体电源,这正是适配器能成立的根基(第六篇单例时埋的"依赖倒置"伏笔,又应验了一次)。
3. 接口适配器:空实现兜底
第三种形态换个场景:接口方法太多,实现类用不上几个,却被迫给每个方法写空实现。比如一个 Animal 接口要你实现飞、游、跑、叫四件事:
public interface Animal {
void fly();
void swim();
void run();
void bark();
}
鸭子不会飞也不会叫(至少我们的鸭子不会),直接 implements 就得陪跑两个空方法。解法是先来一个"全空实现"的抽象类:
// 接口适配器:抽象类把全部方法给出空实现
public abstract class AnimalAdapter implements Animal {
@Override
public void fly() {
}
@Override
public void swim() {
}
@Override
public void run() {
}
@Override
public void bark() {
}
}
鸭子只继承这个抽象类,覆写关心的方法:
// 只覆写关心的方法,其余方法不用陪跑
public class Duck extends AnimalAdapter {
@Override
public void swim() {
System.out.println("鸭子在游泳");
}
@Override
public void run() {
System.out.println("鸭子摇摇摆摆地跑");
}
}
测试代码:
private static void interfaceAdapter() {
Duck duck = new Duck();
duck.swim();
duck.run();
}
运行结果:
===== 接口适配器 =====
鸭子在游泳
鸭子摇摇摆摆地跑
JDK 里最典型的就是 WindowAdapter:MouseListener 接口有 5 个方法,你只想处理点击,剩下四个空着——继承适配器类只覆写一个就行。老 Swing 代码里它和匿名内部类是黄金搭档。
注意它的本质:前两种适配器是"把 A 的接口翻译成 B",接口适配器是"把一个太重的接口稀释成你要的浓度",严格说只是借了个名分,但面试常放在一起问,值得知道。
五、类适配器 vs 对象适配器怎么选
两种形态一字之差(extends 换成了字段),气质却大不相同:
| 类适配器 | 对象适配器 | |
|---|---|---|
| 耦合方式 | 继承(类级绑定) | 组合(对象级持有) |
| 占用继承名额 | 占 | 不占 |
| 适配范围 | 一个特定的 Adaptee | Adaptee 及其所有子类 |
| 覆写 Adaptee 行为 | 方便(直接重写) | 困难 |
| 运行时换被适配者 | 不行 | 可以(构造注入) |
| Java 里的推荐度 | 受单继承限制 | **首选** |
结论一句话:默认对象适配器,只有当你确实需要改造(覆写)被适配者的部分行为时,才考虑类适配器。它俩的区别,本质上就是"继承 vs 组合"这场百年之争在一个模式上的投影。
写在最后
回顾这一篇:适配器模式在 Target 和 Adaptee 都不动的前提下,用中间一层转换器让不兼容的接口协同工作,是把"改不了、改不起"的存量代码接入新体系的急救包;类适配器靠继承,受单继承限制;对象适配器靠组合,灵活能换,是 Java 首选;接口适配器用全空实现的抽象类解决"接口方法太多"的陪跑之苦;Spring MVC 的 HandlerAdapter 则是它在真实框架里的高光时刻——新增处理器类型不用改 DispatcherServlet。
下一篇继续结构型模式,第二位选手:桥接模式(Bridge)。同样是"中间加一层",适配器治的是"接口对不上",桥接治的是"两个维度一起膨胀"——当形状要乘以颜色、品牌要乘以型号,继承图开始爆炸时,就该请它出场了。