结构型模式:适配器模式与组合模式

从「行为」到「结构」

前面几节我们学习了六种行为型模式——策略、状态、命令、观察者、中介者、模板方法——它们关注的是对象之间如何分配职责、如何通信。再往前,我们还学过五种创建型模式——简单工厂、工厂方法、抽象工厂、建造者、原型——它们关注的是对象怎么来。

现在,一个新的问题浮现了:对象有了,行为也定义好了,但它们之间怎么「拼」在一起?

结构型模式(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

适配器将 DataOperationdoEncrypt() 调用转发给第三方的 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 AWTWindowListener 接口定义了 7 个方法(windowOpenedwindowClosingwindowClosed 等),但你可能只想处理「窗口关闭」这一个事件。

如果直接实现接口,你不得不为其他 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(容器节点),Carson31.95 等文本是 Leaf(叶子节点)。

操作系统目录结构。文件系统天然是组合模式的应用——文件是叶子,目录是容器。杀毒软件在扫描时,可以针对单个文件查毒,也可以对整个目录递归扫描,操作方式完全一致。

Java AWT/Swing 组件体系。Swing 的 GUI 组件层次结构就是组合模式的经典实现。Component 是抽象构件,JButtonJLabel 等是叶子,JPanelJFrame 等容器可以包含其他组件。对顶层容器调用 repaint(),会递归重绘所有子组件——这正是 Composite 的 operation() 递归调用子构件的体现。

两种模式纵览

本节介绍了两种结构型模式。它们看似不相关,但有一个共同点:都是通过包装或组合现有对象来构建更大的结构。

维度 适配器模式 组合模式
核心问题 接口不兼容 整体-部分的统一处理
关键机制 包装适配者,转换接口 递归组合,树形结构
模式类型 类/对象结构型 对象结构型
核心角色 Adapter 连接 Target 和 Adaptee Composite 包含 Component
典型场景 复用遗留代码、接入第三方库 树形层次结构(文件系统、GUI、组织架构)

设计工具箱更新:

层次 内容
OO 基础 抽象、封装、多态、继承
OO 原则 封装变化、面向接口编程、组合优于继承、好莱坞原则
OO 模式 策略、简单工厂、工厂方法、抽象工厂、建造者、原型、状态、命令、观察者、中介者、模板方法、适配器组合