一、问题的起点:两边都不该改
写业务代码时,接口对不上是常态。下面三个场景几乎每个项目都会遇到:
场景一:老接口还在服役。 系统里几十处调用点都在用 OldLogger.log(level, msg)。你新引入的日志规范是 info/warn/error,语义更清楚,但你不可能一夜之间改掉全公司的调用点。
场景二:第三方 SDK 只能听它的。 支付 SDK 暴露的是 doPay(long orderId, long cents, String currency)——金额用「分」、订单号是数字。而你的核心域里,金额是 BigDecimal(元)、订单号是 "A-2026" 这样的字符串。SDK 是 jar 包,改不了。
场景三:新旧两套 API 并存。 老代码到处用 Enumeration,新代码统一用 Iterator。两边都在跑,谁也不想动。
这三个场景的共同点是:调用方想要的接口是对的,被调用方提供的接口也是对的,两边都不该改。
- 想改调用方?几十个调用点、已有测试、别的团队在用——改造成本和风险都在你这边。
- 想改被调用方?第三方 jar 包改不了;老系统动了可能连带一片。
- 想复制一份代码、把逻辑抄成两套?那从此之后两个版本会各自演化,修 bug 要修两遍。
既然两边都不动,那就只能在接缝处加一层:把「一边的接口」翻译成「另一边能听懂的形式」。这一层就是适配器(Adapter)。
二、适配器的三种形态
适配器不是只有一种写法。按「怎么拿到被适配者」来分,有三种形态;再加一种 Java 特有的历史形态:

| 形态 | 怎么拿到 Adaptee | 典型场景 |
|---|---|---|
| 类适配器 | extends Adaptee + implements Target | 被适配者是一个类的实现,且你希望适配器以 Adaptee 的身份出现在某些调用里 |
| 对象适配器 | implements Target + 字段持有 Adaptee | 绝大多数场景,默认选它 |
| 双向适配器 | 同时 implements Target, Adaptee | 新老系统双向调用、过渡期 |
| 默认适配器(缺省适配器) | 为大接口提供全部空实现 | MouseAdapter、WindowAdapter 这类 JDK 里的"填平"类 |
前三者的差别只在类之间的关系上,行为上完全等价——它们对调用方暴露的都是同一个 Target 接口。所以选型的重点不是"能不能用",而是"哪一种更好改、更好测、更不容易出事故"。
三、结构:三个角色与一次调用
不管你选哪种形态,结构都由三个角色组成:
- Target(目标接口):调用方唯一认识的东西。
- Adaptee(被适配者):已经存在、接口不兼容的那个实现。
- Adapter(适配器):实现 Target,把请求翻译成 Adaptee 听得懂的形式。
以场景二为例(支付 SDK),对象适配器的结构是这样:

一次调用里发生了什么:

要点只有一个:转换只发生在适配器里。 客户端不知道 LegacyPaySdk 存在,LegacyPaySdk 也不知道自己正在被适配——两边都是干净的。
下面是可直接运行的单文件版本(对象适配器):
import java.math.BigDecimal;
/** ① 目标接口:核心域只认识它 */
interface PaymentGateway {
void pay(String orderNo, BigDecimal amount);
}
/** ② 被适配者:第三方 SDK,金额用「分」、订单号是 long,签名完全对不上 */
class LegacyPaySdk {
public boolean doPay(long orderId, long cents, String currency) {
System.out.printf("[SDK] 订单 %d,金额 %d %s分%n", orderId, cents, currency);
return true;
}
}
/** ③ 对象适配器:实现目标接口,持有被适配者,负责翻译 */
class PaySdkAdapter implements PaymentGateway {
private final LegacyPaySdk sdk; // 组合,而不是继承
PaySdkAdapter(LegacyPaySdk sdk) {
this.sdk = sdk; // 运行时可以换实现(甚至可以传子类)
}
@Override
public void pay(String orderNo, BigDecimal amount) {
long orderId = Long.parseLong(orderNo.substring(2)); // "A-2026" → 2026
long cents = amount.movePointRight(2).longValueExact(); // 19.90 元 → 1990
if (!sdk.doPay(orderId, cents, "CNY")) {
throw new IllegalStateException("支付失败:" + orderNo); // 把 SDK 的失败码翻译成异常
}
}
}
public class ObjectAdapterDemo {
public static void main(String[] args) {
PaymentGateway gateway = new PaySdkAdapter(new LegacyPaySdk());
gateway.pay("A-2026", new BigDecimal("19.90")); // 调用方全程只认识 PaymentGateway
}
}
注意 pay() 里做的事:参数转换(订单号、金额单位)、调用转换(方法名与返回值)、错误转换(false → 异常)。这三件事就是适配器的全部职责,也是它容易藏 bug 的地方——尤其是金额这种单位换算,一定要有测试兜住。
四、类适配器:继承能给你什么,又会拿走什么
类适配器用继承拿 Adaptee 的实现,同时用实现(interface)对上 Target:

/** 被适配者:老日志实现 */
class OldLogger {
public void log(int level, String msg) {
System.out.println("[LV" + level + "] " + msg);
}
}
/** 目标接口:新代码想用的样子 */
interface AppLogger {
void info(String msg);
void error(String msg);
}
/** 类适配器:继承拿到实现,实现接口对上签名 */
class LoggerClassAdapter extends OldLogger implements AppLogger {
@Override public void info(String msg) { log(3, msg); }
@Override public void error(String msg) { log(1, msg); }
}
public class ClassAdapterDemo {
public static void main(String[] args) {
AppLogger logger = new LoggerClassAdapter();
logger.info("启动完成");
logger.error("连接失败");
// 代价一:适配器在类型上"是"一个 OldLogger,
// 调用方可以绕过 Target 直接调老接口 —— "只依赖目标接口"的约束被绕过了
((OldLogger) logger).log(2, "绕过目标接口直接调用老方法");
}
}
类适配器在支持多重继承的语言(C++)里最自然:既可以继承 Adaptee 的实现,又可以继承 Target 的抽象。Java 只有单继承,于是只能「继承 Adaptee + 实现 Target」,这就带来四个具体代价:
- 只能适配一个 Adaptee。 单继承意味着
extends后面只能写一个类。要同时适配两个老接口,就得写两个适配器类;对象适配器可以用一个类持有多个 Adaptee。 - Adaptee 的全部公开方法都会暴露。 适配器是 Adaptee 的子类型,调用方拿到
LoggerClassAdapter后可以直接调log(),适配层形同虚设。而对象适配器只暴露 Target 上的方法。 - 无法在运行时替换被适配者。 继承了谁,编译期就定死了。对象适配器可以构造器/Setter 注入,测试时换成 mock、线上换成真实实现。
- 覆写 Adaptee 的方法会踩"脆弱基类"的坑。 Adaptee 内部的自调用(
this.xxx())会落到你覆写后的版本上,行为可能悄悄改变——这是继承体系里最经典的事故来源。
所以:除非你确实需要"适配器同时是 Adaptee"这个身份(比如某些框架要求你传入 Adaptee 类型的实例),否则用对象适配器。
五、对象适配器:组合才是默认答案
对象适配器的写法就是前面支付 SDK 那段代码,这里把它的好处集中列一下:
| 好处 | 说明 |
|---|---|
| 可以适配多个 Adaptee | 一个类里持有多个字段,把多个老接口收拢到一个目标接口下 |
| 可以适配 Adaptee 的子类 | 字段类型声明为 Adaptee,运行时传任意子类进去 |
| 不暴露 Adaptee 的方法 | 适配器只实现 Target,调用方看不到老接口 |
| 便于测试 | 构造器注入,单测里传一个假的 Adaptee,不需要真实 SDK |
| 不依赖继承层次 | Adaptee 内部怎么改、怎么继承,都不影响适配器 |
一句话总结取舍:类适配器是"我变成你",对象适配器是"我拿着你"。 前者省一次转发,后者省一堆麻烦。
六、双向适配器:新老并行的过渡期
还有一种少见但有用的形态:新系统要通过回调调用老系统,老系统也要主动调用新系统——两个方向都要通。
/** 新系统这一侧的接口(我们实现的) */
interface NewService {
void handle(String event);
}
/** 老系统那一侧的接口(别人期待的) */
interface OldListener {
void onEvent(String code, int type);
}
/** 双向适配器:两边的接口都实现,互相转发 */
class TwoWayAdapter implements NewService, OldListener {
private final NewService self; // 新系统一侧
private final OldListener legacy; // 老系统一侧
TwoWayAdapter(NewService self, OldListener legacy) {
this.self = self;
this.legacy = legacy;
}
@Override public void handle(String event) { // 新 → 老
legacy.onEvent(event, 1);
}
@Override public void onEvent(String code, int type) { // 老 → 新
self.handle(code);
}
}
一个经验判断:双向适配器出现的地方,通常就是一段过渡期设计。 它本身没问题,但要给它写清楚退役条件(比如"老系统下线后删除"),否则它会变成两套系统之间永久的耦合点。
七、默认适配器:MouseAdapter 与它的现代替代
JDK 里有一类类名叫 XXXAdapter 的东西,它们遵循的是同一种思路:用一个类把大接口的全部方法填成空实现,让实现者只覆写自己关心的方法。这就是「默认适配器(缺省适配器)」。

// 没有 MouseAdapter 的话,只监听一个点击事件也要写满 5 个方法
JButton button = new JButton("点我");
button.addMouseListener(new MouseAdapter() { // 只覆写关心的那个
@Override public void mouseClicked(MouseEvent e) {
System.out.println("clicked");
}
});
// Java 8 之后:接口可以给 default 实现,这类空实现类就不再必要了
interface MyListener {
default void onStart() { }
default void onStop() { }
default void onTick() { }
}
这里有两点值得记住:
- AWT/Swing 里的
MouseAdapter、KeyAdapter、WindowAdapter都是这个套路(WindowListener有 7 个方法,不填平没法用)。它们让"接口方法多"这件事不再痛苦。 - Java 8 之后优先用
default方法,不要再新增XXXAdapter空实现类。JDK 自己没有把MouseListener改成 default(为了二进制兼容,改了会让老实现类编译报错),但你新写的接口没必要再走老路。
八、JDK 与框架里的适配器
适配器是框架里出现频率最高的结构型模式之一——因为框架永远要面对"多种实现,一个入口"。
| 位置 | 目标接口(统一的样子) | 被适配者(各式各样的实现) | 说明 |
|---|---|---|---|
InputStreamReader / OutputStreamWriter | Reader / Writer(字符流) | InputStream / OutputStream(字节流) | 字节 ↔ 字符 + 编码转换,构造器接收 Charset |
Arrays.asList(T...) | List | 数组 | 数组 → List 视图;长度固定,add/remove 抛 UnsupportedOperationException |
Collections.enumeration(Collection) | Enumeration(老 API) | Collection / Iterator(新 API) | 为兼容老接口而存在 |
JDBC java.sql.Driver | Driver | 各厂商驱动实现 | MySQL、PostgreSQL、Oracle 的驱动各自适配到同一套接口 |
Spring MVC HandlerAdapter | HandlerAdapter | @Controller、HttpRequestHandler、Servlet 等多种处理器 | DispatcherServlet 只认识 HandlerAdapter,其余交给适配器去调 |
| SLF4J 绑定(logback、log4j-slf4j) | org.slf4j.Logger | logback / log4j 的实现 | 门面 + 适配/绑定,换日志实现不动业务代码 |
// 字节流 → 字符流:同一个 InputStream,套上适配器就变成 Reader
try (Reader reader = new InputStreamReader(
new FileInputStream("data.txt"), StandardCharsets.UTF_8)) {
char[] buf = new char[1024];
while (reader.read(buf) != -1) {
// 这里处理的是字符,不再是字节
}
}
// 数组 → List 视图
List<String> list = Arrays.asList("a", "b", "c");
// list.add("d"); // 运行期抛 UnsupportedOperationException:它只是视图,不是新集合
怎么判断一个类名带 Adapter 的类是不是适配器模式? 别看名字,看它有没有在转换接口:如果它把一个接口的调用翻译成另一个接口能听懂的调用,就是适配器;如果它只是给现有接口加了点行为(签名没变),那是装饰器;如果它把一堆子系统收成一个门面(接口变少、变简单),那是外观。
九、适配器 vs 装饰器 vs 外观
这三个模式最容易混,因为它们都表现为"包一层"。区分它们的唯一标准是:接口签名有没有变、往哪个方向变。

| 模式 | 接口签名 | 目的 | 关系与形态 |
|---|---|---|---|
| 适配器 | 改变(翻译成另一边认得的形式) | 让不兼容的接口能协作 | 1 个被适配者(双向适配器是 2 个方向) |
| 装饰器 | 不变 | 在不改签名的前提下叠加行为 | 1 个被装饰者,可层层嵌套 |
| 代理 | 不变 | 控制对真实对象的访问 | 1 个真实对象,通常单层 |
| 外观 | 简化(多个接口收成一个) | 降低调用复杂度 | N 个子系统 |
拿上一篇代理模式里那张三列图对照着看会更清楚:代理和装饰器的接口都不变(一个管"能不能进来",一个管"进来之后多做什么");适配器一定会改接口(因为它面对的就是两个不兼容的接口);外观是把多个接口收成一个(它不翻译细节,只是少暴露)。
十、什么时候不要用适配器
适配器是"给改不动的代码兜底"的工具,不是"让代码看起来整齐"的工具。下面几种情况,加适配器是错的方向:
- 两边都能改。 那直接统一接口,别加中间层。适配器的价值全部来自"有一边改不动"。
- 只是想遮住一个丑接口。 那是外观(Facade)或直接重构的活,不是适配器——适配器的目标是"协作",不是"好看"。
- 在同一个接缝上叠了三层以上适配器。 调用链变长、异常栈难读、调试成本上升。性能一般可以忽略(一次方法转发),但 IO 密集路径上要实测。
- 用适配器掩盖接口设计问题。 如果五个适配器都围绕同一个接口转,说明真正该抽象的是领域层的接口,而不是在每个调用点加转换。
- 双向适配器没有退役计划。 过渡期设计要写清楚"什么条件满足后删除",否则两套系统的耦合会永久固化。
一个反过来的判断标准:如果删掉适配器,改动量是否只需几行? 是的话,直接改;只有当改动会波及大量调用点、别人的模块、或不可修改的第三方代码时,适配器才划算。
十一、参考资料
- GoF《设计模式:可复用面向对象软件的基础》第 4 章 Adapter(类适配器与对象适配器的原始讨论)
- 《Head First 设计模式》第 7 章 Adapter and Facade
- JDK 源码:
java.io.InputStreamReader、java.util.Arrays#asList、java.util.Collections#enumeration、java.awt.event.MouseAdapter - Spring Framework:
org.springframework.web.servlet.HandlerAdapter及其多个实现 - SLF4J 官方文档中关于「绑定(binding)/适配」的说明
- 本系列相关篇目:《设计模式(Java)· 代理模式》——其中有代理、装饰器、适配器三者的三列对比图
评论