结构型模式:适配器模式与组合模式
从「行为」到「结构」
前面几节我们学习了六种行为型模式——策略、状态、命令、观察者、中介者、模板方法——它们关注的是对象之间如何分配职责、如何通信。再往前,我们还学过五种创建型模式——简单工厂、工厂方法、抽象工厂、建造者、原型——它们关注的是对象怎么来。
现在,一个新的问题浮现了:对象有了,行为也定义好了,但它们之间怎么「拼」在一起?
结构型模式(Structural Patterns)关注的正是这件事:如何将类或对象组合成更大的结构。如果说创建型模式是「造零件」,行为型模式是「定规则」,那么结构型模式就是「搭架子」——它解决的是接口适配、功能组合、结构简化等问题。
本节介绍两种结构型模式,它们解决两个截然不同但同样常见的问题:
- 适配器模式——接口不兼容怎么办?答:加一层转换
- 组合模式——怎么统一处理「个体」和「整体」?答:用树形结构递归组合
适配器模式
一个「接口不匹配」的烦恼
你新买了一台 MacBook,只有 USB-C 接口。但你的移动硬盘是 USB-A 的,鼠标是 USB-A 的,投影仪是 HDMI 的。硬盘能用、鼠标能用、投影仪也能用——唯一的问题是接口不匹配。你需要的不是换掉这些设备,而是一个转接头。
软件系统中也会遇到同样的问题。你的系统需要调用某个功能,恰好有一个现成的类已经实现了这个功能——但它的接口和你的系统期望的不一样。方法名不同、参数顺序不同、甚至返回类型不同。直接用不了,重写又浪费。
这时候你需要的就是软件世界的「转接头」——适配器。
模式定义
适配器模式
适配器模式(Adapter Pattern)将一个接口转换成客户希望的另一个接口,使接口不兼容的那些类可以一起工作。
Convert the interface of a class into another interface clients expect. Adapter lets classes work together that couldn't otherwise because of incompatible interfaces.
别名:包装器(Wrapper)。适配器模式既可以作为类结构型模式,也可以作为对象结构型模式。
简单来说,适配器模式做的事情就是:客户端期望接口 A,现有类提供接口 B,适配器把 B 包装成 A。客户端只和接口 A 打交道,完全不知道背后是接口 B 在工作——这个转换过程对客户端是透明的。
模式中有四个角色:
| 角色 | 职责 |
|---|---|
| Target(目标抽象类) | 定义客户端期望的接口 |
| Adaptee(适配者类) | 已有的、需要被适配的类,提供了客户端需要的功能但接口不匹配 |
| Adapter(适配器类) | 核心角色,将 Adaptee 的接口转换为 Target 的接口 |
| Client(客户类) | 面向 Target 编程,通过 Target 接口使用适配器 |
根据适配器与适配者之间的关系,适配器模式分为两种形式:对象适配器和类适配器。
对象适配器:用组合实现转换
对象适配器通过组合(持有适配者的引用)来实现接口转换——这也是实际开发中更常用的形式。
classDiagram
direction LR
class Target {
<<interface>>
+request()* void
}
class Adapter {
-adaptee : Adaptee
+request() void
}
class Adaptee {
+specificRequest() void
}
class Client {
}
Target <|.. Adapter
Adapter --> Adaptee : adaptee
Client --> Target
适配器实现了目标接口,内部持有适配者的引用。当客户端调用 request() 时,适配器在内部将调用转发给适配者的 specificRequest():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | // 目标接口——客户端期望的接口 public interface Target { void request(); } // 适配者——已有的类,功能满足需求但接口不匹配 public class Adaptee { public void specificRequest() { System.out.println("适配者的业务方法"); } } // 对象适配器——通过组合持有适配者引用 public class Adapter implements Target { private Adaptee adaptee; // 持有适配者引用 public Adapter(Adaptee adaptee) { this.adaptee = adaptee; } @Override public void request() { adaptee.specificRequest(); // 转发调用 } } |
客户端面向 Target 编程,完全不知道 Adaptee 的存在:
1 2 | Target target = new Adapter(new Adaptee()); target.request(); // 实际执行的是 Adaptee.specificRequest() |
类适配器:用继承实现转换
类适配器通过多重继承(在 Java 中是继承 + 实现接口)来同时获得目标接口和适配者的能力:
classDiagram
direction LR
class Target {
<<interface>>
+request()* void
}
class Adapter {
+request() void
}
class Adaptee {
+specificRequest() void
}
class Client {
}
Target <|.. Adapter
Adaptee <|-- Adapter
Client --> Target
适配器继承适配者并实现目标接口,在 request() 中直接调用从父类继承来的方法:
1 2 3 4 5 6 7 | // 类适配器——继承适配者,实现目标接口 public class Adapter extends Adaptee implements Target { @Override public void request() { specificRequest(); // 直接调用继承来的方法 } } |
代码更简洁,但受限于 Java 的单继承机制——如果 Target 不是接口而是类,这种方式就无法使用。
两种适配器的对比
| 维度 | 对象适配器 | 类适配器 |
|---|---|---|
| 实现方式 | 组合(持有 Adaptee 引用) | 继承(extends Adaptee) |
| 灵活性 | 可以适配 Adaptee 及其所有子类 | 只能适配特定的 Adaptee 类 |
| 方法覆盖 | 不便直接覆盖 Adaptee 的方法 | 可以覆盖 Adaptee 的方法 |
| 语言限制 | 无 | 需要多继承支持(Java 中 Target 须为接口) |
| 推荐程度 | 更推荐——符合「组合优于继承」原则 | 适用于需要覆盖适配者方法的特殊场景 |
还记得学习策略模式时提到的「多用组合,少用继承」吗?对象适配器正是这一原则的体现。实际开发中,除非有明确的理由需要覆盖适配者的方法,否则优先选择对象适配器。
实例:仿生机器人
来看一个具体的例子。假设需要设计一个仿生机器人系统。机器人定义了 cry() 和 move() 两个方法,现在希望机器人能模拟狗的行为——像狗一样叫、像狗一样跑。已有一个 Dog 类实现了 wang()(叫)和 run()(跑),但接口显然和机器人的 cry()、move() 对不上。
classDiagram
direction LR
class Robot {
<<interface>>
+cry()* void
+move()* void
}
class DogAdapter {
-dog : Dog
+cry() void
+move() void
}
class Dog {
+wang() void
+run() void
}
Robot <|.. DogAdapter
DogAdapter --> Dog : dog
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | // 机器人接口(Target) public interface Robot { void cry(); void move(); } // 狗(Adaptee)——已有的类 public class Dog { public void wang() { System.out.println("汪汪汪!"); } public void run() { System.out.println("四条腿快跑!"); } } // 仿生机器人适配器 public class DogAdapter implements Robot { private Dog dog; public DogAdapter(Dog dog) { this.dog = dog; } @Override public void cry() { System.out.println("机器人模拟狗叫:"); dog.wang(); // 委托给狗的叫声方法 } @Override public void move() { System.out.println("机器人模拟狗跑:"); dog.run(); // 委托给狗的奔跑方法 } } |
如果将来要让机器人模拟猫,只需新建一个 CatAdapter——Dog 类和 Robot 接口都不需要修改。通过配置文件指定使用哪个适配器,还能在运行时灵活切换,完全符合开闭原则。
实例:加密适配器
再看一个更贴近实际的例子。某系统需要在存储用户密码之前进行加密,系统已经定义了数据操作接口 DataOperation,现在想复用第三方提供的加密算法(如 Caesar 密码、MD5 等),但这些第三方类的接口与 DataOperation 不一致。
classDiagram
direction LR
class DataOperation {
<<abstract>>
+doEncrypt(int key, String ps)* String
}
class CipherAdapter {
-cipher : NewCipher
+doEncrypt(int key, String ps) String
}
class NewCipher {
+encrypt(int key, String ps) String
}
DataOperation <|-- CipherAdapter
CipherAdapter --> NewCipher : cipher
适配器将 DataOperation 的 doEncrypt() 调用转发给第三方的 encrypt()。即使第三方类没有源代码,我们也能通过适配器无缝接入系统。如果将来更换加密算法(比如从 Caesar 换成 AES),只需更换适配器类,系统的其他部分完全不受影响。
优缺点
通用优点:
| 优点 | 说明 |
|---|---|
| 解耦 | 客户端与适配者通过适配器间接交互,两者完全解耦 |
| 透明复用 | 适配者的具体实现对客户端透明,提高了适配者的复用性 |
| 灵活扩展 | 通过配置文件可以方便地更换或新增适配器,符合开闭原则 |
各自的特点:
| 类适配器 | 对象适配器 | |
|---|---|---|
| 额外优点 | 可以覆盖适配者的方法,灵活性强 | 一个适配器可以适配多个不同的适配者(包括子类) |
| 缺点 | Java 等单继承语言中一次只能适配一个类,且 Target 必须是接口 | 覆盖适配者方法比较麻烦,需要先创建子类再适配 |
适用场景
- 系统需要使用一个现有的类,但其接口不符合系统的要求
- 想创建一个可复用的类,用于和多个接口不兼容的类协作
- 需要在不修改现有代码的前提下接入第三方库或遗留系统
实际应用
JDBC——数据库世界的适配器。JDBC(Java Database Connectivity)是适配器模式在 Java 类库中最经典的应用之一。JDBC 定义了一套统一的数据库操作接口,而每个数据库引擎(MySQL、Oracle、SQL Server 等)都有自己独特的 API。JDBC 驱动程序就是介于 JDBC 接口和数据库引擎 API 之间的适配器——它将统一的 JDBC 调用转换为特定数据库的原生操作。
flowchart LR
C[Java 应用程序] --> J[JDBC 接口<br/>Target]
J --> D1[MySQL 驱动<br/>Adapter]
J --> D2[Oracle 驱动<br/>Adapter]
J --> D3[SQL Server 驱动<br/>Adapter]
D1 --> DB1[(MySQL)]
D2 --> DB2[(Oracle)]
D3 --> DB3[(SQL Server)]
classDef app fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
classDef iface fill:#fff3e0,stroke:#ef6c00,stroke-width:2px
classDef adapter fill:#e8f5e8,stroke:#2e7d32,stroke-width:2px
classDef db fill:#f8f9fa,stroke:#495057,stroke-width:2px
class C app
class J iface
class D1,D2,D3 adapter
class DB1,DB2,DB3 db
开发者只需面向 JDBC 接口编程,切换数据库时只需更换驱动(适配器),应用代码不做任何修改。这正是适配器模式「解耦客户端与具体实现」的威力。
JDK 中的适配器。JDK 类库本身也大量使用了适配器模式。例如 com.sun.imageio.plugins.common.InputStreamAdapter 类将 ImageInputStream 适配为标准的 InputStream:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | public class InputStreamAdapter extends InputStream { ImageInputStream stream; // 适配者 public InputStreamAdapter(ImageInputStream stream) { this.stream = stream; } public int read() throws IOException { return stream.read(); // 转发调用 } public int read(byte b[], int off, int len) throws IOException { return stream.read(b, off, len); // 转发调用 } } |
这是一个典型的对象适配器——InputStreamAdapter 继承了 InputStream(Target),内部持有 ImageInputStream(Adaptee)的引用,将 InputStream 的方法调用转发给 ImageInputStream。
另一个常见例子是 java.util.Arrays.asList()——它将原始数组适配为 List 接口,使数组可以在需要 List 的地方使用。底层数据并没有复制,只是在数组外面「包」了一层 List 接口。
模式扩展
默认适配器模式
有时候一个接口定义了很多方法,但具体实现类只关心其中的一两个。比如 Java AWT 的 WindowListener 接口定义了 7 个方法(windowOpened、windowClosing、windowClosed 等),但你可能只想处理「窗口关闭」这一个事件。
如果直接实现接口,你不得不为其他 6 个不关心的方法写空实现——这很烦。默认适配器模式(Default Adapter Pattern)的解决方案是:提供一个抽象类,为接口中的每个方法提供空的默认实现,具体类只需继承这个抽象类并覆盖关心的方法。
classDiagram
class ServiceInterface {
<<interface>>
+methodA()* void
+methodB()* void
+methodC()* void
}
class AbstractAdapter {
<<abstract>>
+methodA() void
+methodB() void
+methodC() void
}
class ConcreteService {
+methodB() void
}
ServiceInterface <|.. AbstractAdapter
AbstractAdapter <|-- ConcreteService
note for AbstractAdapter "所有方法提供空实现"
note for ConcreteService "只覆盖关心的方法"
Java AWT 中的 WindowAdapter 就是这种模式——它实现了 WindowListener 接口并为所有方法提供空实现,开发者只需继承 WindowAdapter 并覆盖需要的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | // 不使用默认适配器——必须实现所有 7 个方法 frame.addWindowListener(new WindowListener() { public void windowClosing(WindowEvent e) { System.exit(0); } public void windowOpened(WindowEvent e) { } // 不关心,但必须写 public void windowClosed(WindowEvent e) { } // 不关心,但必须写 public void windowIconified(WindowEvent e) { } // 不关心,但必须写 public void windowDeiconified(WindowEvent e) { } // 不关心,但必须写 public void windowActivated(WindowEvent e) { } // 不关心,但必须写 public void windowDeactivated(WindowEvent e) { } // 不关心,但必须写 }); // 使用默认适配器——只覆盖关心的方法 frame.addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent e) { System.exit(0); } }); |
默认适配器模式也称为单接口适配器模式(Single Interface Adapter Pattern)。它本质上是用一个抽象类为接口「兜底」,减轻实现者的负担。在 Java 8 引入接口默认方法(default method)后,接口本身就可以提供默认实现,默认适配器模式的使用场景有所减少,但在需要维护状态或实现复杂默认逻辑时仍然有用。
双向适配器
在标准的适配器模式中,适配是单向的——从 Adaptee 到 Target。双向适配器同时持有 Target 和 Adaptee 的引用,使两者可以互相适配:Target 的客户端可以通过适配器使用 Adaptee 的功能,Adaptee 的客户端也可以通过同一个适配器使用 Target 的功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | public class TwoWayAdapter implements Target, Adaptee { private Target target; private Adaptee adaptee; // 同时持有两端的引用 public TwoWayAdapter(Target target, Adaptee adaptee) { this.target = target; this.adaptee = adaptee; } // 实现 Target 接口——委托给 Adaptee @Override public void request() { adaptee.specificRequest(); } // 实现 Adaptee 接口——委托给 Target @Override public void specificRequest() { target.request(); } } |
双向适配器在实践中较少使用,但在两个子系统需要互相调用对方接口的场景下有其价值。
组合模式
适配器模式解决的是「接口不匹配」的问题——已有的功能没问题,只是接口对不上。接下来的组合模式面对的则是一个完全不同的结构问题:当对象之间存在层次关系时,如何让客户端统一地对待「个体」和「整体」?
「个体」与「整体」的统一
打开你的文件管理器,你会看到一棵树:文件夹里套着文件夹,文件夹里还有文件。对一个文件夹执行「删除」操作时,里面的所有文件和子文件夹都会被递归删除。对一个文件执行「删除」则只删除它自己。但从用户的角度看,操作方式完全一样——选中,按 Delete 键。
用户不关心自己操作的是单个文件还是整个文件夹,他希望一致地处理它们。但从程序实现的角度,文件夹(容器)和文件(叶子)在行为上有本质区别——容器可以包含子元素并递归处理,叶子不能。如果客户端代码必须到处写 if (是文件夹) ... else (是文件) ...,系统的复杂度会随着对象类型的增加而急剧膨胀。
组合模式解决的正是这个问题:让客户端能够以统一的方式处理单个对象和组合对象。
模式定义
组合模式
组合模式(Composite Pattern)组合多个对象形成树形结构以表示「整体-部分」的结构层次。组合模式对单个对象(即叶子对象)和组合对象(即容器对象)的使用具有一致性。
Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly.
别名:整体-部分模式(Part-Whole)。属于对象结构型模式。
组合模式的关键思想是定义一个抽象构件类(Component),它既可以代表叶子,又可以代表容器。客户端面向这个抽象类编程,无须知道自己操作的到底是叶子还是容器。
模式结构
classDiagram
class Component {
<<abstract>>
+add(Component c)* void
+remove(Component c)* void
+getChild(int i)* Component
+operation()* void
}
class Leaf {
+add(Component c) void
+remove(Component c) void
+getChild(int i) Component
+operation() void
}
class Composite {
-children : List~Component~
+add(Component c) void
+remove(Component c) void
+getChild(int i) Component
+operation() void
}
Component <|-- Leaf
Component <|-- Composite
Composite o--> Component : children
三个核心角色:
| 角色 | 职责 |
|---|---|
| Component(抽象构件) | 为叶子和容器声明统一接口,可包含管理子构件的方法 |
| Leaf(叶子构件) | 树的末端节点,没有子节点,实现具体业务逻辑 |
| Composite(容器构件) | 包含子节点(可以是叶子也可以是容器),实现管理子构件的方法,业务方法通常递归调用子构件的相应方法 |
注意 Composite 与 Component 之间的聚合关系——容器持有一组 Component 引用,这些 Component 既可以是 Leaf 也可以是 Composite,从而形成递归的树形结构。
递归的力量
组合模式最精妙的地方在于 Composite 的 operation() 方法——它遍历自己的子构件列表,逐一调用每个子构件的 operation()。如果子构件恰好也是 Composite,那么调用又会递归下去,直到遇到 Leaf 为止。这种递归组合能够表达任意深度的层次结构。
抽象构件定义统一接口:
1 2 3 4 5 6 | public abstract class Component { public abstract void add(Component c); public abstract void remove(Component c); public abstract Component getChild(int i); public abstract void operation(); } |
叶子构件实现业务逻辑,但管理方法无意义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | public class Leaf extends Component { private String name; public Leaf(String name) { this.name = name; } @Override public void add(Component c) { // 叶子节点不支持此操作 throw new UnsupportedOperationException(); } @Override public void remove(Component c) { throw new UnsupportedOperationException(); } @Override public Component getChild(int i) { throw new UnsupportedOperationException(); } @Override public void operation() { System.out.println(" 叶子 " + name + " 被访问"); } } |
容器构件维护子节点列表,业务方法递归调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | public class Composite extends Component { private String name; private List<Component> children = new ArrayList<>(); public Composite(String name) { this.name = name; } @Override public void add(Component c) { children.add(c); } @Override public void remove(Component c) { children.remove(c); } @Override public Component getChild(int i) { return children.get(i); } @Override public void operation() { System.out.println("容器 " + name + " 被访问"); for (Component child : children) { child.operation(); // 递归!子节点可能是 Leaf 也可能是 Composite } } } |
客户端完全不需要区分叶子和容器:
1 2 3 4 5 6 7 8 9 10 11 12 13 | Component root = new Composite("根"); Component branch = new Composite("分支"); branch.add(new Leaf("叶子A")); branch.add(new Leaf("叶子B")); root.add(branch); root.add(new Leaf("叶子C")); root.operation(); // 输出: // 容器 根 被访问 // 容器 分支 被访问 // 叶子 叶子A 被访问 // 叶子 叶子B 被访问 // 叶子 叶子C 被访问 |
实例:水果盘
在水果盘(Plate)中有各种水果——苹果(Apple)、香蕉(Banana)、梨子(Pear)。大水果盘中还可以套小水果盘。对一个水果盘执行「吃」操作,实际上就是吃掉里面的所有水果。
classDiagram
class MyElement {
<<abstract>>
+eat()* void
}
class Apple {
+eat() void
}
class Banana {
+eat() void
}
class Pear {
+eat() void
}
class Plate {
-elements : List~MyElement~
+add(MyElement e) void
+remove(MyElement e) void
+eat() void
}
MyElement <|-- Apple
MyElement <|-- Banana
MyElement <|-- Pear
MyElement <|-- Plate
Plate o--> MyElement : elements
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | public abstract class MyElement { public abstract void eat(); } public class Apple extends MyElement { @Override public void eat() { System.out.println("吃苹果"); } } // Banana、Pear 类似... public class Plate extends MyElement { private List<MyElement> elements = new ArrayList<>(); public void add(MyElement e) { elements.add(e); } public void remove(MyElement e) { elements.remove(e); } @Override public void eat() { System.out.println("开始吃盘中的水果:"); for (MyElement e : elements) { e.eat(); // 如果 e 是另一个 Plate,会递归进去 } } } |
对客户端来说,apple.eat() 和 plate.eat() 的调用方式完全一样——这就是组合模式的统一处理能力。大水果盘套小水果盘?没问题,递归自然搞定。
实例:文件浏览
一个更贴近日常的例子。文件有不同类型(文本文件、图片文件),文件夹可以包含文件和子文件夹。对文件夹的「浏览」操作就是递归浏览其中的所有内容。
classDiagram
class AbstractFile {
<<abstract>>
+display()* void
}
class TextFile {
-name : String
+display() void
}
class ImageFile {
-name : String
+display() void
}
class Folder {
-name : String
-files : List~AbstractFile~
+add(AbstractFile f) void
+remove(AbstractFile f) void
+display() void
}
AbstractFile <|-- TextFile
AbstractFile <|-- ImageFile
AbstractFile <|-- Folder
Folder o--> AbstractFile : files
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | public abstract class AbstractFile { public abstract void display(); } public class TextFile extends AbstractFile { private String name; public TextFile(String name) { this.name = name; } @Override public void display() { System.out.println("浏览文本文件:" + name); } } public class ImageFile extends AbstractFile { private String name; public ImageFile(String name) { this.name = name; } @Override public void display() { System.out.println("浏览图片文件:" + name); } } public class Folder extends AbstractFile { private String name; private List<AbstractFile> files = new ArrayList<>(); public Folder(String name) { this.name = name; } public void add(AbstractFile f) { files.add(f); } public void remove(AbstractFile f) { files.remove(f); } @Override public void display() { System.out.println("打开文件夹:" + name); for (AbstractFile f : files) { f.display(); } } } |
新增文件类型(比如视频文件)只需添加一个新的叶子类,不影响 Folder 和其他已有类——符合开闭原则。杀毒软件扫描文件系统时也是同样的原理:对单个文件执行查毒,对文件夹则递归扫描。
透明组合 vs 安全组合
你可能已经注意到一个问题:在前面的标准实现中,叶子节点被迫实现了 add()、remove()、getChild() 这些对叶子毫无意义的方法。组合模式有两种变体来处理这个问题:
透明组合模式
管理方法声明在 Component 中,Leaf 和 Composite 都必须实现。叶子节点的管理方法通常抛出异常或静默忽略。
classDiagram
class Component {
<<abstract>>
+add(Component c) void
+remove(Component c) void
+getChild(int i) Component
+operation()* void
}
class Leaf {
+operation() void
}
class Composite {
-children : List~Component~
+operation() void
}
Component <|-- Leaf
Component <|-- Composite
note for Component "管理方法在此声明"
优点:客户端完全统一处理,不需要区分叶子和容器——「透明」的含义正在于此。
缺点:叶子节点拥有了不该有的方法,违反了单一职责原则。运行时调用叶子的 add() 只能在运行时报错,编译期无法发现问题。
安全组合模式
管理方法只声明在 Composite 中,Component 只包含业务方法。
classDiagram
class Component {
<<abstract>>
+operation()* void
}
class Leaf {
+operation() void
}
class Composite {
-children : List~Component~
+add(Component c) void
+remove(Component c) void
+getChild(int i) Component
+operation() void
}
Component <|-- Leaf
Component <|-- Composite
note for Composite "管理方法在此声明"
优点:叶子不会有不合理的方法,类型安全,编译期就能避免错误调用。
缺点:客户端需要区分叶子和容器——如果要调用管理方法,必须知道自己持有的是 Composite 类型并进行类型转换,这破坏了「统一处理」的初衷。
| 维度 | 透明组合 | 安全组合 |
|---|---|---|
| 管理方法位置 | Component | Composite |
| 叶子节点 | 包含无意义的管理方法 | 干净、只有业务方法 |
| 客户端代码 | 完全统一,无需类型判断 | 需要类型判断才能调用管理方法 |
| 安全性 | 运行时异常 | 编译期保证 |
| GoF 原版 | 采用此方式 | 替代方案 |
GoF 的《设计模式》原书采用的是透明组合模式,更侧重于「统一处理」的便利性。但在类型安全要求较高的场景中,安全组合模式可能是更好的选择。Java 的 AWT/Swing 就采用了安全组合模式——管理子组件的方法(add()、remove())定义在 Container 类中而非 Component 基类中。
优缺点
优点:
| 优点 | 说明 |
|---|---|
| 清晰的层次结构 | 可以方便地定义和控制分层次的复杂对象 |
| 客户端简化 | 统一处理叶子和容器,无需关心具体类型 |
| 递归组合 | 叶子→容器→更大的容器……自然形成任意深度的树形结构 |
| 符合开闭原则 | 新增构件类型只需添加新类,无需修改已有代码 |
缺点:
- 设计更加抽象,对象的业务规则复杂时,实现组合模式有一定挑战
- 很难对容器中的构件类型进行限制——例如,你很难在编译期保证某种容器只能包含特定类型的叶子
适用场景
- 需要表示对象的整体-部分层次结构(如树形菜单、组织架构、文件系统)
- 希望客户端忽略个体与整体的差异,以统一方式处理它们
- 对象的结构是动态的,且复杂程度不确定,但客户端需要一致地处理
实际应用
组合模式在实际系统中的应用非常广泛:
XML 文档解析。XML 的结构天然是一棵树——元素节点可以包含子元素和文本节点,文本节点是叶子。解析 XML 文档时,DOM 解析器将整个文档构建为一棵由 Node 对象组成的树,Element(容器)和 Text(叶子)都是 Node 的子类型,客户端可以统一遍历。
1 2 3 4 5 6 7 8 9 | <books> <book> <author>Carson</author> <price format="dollar">31.95</price> </book> <pubinfo> <publisher>MSPress</publisher> </pubinfo> </books> |
这里 <books> 和 <book> 是 Composite(容器节点),Carson、31.95 等文本是 Leaf(叶子节点)。
操作系统目录结构。文件系统天然是组合模式的应用——文件是叶子,目录是容器。杀毒软件在扫描时,可以针对单个文件查毒,也可以对整个目录递归扫描,操作方式完全一致。
Java AWT/Swing 组件体系。Swing 的 GUI 组件层次结构就是组合模式的经典实现。Component 是抽象构件,JButton、JLabel 等是叶子,JPanel、JFrame 等容器可以包含其他组件。对顶层容器调用 repaint(),会递归重绘所有子组件——这正是 Composite 的 operation() 递归调用子构件的体现。
两种模式纵览
本节介绍了两种结构型模式。它们看似不相关,但有一个共同点:都是通过包装或组合现有对象来构建更大的结构。
| 维度 | 适配器模式 | 组合模式 |
|---|---|---|
| 核心问题 | 接口不兼容 | 整体-部分的统一处理 |
| 关键机制 | 包装适配者,转换接口 | 递归组合,树形结构 |
| 模式类型 | 类/对象结构型 | 对象结构型 |
| 核心角色 | Adapter 连接 Target 和 Adaptee | Composite 包含 Component |
| 典型场景 | 复用遗留代码、接入第三方库 | 树形层次结构(文件系统、GUI、组织架构) |
设计工具箱更新:
| 层次 | 内容 |
|---|---|
| OO 基础 | 抽象、封装、多态、继承 |
| OO 原则 | 封装变化、面向接口编程、组合优于继承、好莱坞原则 |
| OO 模式 | 策略、简单工厂、工厂方法、抽象工厂、建造者、原型、状态、命令、观察者、中介者、模板方法、适配器、组合 |